From a5e3cc1c6de6377236a87dd1f1e1ad830ad42ed6 Mon Sep 17 00:00:00 2001 From: Andrei Huides Date: Tue, 11 Aug 2026 12:14:56 +0300 Subject: [PATCH 01/19] fix(lh-102436):Local credentials cannot enter the Galaxy artifact --- .github/workflows/ci.yml | 40 +- AGENTS.md | 2 +- INSTALL.md | 5 +- README.md | 5 +- cisco_sccfm_cli/e2e/README.md | 5 +- cisco_sccfm_scripts/_test_setup_tokens.py | 398 ----------------- .../build_ansible_collection.py | 32 +- cisco_sccfm_scripts/setup_tokens.py | 96 +++- cisco_sccfm_scripts/token_store.py | 82 +++- .../verify_ansible_collection.py | 411 ++++++++++++++++++ docs/ansible/modules/onboard_cdfmc_ftd.md | 8 +- docs/ansible/modules/onboard_cdfmc_ftd_ztp.md | 8 +- pyproject.toml | 2 +- sccfm-ansible/.gitignore | 5 +- sccfm-ansible/README.md | 22 +- .../examples/onboard_cdfmc_ftd_ztp.yml | 2 +- sccfm-ansible/galaxy.yml | 38 ++ .../plugins/modules/onboard_cdfmc_ftd.py | 8 +- .../plugins/modules/onboard_cdfmc_ftd_ztp.py | 8 +- skills/sccfm-ansible/SKILL.md | 7 +- tests/test_token_workspace.py | 321 ++++++++++++++ tests/test_verify_ansible_collection.py | 359 +++++++++++++++ 22 files changed, 1385 insertions(+), 479 deletions(-) delete mode 100644 cisco_sccfm_scripts/_test_setup_tokens.py create mode 100644 cisco_sccfm_scripts/verify_ansible_collection.py create mode 100644 tests/test_token_workspace.py create mode 100644 tests/test_verify_ansible_collection.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2db910a8..b6b39b6e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -92,14 +92,44 @@ jobs: # Build Ansible collection with updated version poetry run build-ansible-collection + ARTIFACT_PATH="dist/cisco-sccfm-${NEW_VERSION}.tar.gz" - # Now commit everything together - git add . - git commit -m "bump: version ${NEW_VERSION}" -m "[skip ci]" - git tag "${NEW_TAG}" + # Verify the exact artifact that will be attached to the release. + poetry run python -m cisco_sccfm_scripts.verify_ansible_collection \ + "${ARTIFACT_PATH}" --expected-version "${NEW_VERSION}" + ARTIFACT_SHA256=$(sha256sum "${ARTIFACT_PATH}" | cut -d ' ' -f 1) echo "bumped=true" >> "$GITHUB_OUTPUT" echo "new_tag=${NEW_TAG}" >> "$GITHUB_OUTPUT" + echo "artifact_path=${ARTIFACT_PATH}" >> "$GITHUB_OUTPUT" + echo "artifact_sha256=${ARTIFACT_SHA256}" >> "$GITHUB_OUTPUT" + + - name: Install pinned Gitleaks + if: steps.bump.outputs.bumped == 'true' + run: | + GITLEAKS_BIN_DIR="${RUNNER_TEMP}/gitleaks-bin" + mkdir -p "${GITLEAKS_BIN_DIR}" + GOBIN="${GITLEAKS_BIN_DIR}" go install github.com/gitleaks/gitleaks/v8@v8.30.1 + echo "${GITLEAKS_BIN_DIR}" >> "$GITHUB_PATH" + + - name: Scan exact collection artifact + if: steps.bump.outputs.bumped == 'true' + run: | + gitleaks dir \ + --no-banner \ + --no-color \ + --redact=100 \ + --max-archive-depth=1 \ + "${{ steps.bump.outputs.artifact_path }}" + + - name: Commit and tag verified release + if: steps.bump.outputs.bumped == 'true' + env: + NEW_TAG: ${{ steps.bump.outputs.new_tag }} + run: | + git add . + git commit -m "bump: version ${NEW_TAG#v}" -m "[skip ci]" + git tag "${NEW_TAG}" - name: Push changes and tags if: steps.bump.outputs.bumped == 'true' @@ -117,5 +147,5 @@ jobs: uses: ncipollo/release-action@v1 with: tag: ${{ steps.bump.outputs.new_tag }} - artifacts: "dist/*.whl,dist/*.tar.gz" + artifacts: "dist/*.whl,${{ steps.bump.outputs.artifact_path }}" token: ${{ secrets.GITHUB_TOKEN }} diff --git a/AGENTS.md b/AGENTS.md index 94fe6086..344c92b9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -101,7 +101,7 @@ No MCP servers are currently configured for this project. Skill files under `ski # Build and install locally build-ansible-collection -# Set up tokens and vault +# Set up tokens and vault (generated credential files are ignored and excluded from builds) devkit # select "change-tokens" # Verify inventory plugin diff --git a/INSTALL.md b/INSTALL.md index 432d2f90..a99db732 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -187,6 +187,9 @@ Or run the token setup directly: change-tokens ``` -This will prompt for your region, API token, and vault password, then create all the required files (.env, vars.yml, vault.yml). +This prompts for your region, API token, and vault password, then creates `.env`, `.vault_pass`, +`vars.yml`, and encrypted `vault.yml`. Local credential files are Git-ignored and explicitly +excluded from collection release artifacts. Pass `--path /path/to/examples` to override the +default `sccfm-ansible/examples` directory. See the [Trying out examples](sccfm-ansible/README.md#trying-out-examples) section in the Ansible collection README for the full walkthrough including how to run playbooks. diff --git a/README.md b/README.md index 858e8fcb..ae2eb46c 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,10 @@ The package root exports the supported public service classes and response model - macOS: `brew install ansible` (this includes `ansible-galaxy`; verify with `ansible-galaxy --version`). - Build and install the collection locally: `build-ansible-collection`. -- Set up tokens interactively: `devkit` and select **change-tokens** (saves your API token, creates `.env`, `.vault_pass`, encrypts `group_vars/all/vault.yml`, and sets the region). +- Set up tokens interactively with `devkit` and select **change-tokens**. By default this writes + `.vault_pass` and encrypted `group_vars/all/vault.yml` under `sccfm-ansible/examples`; both are + Git-ignored and explicitly excluded from collection artifacts. Use `--path` to override the + examples directory when needed. - 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. - Configure SCCFM region (`int`, `us`, `eu`, `apj`, `au`, `uae`, `in`, or `ci`) plus `SCCFM_API_TOKEN`; you can set them via env vars or inline (i.e., write the values directly in the inventory file—useful for local dev, but prefer env vars or Ansible Vault for anything shared). - 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/e2e/README.md b/cisco_sccfm_cli/e2e/README.md index 4dd80434..40f6ddd8 100644 --- a/cisco_sccfm_cli/e2e/README.md +++ b/cisco_sccfm_cli/e2e/README.md @@ -8,7 +8,7 @@ Tenant-backed integration tests for the `sccfm-cli` binary. The suite mirrors ` - `playbooks/onboard_vasa.yml` / `playbooks/remove_vasa.yml`: Ansible playbooks that mirror their `sccfm-ansible/e2e/asa/` counterparts but use the `ci-e2e-cli-asa-` name prefix so the CLI suite owns its own device. The shared-device approach (a single `ci-e2e-asa-*` vASA) leaves the device NOT_SYNCED after the Ansible suite mutates it, which then blocks ASA CLI script pushes from this suite. - `conftest.py`: top-level suite ordering (`objects` → `asa` → `access_rules` → `ftd`) plus the session-scoped `e2e_profile` fixture that decodes the Ansible vault and writes a temp `sccfm-cli` profile. - `_runner.py`: `run_cli(...)` subprocess wrapper — the analog of Ansible's `run_playbook()`. Asserts on rc, parses `--format json` stdout, and supports `expect_failure` / `tolerate_any_rc` for idempotency and cleanup paths. -- `_profile.py`: bootstraps credentials by shelling out to `ansible-vault view` and writing a temp profile via `ConfigService.save()`. Reuses `examples/group_vars/all/vault.yml` from the Ansible suite — one source of truth. +- `_profile.py`: bootstraps credentials by shelling out to `ansible-vault view` and writing a temp profile via `ConfigService.save()`. Reuses `examples/group_vars/all/vault.yml` from the Ansible suite — one source of truth. - `_phases.py`: `PhaseCase` dataclass + `PhaseTracker` (skip-on-failed-deps, identical semantics to the Ansible suite). - `_state.py`: in-process cross-phase data store (replaces the `/tmp/ci_*_uid` files used by the Ansible suite). - Per-suite directories (`objects/`, `access_rules/`, `asa/`, `ftd/`): @@ -32,7 +32,8 @@ Tenant-backed integration tests for the `sccfm-cli` binary. The suite mirrors ` poetry run change-tokens ``` - This creates `sccfm-ansible/examples/.vault_pass` and an encrypted `vault.yml`. + This creates `sccfm-ansible/examples/.vault_pass` and an encrypted `vault.yml`. Both files are + Git-ignored and excluded from collection artifacts. 2. Install dev dependencies so `ansible-vault` is available for the runner to decode the vault: diff --git a/cisco_sccfm_scripts/_test_setup_tokens.py b/cisco_sccfm_scripts/_test_setup_tokens.py deleted file mode 100644 index 3d9d071d..00000000 --- a/cisco_sccfm_scripts/_test_setup_tokens.py +++ /dev/null @@ -1,398 +0,0 @@ -#!/usr/bin/env python3 - -# Copyright 2026 Cisco Systems, Inc. and its affiliates -# -# SPDX-License-Identifier: Apache-2.0 - -"""Integration tests for setup_tokens and vault-based token_store. - -Backs up touched files before running, and restores them afterward -so the test is fully repeatable. -""" -import shutil -import subprocess -import tempfile -from collections.abc import Iterator -from pathlib import Path - -import pytest - -# ── Paths ──────────────────────────────────────────────────────── - -ROOT = Path(__file__).resolve().parent.parent -EXAMPLES = ROOT / "sccfm-ansible" / "examples" -VAULT_PATH = EXAMPLES / "group_vars" / "all" / "vault.yml" -VAULT_PASS_PATH = EXAMPLES / ".vault_pass" -VARS_PATH = EXAMPLES / "group_vars" / "all" / "vars.yml" -ENV_PATH = ROOT / ".env" - -_BACKUP_SUFFIX = ".test_bak" - -_MANAGED_FILES = [VAULT_PATH, VAULT_PASS_PATH, VARS_PATH, ENV_PATH] - - -# ── Helpers ────────────────────────────────────────────────────── - - -def _backup(path: Path) -> None: - if path.exists(): - dst = path.with_suffix(path.suffix + _BACKUP_SUFFIX) - shutil.copy2(path, dst) - print(f" backed up {path.name}") - - -def _restore(path: Path) -> None: - bak = path.with_suffix(path.suffix + _BACKUP_SUFFIX) - if bak.exists(): - shutil.copy2(bak, path) - bak.unlink() - print(f" restored {path.name}") - elif path.exists(): - path.unlink() - print(f" removed {path.name} (no backup existed)") - - -def backup_all() -> None: - print("\n--- backup originals ---") - for p in _MANAGED_FILES: - _backup(p) - - -def restore_all() -> None: - print("\n--- restore originals ---") - for p in _MANAGED_FILES: - _restore(p) - - -@pytest.fixture(scope="session", autouse=True) -def preserve_managed_files() -> Iterator[None]: - """Restore ignored credential files when tests are invoked through pytest.""" - backup_all() - try: - yield - finally: - restore_all() - - -# ── Tests ──────────────────────────────────────────────────────── - - -def test_ansible_vault_available() -> None: - from cisco_sccfm_scripts.setup_tokens import _verify_ansible_vault - - _verify_ansible_vault() - print("PASS: ansible-vault available") - - -def test_vault_pass_detected() -> None: - from cisco_sccfm_scripts.setup_tokens import _ensure_vault_pass - - vp = _ensure_vault_pass(EXAMPLES) - assert vp == VAULT_PASS_PATH, f"Expected {VAULT_PASS_PATH}, got {vp}" - print("PASS: vault password file detected") - - -def test_vault_token_store_round_trip() -> None: - """Save tokens to vault, read them back, verify contents.""" - from cisco_sccfm_scripts.token_store import SavedToken, VaultTokenStore - - store = VaultTokenStore(EXAMPLES) - - # Remove vault so we start clean - if VAULT_PATH.exists(): - VAULT_PATH.unlink() - - assert store.list_tokens() == [], "Store should start empty" - - tok1 = SavedToken(name="alpha", region="us", token="tok-aaa111") - tok2 = SavedToken(name="beta", region="eu", token="tok-bbb222") - - # Save both tokens with tok1 as active - store.save_active_and_tokens(tok1, [tok1, tok2]) - - # Vault file should be encrypted - first_line = VAULT_PATH.read_text().split("\n")[0] - assert "$ANSIBLE_VAULT" in first_line, f"Not encrypted: {first_line}" - - # Read back saved tokens - tokens = store.list_tokens() - assert len(tokens) == 2, f"Expected 2 tokens, got {len(tokens)}" - assert tokens[0].name == "alpha" - assert tokens[1].name == "beta" - assert tokens[0].token == "tok-aaa111" - assert tokens[1].region == "eu" - - # Verify active token via ansible-vault view - result = subprocess.run( - ["ansible-vault", "view", str(VAULT_PATH), "--vault-password-file", str(VAULT_PASS_PATH)], - capture_output=True, - text=True, - ) - assert result.returncode == 0, f"Decrypt failed: {result.stderr}" - assert "tok-aaa111" in result.stdout, "Active token not in vault" - - # Now switch active to tok2 and add a third - tok3 = SavedToken(name="gamma", region="int", token="tok-ccc333") - store.save_active_and_tokens(tok2, [tok1, tok2, tok3]) - - tokens = store.list_tokens() - assert len(tokens) == 3, f"Expected 3 tokens, got {len(tokens)}" - - result = subprocess.run( - ["ansible-vault", "view", str(VAULT_PATH), "--vault-password-file", str(VAULT_PASS_PATH)], - capture_output=True, - text=True, - ) - assert "tok-bbb222" in result.stdout, "Active token should be tok2 now" - - print("PASS: vault token store round-trip") - print(f"Decrypted content:\n{result.stdout}") - - -def test_vars_region_updated() -> None: - from cisco_sccfm_scripts.setup_tokens import _update_vars_region - - _update_vars_region(EXAMPLES, "eu") - content = VARS_PATH.read_text() - assert "sccfm_region: eu" in content, "Region not updated in vars.yml" - print("PASS: vars.yml region updated") - - -def test_env_file_updates_in_place() -> None: - """Verify that existing .env is updated in-place, preserving comments.""" - from cisco_sccfm_scripts.setup_tokens import _write_env_file - - seed = ( - "# Copy this file to .env and fill in your values\n" - "export SCCFM_REGION=int\n" - 'export SCCFM_API_TOKEN="old-token"\n' - ) - ENV_PATH.write_text(seed) - - _write_env_file(ROOT, "eu", "new-tok-456") - content = ENV_PATH.read_text() - - assert "export SCCFM_REGION=eu" in content, "Region not updated" - assert 'export SCCFM_API_TOKEN="new-tok-456"' in content, "Token not updated" - assert "Copy this file" in content, "Original comment was lost" - print("PASS: .env updated in-place (comments preserved)") - - -def test_env_file_created_from_example() -> None: - """When .env doesn't exist, it should be seeded from .env.example.""" - from cisco_sccfm_scripts.setup_tokens import _write_env_file - - if ENV_PATH.exists(): - ENV_PATH.unlink() - - _write_env_file(ROOT, "apj", "fresh-tok-789") - content = ENV_PATH.read_text() - - assert "export SCCFM_REGION=apj" in content, "Region not set" - assert 'export SCCFM_API_TOKEN="fresh-tok-789"' in content, "Token not set" - assert "direnv" in content or "SCCFM" in content, "Missing template content" - print("PASS: .env created from .env.example template") - - -# ── Headless-mode tests ───────────────────────────────────────── - - -def test_upsert_env_var_replaces_existing() -> None: - """_upsert_env_var should replace an existing variable in-place.""" - from cisco_sccfm_scripts.setup_tokens import _upsert_env_var - - content = "# comment\nexport FOO=old\nexport BAR=keep\n" - result = _upsert_env_var(content, "FOO", "new") - assert "export FOO=new" in result, "Variable not replaced" - assert "export BAR=keep" in result, "Other variable was lost" - assert "# comment" in result, "Comment was lost" - print("PASS: _upsert_env_var replaces existing variable") - - -def test_upsert_env_var_appends_missing() -> None: - """_upsert_env_var should append when the variable doesn't exist.""" - from cisco_sccfm_scripts.setup_tokens import _upsert_env_var - - content = "# comment\nexport OTHER=value\n" - result = _upsert_env_var(content, "NEW_VAR", "hello") - assert "export NEW_VAR=hello" in result, "Variable not appended" - assert "export OTHER=value" in result, "Existing variable was lost" - print("PASS: _upsert_env_var appends missing variable") - - -def test_merge_token_adds_new() -> None: - """_merge_token should add a new token when name doesn't exist.""" - from unittest.mock import MagicMock - - from cisco_sccfm_scripts.setup_tokens import _merge_token - from cisco_sccfm_scripts.token_store import SavedToken - - store = MagicMock() - store.list_tokens.return_value = [ - SavedToken(name="alpha", region="us", token="tok-aaa"), - ] - - new_tok = SavedToken(name="beta", region="eu", token="tok-bbb") - result = _merge_token(store, new_tok) - - assert len(result) == 2, f"Expected 2 tokens, got {len(result)}" - names = [t.name for t in result] - assert "alpha" in names and "beta" in names - print("PASS: _merge_token adds new token") - - -def test_merge_token_replaces_existing() -> None: - """_merge_token should replace a token with the same name.""" - from unittest.mock import MagicMock - - from cisco_sccfm_scripts.setup_tokens import _merge_token - from cisco_sccfm_scripts.token_store import SavedToken - - store = MagicMock() - store.list_tokens.return_value = [ - SavedToken(name="alpha", region="us", token="tok-old"), - SavedToken(name="beta", region="eu", token="tok-bbb"), - ] - - updated = SavedToken(name="alpha", region="apj", token="tok-new") - result = _merge_token(store, updated) - - assert len(result) == 2, f"Expected 2 tokens, got {len(result)}" - alpha = next(t for t in result if t.name == "alpha") - assert alpha.token == "tok-new", "Token not replaced" - assert alpha.region == "apj", "Region not updated" - print("PASS: _merge_token replaces existing token") - - -def test_merge_token_empty_store() -> None: - """_merge_token should work on an empty store.""" - from unittest.mock import MagicMock - - from cisco_sccfm_scripts.setup_tokens import _merge_token - from cisco_sccfm_scripts.token_store import SavedToken - - store = MagicMock() - store.list_tokens.return_value = [] - - tok = SavedToken(name="first", region="us", token="tok-111") - result = _merge_token(store, tok) - - assert len(result) == 1 - assert result[0].name == "first" - print("PASS: _merge_token works on empty store") - - -def test_ensure_vault_pass_headless_uses_existing(tmp_path: Path) -> None: - """When .vault_pass already exists, return it without writing.""" - from cisco_sccfm_scripts.setup_tokens import _ensure_vault_pass_headless - - vault_pass = tmp_path / ".vault_pass" - vault_pass.write_text("existing-password\n") - - result = _ensure_vault_pass_headless(tmp_path, vault_password="ignored") - assert result == vault_pass - assert vault_pass.read_text() == "existing-password\n", "File should not be overwritten" - print("PASS: _ensure_vault_pass_headless uses existing file") - - -def test_ensure_vault_pass_headless_creates_new(tmp_path: Path) -> None: - """When .vault_pass is missing, create it from --vault-password.""" - from cisco_sccfm_scripts.setup_tokens import _ensure_vault_pass_headless - - result = _ensure_vault_pass_headless(tmp_path, vault_password="my-secret") - assert result == tmp_path / ".vault_pass" - assert result.read_text() == "my-secret\n" - assert oct(result.stat().st_mode & 0o777) == "0o600", "File should be chmod 600" - print("PASS: _ensure_vault_pass_headless creates new file") - - -def test_ensure_vault_pass_headless_raises_without_password(tmp_path: Path) -> None: - """When .vault_pass is missing and no password supplied, raise.""" - import click - - from cisco_sccfm_scripts.setup_tokens import _ensure_vault_pass_headless - - try: - _ensure_vault_pass_headless(tmp_path, vault_password=None) - raise AssertionError("Should have raised ClickException") - except click.ClickException as exc: - assert "--vault-password" in str(exc), f"Unexpected message: {exc}" - print("PASS: _ensure_vault_pass_headless raises without password") - - -def test_headless_mode_detection() -> None: - """Verify that main() routes to headless when --region and --api-token are set.""" - from unittest.mock import patch - - from click.testing import CliRunner - - from cisco_sccfm_scripts.setup_tokens import main - - runner = CliRunner() - - # Missing --api-token → error - result = runner.invoke(main, ["--region", "us"]) - assert result.exit_code != 0, "Should fail with only --region" - assert "both --region and --api-token" in result.output - - # Missing --region → error - result = runner.invoke(main, ["--api-token", "tok-123"]) - assert result.exit_code != 0, "Should fail with only --api-token" - assert "both --region and --api-token" in result.output - - # Both supplied → should call _run_headless (mock it out) - with patch("cisco_sccfm_scripts.setup_tokens._run_headless") as mock_headless: - result = runner.invoke( - main, - ["--region", "us", "--api-token", "tok-123", "--name", "myenv"], - ) - assert result.exit_code == 0, f"Unexpected error: {result.output}" - mock_headless.assert_called_once() - call_kwargs = mock_headless.call_args - assert call_kwargs.kwargs["region"] == "us" - assert call_kwargs.kwargs["api_token"] == "tok-123" - assert call_kwargs.kwargs["name"] == "myenv" - - print("PASS: headless mode detection works correctly") - - -def run_tests() -> None: - test_ansible_vault_available() - test_vault_pass_detected() - test_vault_token_store_round_trip() - test_vars_region_updated() - test_env_file_updates_in_place() - test_env_file_created_from_example() - - # Headless / pure-logic tests (use a temp dir) - test_upsert_env_var_replaces_existing() - test_upsert_env_var_appends_missing() - test_merge_token_adds_new() - test_merge_token_replaces_existing() - test_merge_token_empty_store() - - with tempfile.TemporaryDirectory() as td: - test_ensure_vault_pass_headless_uses_existing(Path(td)) - with tempfile.TemporaryDirectory() as td: - test_ensure_vault_pass_headless_creates_new(Path(td)) - with tempfile.TemporaryDirectory() as td: - test_ensure_vault_pass_headless_raises_without_password(Path(td)) - - test_headless_mode_detection() - - print("\n=== ALL TESTS PASSED ===") - - -# ── Main ───────────────────────────────────────────────────────── - - -def main() -> None: - backup_all() - try: - run_tests() - finally: - restore_all() - - -if __name__ == "__main__": - main() diff --git a/cisco_sccfm_scripts/build_ansible_collection.py b/cisco_sccfm_scripts/build_ansible_collection.py index 40292b19..49ad8b2c 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 os import shutil import subprocess import sys @@ -13,6 +14,21 @@ import yaml +from cisco_sccfm_scripts.verify_ansible_collection import ( + ArtifactVerificationError, + verify_collection_artifact, +) + + +def _find_collection_symlink(collection_dir: Path) -> Path | None: + """Return the first symlink without following targets outside the collection.""" + for root, directories, files in os.walk(collection_dir, followlinks=False): + for name in sorted([*directories, *files]): + candidate = Path(root) / name + if candidate.is_symlink(): + return candidate.relative_to(collection_dir) + return None + def main() -> int: """Build the Ansible collection tarball.""" @@ -26,6 +42,11 @@ def main() -> int: print("🎭 Building Ansible collection...") + symlink = _find_collection_symlink(collection_dir) + if symlink is not None: + print(f"❌ Collection source contains a symlink: {symlink}", file=sys.stderr) + return 1 + # Copy the root LICENSE into the collection so galaxy.yml's `license_file` # resolves and the license ships in the tarball (Galaxy import requires it). shutil.copyfile(license_src, license_dst) @@ -63,7 +84,16 @@ def main() -> int: print(f"❌ Failed to build Ansible collection:\n{result.stderr}", file=sys.stderr) return 1 - print("✅ Ansible collection built successfully") + artifact_path = dist_dir / f"cisco-sccfm-{version}.tar.gz" + try: + verification = verify_collection_artifact(artifact_path, expected_version=version) + except ArtifactVerificationError as exc: + artifact_path.unlink(missing_ok=True) + print(f"❌ Collection artifact rejected: {exc}", file=sys.stderr) + return 1 + + print("✅ Ansible collection built and verified successfully") + print(f"🔐 SHA-256: {verification.sha256}") print(result.stdout) return 0 diff --git a/cisco_sccfm_scripts/setup_tokens.py b/cisco_sccfm_scripts/setup_tokens.py index 5d544168..49c55828 100644 --- a/cisco_sccfm_scripts/setup_tokens.py +++ b/cisco_sccfm_scripts/setup_tokens.py @@ -35,9 +35,11 @@ from __future__ import annotations +import os import re import stat import subprocess +import tempfile from pathlib import Path import click @@ -75,19 +77,20 @@ def _project_root() -> Path: # ── Path resolution ────────────────────────────────────────────── -def _resolve_examples_path(path: str | None) -> Path: - """Return the absolute examples directory, raising if not found.""" - if path: - resolved = Path(path).resolve() +def _resolve_examples_path(path: str | Path | None) -> Path: + """Return the absolute examples directory, raising if it cannot be found.""" + if path is not None: + resolved = Path(path).expanduser().resolve() if not resolved.is_dir(): raise click.ClickException(f"Directory not found: {resolved}") + if not os.access(resolved, os.W_OK): + raise click.ClickException(f"Examples directory is not writable: {resolved}") return resolved default = Path.cwd() / _DEFAULT_EXAMPLES_PATH if default.is_dir(): return default.resolve() - # Maybe the user already cd'd into the examples dir cwd = Path.cwd() if (cwd / "group_vars").is_dir() and (cwd / ".vault_pass.example").exists(): return cwd.resolve() @@ -98,6 +101,35 @@ def _resolve_examples_path(path: str | None) -> Path: ) +def _secure_directory(path: Path) -> None: + """Create a user-private directory and enforce mode 0700.""" + if path.is_symlink(): + raise click.ClickException(f"Refusing to use a symlinked private directory: {path}") + path.mkdir(parents=True, exist_ok=True, mode=stat.S_IRWXU) + path.chmod(stat.S_IRWXU) + + +def _write_private_text(path: Path, content: str) -> None: + """Atomically write UTF-8 text with mode 0600.""" + if path.parent.is_symlink(): + raise click.ClickException( + f"Refusing to write through a symlinked directory: {path.parent}" + ) + path.parent.mkdir(parents=True, exist_ok=True) + descriptor, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) + temporary_path = Path(temporary_name) + try: + os.fchmod(descriptor, stat.S_IRUSR | stat.S_IWUSR) + with os.fdopen(descriptor, "w", encoding="utf-8") as handle: + handle.write(content) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary_path, path) + path.chmod(stat.S_IRUSR | stat.S_IWUSR) + finally: + temporary_path.unlink(missing_ok=True) + + # ── Ansible-vault availability ─────────────────────────────────── @@ -244,6 +276,8 @@ def _write_env_file(root: Path, region: str, api_token: str) -> Path: """ env_path = root / ".env" example_path = root / _ENV_EXAMPLE + if env_path.is_symlink(): + raise click.ClickException(f"Refusing to update a symlinked credential file: {env_path}") if env_path.exists(): content = env_path.read_text() @@ -257,7 +291,7 @@ def _write_env_file(root: Path, region: str, api_token: str) -> Path: content = _upsert_env_var(content, "SCCFM_REGION", region) content = _upsert_env_var(content, "SCCFM_API_TOKEN", f'"{api_token}"') - env_path.write_text(content) + _write_private_text(env_path, content) console.print(f"[green]Updated .env file:[/green] {env_path}") return env_path @@ -282,7 +316,12 @@ def _ensure_vault_pass(examples_path: Path) -> Path: """Return the vault password file, creating it interactively if needed.""" vault_pass_path = examples_path / ".vault_pass" + if vault_pass_path.is_symlink(): + raise click.ClickException( + f"Refusing to use a symlinked credential file: {vault_pass_path}" + ) if vault_pass_path.exists(): + vault_pass_path.chmod(stat.S_IRUSR | stat.S_IWUSR) console.print(f"\n[dim]Using existing vault password file: {vault_pass_path}[/dim]") return vault_pass_path @@ -295,8 +334,7 @@ def _ensure_vault_pass(examples_path: Path) -> Path: if not password.strip(): raise click.ClickException("Vault password cannot be empty.") - vault_pass_path.write_text(password.strip() + "\n") - vault_pass_path.chmod(stat.S_IRUSR | stat.S_IWUSR) # chmod 600 + _write_private_text(vault_pass_path, password.strip() + "\n") console.print(f"[green]Created vault password file:[/green] {vault_pass_path}") return vault_pass_path @@ -307,7 +345,12 @@ def _ensure_vault_pass_headless(examples_path: Path, vault_password: str | None) """ vault_pass_path = examples_path / ".vault_pass" + if vault_pass_path.is_symlink(): + raise click.ClickException( + f"Refusing to use a symlinked credential file: {vault_pass_path}" + ) if vault_pass_path.exists(): + vault_pass_path.chmod(stat.S_IRUSR | stat.S_IWUSR) console.print(f"[dim]Using existing vault password file: {vault_pass_path}[/dim]") return vault_pass_path @@ -316,8 +359,7 @@ def _ensure_vault_pass_headless(examples_path: Path, vault_password: str | None) "No vault password file found and --vault-password was not supplied." ) - vault_pass_path.write_text(vault_password.strip() + "\n") - vault_pass_path.chmod(stat.S_IRUSR | stat.S_IWUSR) # chmod 600 + _write_private_text(vault_pass_path, vault_password.strip() + "\n") console.print(f"[green]Created vault password file:[/green] {vault_pass_path}") return vault_pass_path @@ -336,7 +378,10 @@ def _merge_token(store: VaultTokenStore, token: SavedToken) -> list[SavedToken]: def _update_vars_region(examples_path: Path, region: str) -> None: """Set sccfm_region in group_vars/all/vars.yml, preserving other content.""" vars_path = examples_path / "group_vars" / "all" / "vars.yml" - vars_path.parent.mkdir(parents=True, exist_ok=True) + if vars_path.is_symlink(): + raise click.ClickException(f"Refusing to update a symlinked workspace file: {vars_path}") + _secure_directory(examples_path / "group_vars") + _secure_directory(vars_path.parent) if vars_path.exists(): content = vars_path.read_text() @@ -349,15 +394,16 @@ def _update_vars_region(examples_path: Path, region: str) -> None: ) else: updated = content.rstrip() + f"\nsccfm_region: {region}\n" - vars_path.write_text(updated) + _write_private_text(vars_path, updated) else: - vars_path.write_text( + _write_private_text( + vars_path, "---\n" "# Plain variables (not sensitive)\n" "# These can be committed to version control\n" "\n" "# SCCFM connection settings\n" - f"sccfm_region: {region}\n" + f"sccfm_region: {region}\n", ) console.print(f"[green]Set region to '{region}' in:[/green] {vars_path}") @@ -372,7 +418,7 @@ def _run_headless( name: str, profile: str, vault_password: str | None, - path: str | None, + path: Path | None, ) -> None: """Execute the full setup without any interactive prompts.""" root = _project_root() @@ -421,7 +467,7 @@ def _run_headless( @click.command( help="Setup SCCFM API tokens, .env, and Ansible Vault.\n\n" - "Runs interactively by default. Supply --region and --api-token " + "Runs interactively by default. Supply --region and --api-token " "to run in headless mode (no prompts).", ) @click.option( @@ -459,7 +505,14 @@ def _run_headless( @click.option( "--path", default=None, - type=click.Path(resolve_path=True), + type=click.Path( + exists=True, + file_okay=False, + dir_okay=True, + writable=True, + resolve_path=True, + path_type=Path, + ), help=f"Path to the ansible examples directory (default: {_DEFAULT_EXAMPLES_PATH}).", ) def main( @@ -468,7 +521,7 @@ def main( name: str, profile: str, vault_password: str | None, - path: str | None, + path: Path | None, ) -> None: """Setup tokens — auto-detects interactive vs headless mode.""" headless = region is not None or api_token is not None @@ -491,7 +544,7 @@ def main( console.print("\n[dim]Cancelled.[/dim]") -def _run_setup(path: str | None) -> None: +def _run_setup(path: Path | None) -> None: """Inner setup logic — separated so main() can catch exits cleanly.""" console.print( Panel( @@ -541,8 +594,9 @@ def _run_setup(path: str | None) -> None: console.print(summary) console.print( "\n[green]You can now run playbooks with:[/green]\n" - " ansible-playbook -i examples/inventory.sccfm.yml \\\n" - " examples/show_devices.yml --vault-password-file examples/.vault_pass" + f" ansible-playbook -i {examples_path / 'inventory.sccfm.yml'} \\\n" + f" {examples_path / 'show_devices.yml'} " + f"--vault-password-file {examples_path / '.vault_pass'}" ) diff --git a/cisco_sccfm_scripts/token_store.py b/cisco_sccfm_scripts/token_store.py index 25ec89bc..afc2663e 100644 --- a/cisco_sccfm_scripts/token_store.py +++ b/cisco_sccfm_scripts/token_store.py @@ -24,7 +24,10 @@ from __future__ import annotations +import os +import stat import subprocess +import tempfile from dataclasses import dataclass from pathlib import Path from typing import cast @@ -90,6 +93,8 @@ def _decrypt_vault(self) -> dict[str, object] | None: """Decrypt vault.yml and return parsed YAML, or None.""" if not self._vault_path.exists() or not self._vault_pass_path.exists(): return None + if self._vault_path.is_symlink() or self._vault_pass_path.is_symlink(): + raise RuntimeError("Refusing to read a symlinked vault credential file") result = subprocess.run( [ @@ -109,25 +114,64 @@ def _decrypt_vault(self) -> dict[str, object] | None: return data def _encrypt_vault(self, payload: dict[str, object]) -> Path: - """Write *payload* as YAML to vault.yml, encrypting in-place.""" - self._vault_path.parent.mkdir(parents=True, exist_ok=True) + """Encrypt *payload* in a private temporary file, then replace atomically.""" + group_vars_path = self._vault_path.parent.parent + if group_vars_path.is_symlink() or self._vault_path.parent.is_symlink(): + raise RuntimeError("Refusing to write through a symlinked vault directory") + if self._vault_path.is_symlink() or self._vault_pass_path.is_symlink(): + raise RuntimeError("Refusing to use a symlinked vault credential file") + group_vars_path.mkdir(parents=True, exist_ok=True, mode=stat.S_IRWXU) + group_vars_path.chmod(stat.S_IRWXU) + self._vault_path.parent.mkdir(parents=True, exist_ok=True, mode=stat.S_IRWXU) + self._vault_path.parent.chmod(stat.S_IRWXU) content = "---\n" + yaml.dump(payload, default_flow_style=False, sort_keys=False) - - # Write plaintext, then encrypt in-place - self._vault_path.write_text(content) - result = subprocess.run( - [ - "ansible-vault", - "encrypt", - str(self._vault_path), - "--vault-password-file", - str(self._vault_pass_path), - ], - capture_output=True, - text=True, + plaintext_descriptor, plaintext_name = tempfile.mkstemp( + prefix=".vault.plaintext.", + suffix=".tmp", + dir=self._vault_path.parent, ) - if result.returncode != 0: - raise RuntimeError(f"ansible-vault encrypt failed:\n{result.stderr.strip()}") - - return self._vault_path + plaintext_path = Path(plaintext_name) + ciphertext_path: Path | None = None + try: + os.fchmod(plaintext_descriptor, stat.S_IRUSR | stat.S_IWUSR) + with os.fdopen(plaintext_descriptor, "w", encoding="utf-8") as handle: + handle.write(content) + handle.flush() + os.fsync(handle.fileno()) + ciphertext_descriptor, ciphertext_name = tempfile.mkstemp( + prefix=".vault.ciphertext.", + suffix=".tmp", + dir=self._vault_path.parent, + ) + ciphertext_path = Path(ciphertext_name) + with os.fdopen(ciphertext_descriptor, "wb") as encrypted: + os.fchmod(encrypted.fileno(), stat.S_IRUSR | stat.S_IWUSR) + + result = subprocess.run( + [ + "ansible-vault", + "encrypt", + str(plaintext_path), + "--output", + str(ciphertext_path), + "--vault-password-file", + str(self._vault_pass_path), + ], + capture_output=True, + text=True, + ) + if result.returncode != 0: + raise RuntimeError(f"ansible-vault encrypt failed:\n{result.stderr.strip()}") + with ciphertext_path.open("rb") as encrypted: + if not encrypted.readline(64).startswith(b"$ANSIBLE_VAULT;"): + raise RuntimeError("ansible-vault did not produce valid encrypted output") + + ciphertext_path.chmod(stat.S_IRUSR | stat.S_IWUSR) + os.replace(ciphertext_path, self._vault_path) + self._vault_path.chmod(stat.S_IRUSR | stat.S_IWUSR) + return self._vault_path + finally: + plaintext_path.unlink(missing_ok=True) + if ciphertext_path is not None: + ciphertext_path.unlink(missing_ok=True) diff --git a/cisco_sccfm_scripts/verify_ansible_collection.py b/cisco_sccfm_scripts/verify_ansible_collection.py new file mode 100644 index 00000000..ae2929a7 --- /dev/null +++ b/cisco_sccfm_scripts/verify_ansible_collection.py @@ -0,0 +1,411 @@ +# Copyright 2026 Cisco Systems, Inc. and its affiliates +# +# SPDX-License-Identifier: Apache-2.0 + +"""Fail-closed verification for built ``cisco.sccfm`` collection artifacts.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import tarfile +from dataclasses import dataclass +from pathlib import Path, PurePosixPath +from typing import Any, Sequence, cast + +_MAX_MEMBERS = 2_000 +_MAX_ARCHIVE_BYTES = 20 * 1024 * 1024 +_MAX_MEMBER_BYTES = 10 * 1024 * 1024 +_MAX_TOTAL_BYTES = 50 * 1024 * 1024 + +_ALLOWED_TOP_LEVEL = frozenset( + { + "FILES.json", + "LICENSE", + "MANIFEST.json", + "README.md", + "__init__.py", + "examples", + "meta", + "plugins", + "requirements.txt", + } +) +_REQUIRED_MEMBERS = frozenset( + { + "FILES.json", + "LICENSE", + "MANIFEST.json", + "README.md", + "examples/.vault_pass.example", + "examples/group_vars/all/vault.yml.example", + "meta/runtime.yml", + "plugins/inventory", + "plugins/module_utils", + "plugins/modules", + "requirements.txt", + } +) +_SAFE_CREDENTIAL_TEMPLATES = frozenset( + { + "examples/.vault_pass.example", + "examples/group_vars/all/vault.yml.example", + } +) +_ALLOWED_EXAMPLE_PATHS = frozenset( + { + "examples", + "examples/.vault_pass.example", + "examples/access_rules.yml", + "examples/add_object_override.yml", + "examples/asa_ha_check.yml", + "examples/change_asa_boot_image.yml", + "examples/change_asa_local_password.yml", + "examples/configure_manager.yml", + "examples/create_network_groups.yml", + "examples/create_network_objects.yml", + "examples/delete_network_groups.yml", + "examples/delete_network_objects.yml", + "examples/deploy_cdfmc_ftd.yml", + "examples/execute_asa_cli.yml", + "examples/execute_ftd_cli.yml", + "examples/group_vars", + "examples/group_vars/all", + "examples/group_vars/all/vars.yml", + "examples/group_vars/all/vault.yml.example", + "examples/inventory.sccfm.yml", + "examples/list_asa_boot_registry.yml", + "examples/list_asa_compatible_versions.yml", + "examples/list_asa_disk_files.yml", + "examples/list_asa_local_users.yml", + "examples/list_asa_not_on_version.yml", + "examples/list_ftd_compatible_versions.yml", + "examples/list_ftd_not_on_version.yml", + "examples/list_network_groups.yml", + "examples/list_network_objects.yml", + "examples/manage_asa_shun.yml", + "examples/manage_network_group_members.yml", + "examples/network_objects.yml", + "examples/onboard_asas.yml", + "examples/onboard_cdfmc_ftd.yml", + "examples/onboard_cdfmc_ftd_ztp.yml", + "examples/show_devices.yml", + "examples/trigger_asa_upgrade.yml", + "examples/trigger_ftd_upgrade.yml", + "examples/update_network_groups.yml", + "examples/update_network_objects.yml", + } +) +_FORBIDDEN_DIRECTORY_NAMES = frozenset( + { + ".git", + ".mypy_cache", + ".pytest_cache", + ".tox", + ".venv", + "__pycache__", + } +) +_FORBIDDEN_EXACT_NAMES = frozenset( + { + ".env", + ".netrc", + ".vault_pass", + "credentials", + "credentials.json", + "credentials.yaml", + "credentials.yml", + "secrets.yaml", + "secrets.yml", + "vault.yaml", + "vault.yml", + } +) +_FORBIDDEN_KEY_PREFIXES = ( + "id_dsa", + "id_ecdsa", + "id_ed25519", + "id_rsa", +) +_FORBIDDEN_SUFFIXES = ( + ".bak", + ".db", + ".jks", + ".kdbx", + ".key", + ".keystore", + ".log", + ".orig", + ".p12", + ".pem", + ".pfx", + ".retry", + ".sqlite", + ".sqlite3", + ".swo", + ".swp", +) +_CONTENT_RULES: tuple[tuple[str, re.Pattern[bytes]], ...] = ( + ( + "private key", + re.compile(rb"-----BEGIN (?:[A-Z0-9]+ |OPENSSH )?PRIVATE KEY-----"), + ), + ("AWS access key", re.compile(rb"\b(?:AKIA|ASIA)[A-Z0-9]{16}\b")), + ("GitHub token", re.compile(rb"\bgh[pousr]_[A-Za-z0-9_]{20,}\b")), + ( + "JWT-like token", + re.compile(rb"\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b"), + ), +) + + +class ArtifactVerificationError(RuntimeError): + """Raised when a collection artifact violates the release policy.""" + + +@dataclass(frozen=True) +class ArtifactVerification: + """Summary of a successfully verified artifact.""" + + sha256: str + file_count: int + uncompressed_bytes: int + + +def _safe_member_name(raw_name: str) -> str: + """Validate and return one canonical POSIX archive member path.""" + if not raw_name or "\x00" in raw_name or "\\" in raw_name or raw_name.startswith("/"): + raise ArtifactVerificationError("artifact contains an invalid member path") + raw_parts = raw_name.split("/") + if any(part in {"", ".", ".."} for part in raw_parts): + raise ArtifactVerificationError("artifact contains a non-canonical member path") + canonical = PurePosixPath(raw_name).as_posix() + if canonical != raw_name: + raise ArtifactVerificationError("artifact contains a non-canonical member path") + if len(canonical) > 500: + raise ArtifactVerificationError("artifact contains an excessively long member path") + return canonical + + +def _check_member_path(name: str) -> None: + """Reject paths that do not belong in the public collection.""" + path = PurePosixPath(name) + if path.parts[0] not in _ALLOWED_TOP_LEVEL: + raise ArtifactVerificationError(f"unexpected top-level artifact path: {path.parts[0]}") + + lowered_parts = tuple(part.lower() for part in path.parts) + if any(part in _FORBIDDEN_DIRECTORY_NAMES for part in lowered_parts[:-1]): + raise ArtifactVerificationError(f"forbidden runtime directory in artifact: {name}") + if path.parts[0] == "examples" and name not in _ALLOWED_EXAMPLE_PATHS: + raise ArtifactVerificationError(f"unreviewed examples path in artifact: {name}") + if name in _SAFE_CREDENTIAL_TEMPLATES: + return + + basename = lowered_parts[-1] + if basename in _FORBIDDEN_EXACT_NAMES: + raise ArtifactVerificationError(f"forbidden credential path in artifact: {name}") + if basename.startswith(".env") or basename.startswith(".vault_pass"): + raise ArtifactVerificationError(f"forbidden credential backup in artifact: {name}") + if basename.startswith("vault.yml.") or basename.startswith("vault.yaml."): + raise ArtifactVerificationError(f"forbidden vault backup in artifact: {name}") + if basename.startswith(_FORBIDDEN_KEY_PREFIXES): + raise ArtifactVerificationError(f"forbidden private-key path in artifact: {name}") + if basename.endswith(_FORBIDDEN_SUFFIXES) or basename.endswith("~"): + raise ArtifactVerificationError(f"forbidden local-data path in artifact: {name}") + + +def _read_member(archive: tarfile.TarFile, member: tarfile.TarInfo) -> bytes: + """Read a size-bounded regular member.""" + extracted = archive.extractfile(member) + if extracted is None: + raise ArtifactVerificationError(f"could not read artifact member: {member.name}") + data = extracted.read(_MAX_MEMBER_BYTES + 1) + if len(data) > _MAX_MEMBER_BYTES: + raise ArtifactVerificationError(f"artifact member exceeds size limit: {member.name}") + return data + + +def _load_json_member( + archive: tarfile.TarFile, member: tarfile.TarInfo +) -> tuple[dict[str, Any], bytes]: + """Load one required JSON object without exposing its contents in errors.""" + raw = _read_member(archive, member) + try: + parsed: object = json.loads(raw) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise ArtifactVerificationError(f"invalid JSON in artifact member: {member.name}") from exc + if not isinstance(parsed, dict): + raise ArtifactVerificationError(f"expected a JSON object in artifact member: {member.name}") + return cast(dict[str, Any], parsed), raw + + +def _manifest_entries(files_manifest: dict[str, Any]) -> dict[str, dict[str, Any]]: + """Return a validated, duplicate-free FILES.json entry map.""" + raw_entries = files_manifest.get("files") + if not isinstance(raw_entries, list): + raise ArtifactVerificationError("FILES.json has no valid files list") + + entries: dict[str, dict[str, Any]] = {} + for raw_entry in raw_entries: + if not isinstance(raw_entry, dict): + raise ArtifactVerificationError("FILES.json contains an invalid entry") + entry = cast(dict[str, Any], raw_entry) + name = entry.get("name") + if not isinstance(name, str): + raise ArtifactVerificationError("FILES.json contains an entry without a valid name") + if name == ".": + if entry.get("ftype") != "dir": + raise ArtifactVerificationError("FILES.json root entry is not a directory") + continue + name = _safe_member_name(name) + if name in entries: + raise ArtifactVerificationError(f"FILES.json contains a duplicate path: {name}") + entries[name] = entry + return entries + + +def _verify_manifests( + archive: tarfile.TarFile, + members: dict[str, tarfile.TarInfo], + expected_version: str, +) -> None: + """Verify Ansible metadata, member declarations, and file hashes.""" + manifest, _ = _load_json_member(archive, members["MANIFEST.json"]) + files_manifest, files_raw = _load_json_member(archive, members["FILES.json"]) + + collection_info = manifest.get("collection_info") + if not isinstance(collection_info, dict): + raise ArtifactVerificationError("MANIFEST.json has no valid collection_info") + expected_metadata = {"namespace": "cisco", "name": "sccfm", "version": expected_version} + for key, expected in expected_metadata.items(): + if collection_info.get(key) != expected: + raise ArtifactVerificationError(f"MANIFEST.json has unexpected {key}") + + file_manifest_file = manifest.get("file_manifest_file") + if not isinstance(file_manifest_file, dict): + raise ArtifactVerificationError("MANIFEST.json has no valid file_manifest_file") + if file_manifest_file.get("name") != "FILES.json": + raise ArtifactVerificationError("MANIFEST.json references an unexpected file manifest") + if file_manifest_file.get("chksum_type") != "sha256": + raise ArtifactVerificationError("MANIFEST.json uses an unexpected checksum type") + if file_manifest_file.get("chksum_sha256") != hashlib.sha256(files_raw).hexdigest(): + raise ArtifactVerificationError("FILES.json checksum does not match MANIFEST.json") + + entries = _manifest_entries(files_manifest) + actual_names = set(members) - {"MANIFEST.json", "FILES.json"} + if set(entries) != actual_names: + raise ArtifactVerificationError("artifact members do not exactly match FILES.json") + + for name, entry in entries.items(): + member = members[name] + file_type = entry.get("ftype") + if member.isdir(): + if file_type != "dir": + raise ArtifactVerificationError(f"FILES.json type mismatch for: {name}") + continue + if file_type != "file" or entry.get("chksum_type") != "sha256": + raise ArtifactVerificationError(f"FILES.json file metadata is invalid for: {name}") + actual_hash = hashlib.sha256(_read_member(archive, member)).hexdigest() + if entry.get("chksum_sha256") != actual_hash: + raise ArtifactVerificationError(f"artifact member checksum mismatch: {name}") + + +def _scan_member_content(name: str, data: bytes) -> None: + """Apply redacted high-confidence secret tripwires to one file.""" + if data.lstrip().startswith(b"$ANSIBLE_VAULT;"): + raise ArtifactVerificationError(f"encrypted vault payload found in artifact: {name}") + for label, pattern in _CONTENT_RULES: + if pattern.search(data): + raise ArtifactVerificationError(f"{label} material found in artifact: {name}") + + +def _verify_license_content(archive: tarfile.TarFile, member: tarfile.TarInfo) -> None: + """Require the declared Apache-2.0 license text in the exact artifact.""" + content = _read_member(archive, member) + if b"Apache License" not in content or b"Version 2.0" not in content: + raise ArtifactVerificationError("artifact LICENSE does not contain Apache-2.0 text") + + +def verify_collection_artifact(artifact: Path, expected_version: str) -> ArtifactVerification: + """Verify structure, manifests, paths, content, and digest for one tarball.""" + expected_name = f"cisco-sccfm-{expected_version}.tar.gz" + if artifact.name != expected_name: + raise ArtifactVerificationError(f"unexpected artifact filename: {artifact.name}") + if artifact.is_symlink() or not artifact.is_file(): + raise ArtifactVerificationError("collection artifact must be a regular file") + if artifact.stat().st_size > _MAX_ARCHIVE_BYTES: + raise ArtifactVerificationError("collection artifact exceeds compressed-size limit") + + try: + with tarfile.open(artifact, mode="r:gz") as archive: + raw_members = archive.getmembers() + if len(raw_members) > _MAX_MEMBERS: + raise ArtifactVerificationError("artifact exceeds member-count limit") + + members: dict[str, tarfile.TarInfo] = {} + total_bytes = 0 + for member in raw_members: + name = _safe_member_name(member.name) + if name in members: + raise ArtifactVerificationError(f"artifact contains a duplicate path: {name}") + if not (member.isfile() or member.isdir()): + raise ArtifactVerificationError(f"unsupported archive member type: {name}") + if member.mode & 0o7000 or member.mode & 0o022: + raise ArtifactVerificationError(f"unsafe archive mode for: {name}") + if member.size < 0 or member.size > _MAX_MEMBER_BYTES: + raise ArtifactVerificationError(f"artifact member exceeds size limit: {name}") + total_bytes += member.size + if total_bytes > _MAX_TOTAL_BYTES: + raise ArtifactVerificationError("artifact exceeds uncompressed-size limit") + _check_member_path(name) + members[name] = member + + missing = _REQUIRED_MEMBERS - set(members) + if missing: + raise ArtifactVerificationError( + f"artifact is missing required path: {sorted(missing)[0]}" + ) + + _verify_manifests(archive, members, expected_version) + _verify_license_content(archive, members["LICENSE"]) + for name, member in members.items(): + if member.isfile(): + _scan_member_content(name, _read_member(archive, member)) + except (tarfile.TarError, OSError) as exc: + raise ArtifactVerificationError( + "collection artifact is not a readable tar.gz file" + ) from exc + + digest = hashlib.sha256(artifact.read_bytes()).hexdigest() + file_count = sum(member.isfile() for member in raw_members) + return ArtifactVerification( + sha256=digest, + file_count=file_count, + uncompressed_bytes=total_bytes, + ) + + +def main(argv: Sequence[str] | None = None) -> int: + """Command-line wrapper for CI and release automation.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("artifact", type=Path) + parser.add_argument("--expected-version", required=True) + args = parser.parse_args(argv) + + try: + result = verify_collection_artifact(args.artifact, args.expected_version) + except ArtifactVerificationError as exc: + print(f"Collection artifact rejected: {exc}") + return 1 + + print( + "Collection artifact verified: " + f"files={result.file_count} bytes={result.uncompressed_bytes} sha256={result.sha256}" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/docs/ansible/modules/onboard_cdfmc_ftd.md b/docs/ansible/modules/onboard_cdfmc_ftd.md index e85a197b..7ee84b04 100644 --- a/docs/ansible/modules/onboard_cdfmc_ftd.md +++ b/docs/ansible/modules/onboard_cdfmc_ftd.md @@ -76,7 +76,7 @@ EXAMPLES: - name: Onboard FTD device cisco.sccfm.onboard_cdfmc_ftd: name: "My FTD" - fmc_access_policy_uid: "7131daad-e813-4b8f-8f42-be1e241e8cdb" + fmc_access_policy_uid: "your-access-policy-uid" licenses: - BASE region: "{{ sccfm_region }}" @@ -86,7 +86,7 @@ EXAMPLES: - name: Onboard virtual FTD cisco.sccfm.onboard_cdfmc_ftd: name: "My vFTD" - fmc_access_policy_uid: "7131daad-e813-4b8f-8f42-be1e241e8cdb" + fmc_access_policy_uid: "your-access-policy-uid" licenses: - BASE - CARRIER @@ -97,7 +97,7 @@ EXAMPLES: - name: Onboard FTD with labels cisco.sccfm.onboard_cdfmc_ftd: name: "Branch FTD" - fmc_access_policy_uid: "7131daad-e813-4b8f-8f42-be1e241e8cdb" + fmc_access_policy_uid: "your-access-policy-uid" licenses: - BASE ungrouped_labels: @@ -118,7 +118,7 @@ EXAMPLES: - name: Onboard branch FTD cisco.sccfm.onboard_cdfmc_ftd: name: "Branch FTD" - fmc_access_policy_uid: "7131daad-e813-4b8f-8f42-be1e241e8cdb" + fmc_access_policy_uid: "your-access-policy-uid" licenses: - BASE diff --git a/docs/ansible/modules/onboard_cdfmc_ftd_ztp.md b/docs/ansible/modules/onboard_cdfmc_ftd_ztp.md index 6359a4f2..3987b77f 100644 --- a/docs/ansible/modules/onboard_cdfmc_ftd_ztp.md +++ b/docs/ansible/modules/onboard_cdfmc_ftd_ztp.md @@ -83,7 +83,7 @@ EXAMPLES: serial_number: "FTD1234567890" licenses: - BASE - fmc_access_policy_uid: "7131daad-e813-4b8f-8f42-be1e241e8cdb" + fmc_access_policy_uid: "your-access-policy-uid" region: "{{ sccfm_region }}" api_token: "{{ sccfm_api_token }}" @@ -95,9 +95,9 @@ EXAMPLES: licenses: - BASE - CARRIER - fmc_access_policy_uid: "7131daad-e813-4b8f-8f42-be1e241e8cdb" + fmc_access_policy_uid: "your-access-policy-uid" admin_password: "{{ ftd_admin_password }}" - device_group_uid: "abcd1234-0000-0000-0000-000000000001" + device_group_uid: "your-device-group-uid" # Example 3: Using module_defaults (recommended) - name: Onboard cdFMC-managed FTD with ZTP @@ -114,7 +114,7 @@ EXAMPLES: serial_number: "FTD1234567890" licenses: - BASE - fmc_access_policy_uid: "7131daad-e813-4b8f-8f42-be1e241e8cdb" + fmc_access_policy_uid: "your-access-policy-uid" RETURN VALUES: diff --git a/pyproject.toml b/pyproject.toml index 6caf6dcf..66692fd5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -126,7 +126,7 @@ show_missing = true skip_covered = true [tool.pytest.ini_options] -testpaths = ["cisco_sccfm_cli", "cisco_sccfm_core", "sccfm-ansible"] +testpaths = ["cisco_sccfm_cli", "cisco_sccfm_core", "sccfm-ansible", "tests"] norecursedirs = ["sccfm-ansible/e2e", "cisco_sccfm_cli/e2e"] python_files = ["test_*.py"] python_classes = ["Test*"] diff --git a/sccfm-ansible/.gitignore b/sccfm-ansible/.gitignore index dfdd081a..c48188d6 100644 --- a/sccfm-ansible/.gitignore +++ b/sccfm-ansible/.gitignore @@ -1,5 +1,8 @@ **/vault.yml -**/.vault_pass~ +**/vault.yaml +**/.vault_pass +**/.vault_pass_* +**/.vault_pass-* examples/group_vars/all/vault.yml # Token setup outputs (secrets — never commit) diff --git a/sccfm-ansible/README.md b/sccfm-ansible/README.md index 2f72bb28..5565ffa8 100644 --- a/sccfm-ansible/README.md +++ b/sccfm-ansible/README.md @@ -99,7 +99,10 @@ This will interactively: 7. Write and encrypt `group_vars/all/vault.yml` 8. Update `group_vars/all/vars.yml` with the selected region -You can also point the standalone command at a custom examples directory: +By default, Ansible credentials are written under `sccfm-ansible/examples`. They are Git-ignored +and explicitly excluded from collection artifacts. You can also point the standalone command at a +custom examples directory: + ```bash change-tokens --path /path/to/examples ``` @@ -110,7 +113,7 @@ change-tokens --path /path/to/examples Create a vault password file (do NOT commit this!): ```bash -cd examples +cd sccfm-ansible/examples cp .vault_pass.example .vault_pass echo "YourSecureVaultPassword" > .vault_pass chmod 600 .vault_pass @@ -285,7 +288,9 @@ documentation. ### What is Ansible Vault? -Ansible Vault encrypts sensitive data (API tokens, passwords) so you can safely commit them to version control. The encrypted `vault.yml` file is committed, but the `.vault_pass` password file is **never** committed. +Ansible Vault encrypts sensitive data such as API tokens and passwords. The local `vault.yml` and +`.vault_pass` files are Git-ignored and excluded from collection artifacts; do not commit either +file, even when the vault is encrypted. ### Vault Commands Reference @@ -320,7 +325,7 @@ ansible-vault encrypt group_vars/all/vault.yml --vault-password-file .vault_pass ```bash ansible-vault rekey group_vars/all/vault.yml \ --vault-password-file .vault_pass \ - --new-vault-password-file .vault_pass_new + --new-vault-password-file ~/.sccfm-vault-pass-new ``` **Verify file is encrypted:** @@ -367,8 +372,8 @@ Three ways to provide credentials (in order of precedence): ## Security Best Practices -1. **Never commit unencrypted secrets** to version control -2. **Always encrypt vault files** before committing +1. **Never commit credential files**, including encrypted customer vaults, to this repository +2. **Keep vault files encrypted** whenever they are at rest 3. **Store `.vault_pass` securely** and never commit it 4. **Use different vault passwords** for different environments (dev/prod) 5. **Rotate API tokens regularly** and update vault files accordingly @@ -398,7 +403,8 @@ Three ways to provide credentials (in order of precedence): ## Examples -See the `examples/` directory for complete working examples: +See the `examples/` directory for complete working examples. Locally generated credential files +are Git-ignored and excluded from collection artifacts: - **`inventory.sccfm.yml`** - Dynamic inventory configuration - **`show_devices.yml`** - Display all devices from inventory @@ -408,7 +414,7 @@ See the `examples/` directory for complete working examples: - **`asa_ha_check.yml`** - Run HA health checks on ASA failover devices - **`change_asa_boot_image.yml`** - Change the configured ASA boot image - **`group_vars/all/vars.yml`** - Plain variables (region, defaults) -- **`group_vars/all/vault.yml`** - Encrypted secrets (API token, passwords) +- **`group_vars/all/vault.yml`** - Locally generated encrypted secrets; never packaged - **`group_vars/all/vault.yml.example`** - Template for vault structure ## Additional Resources diff --git a/sccfm-ansible/examples/onboard_cdfmc_ftd_ztp.yml b/sccfm-ansible/examples/onboard_cdfmc_ftd_ztp.yml index 29299349..d5a089d6 100644 --- a/sccfm-ansible/examples/onboard_cdfmc_ftd_ztp.yml +++ b/sccfm-ansible/examples/onboard_cdfmc_ftd_ztp.yml @@ -26,7 +26,7 @@ # -e manager_name="MycdFMC" \ # -e '{"licenses":["BASE"]}' \ # -e admin_password="MyP@ssw0rd" \ -# -e device_group_uid="abcd1234-0000-0000-0000-000000000001" +# -e device_group_uid="your-device-group-uid" # # Dry-run (check mode): # ansible-playbook examples/onboard_cdfmc_ftd_ztp.yml \ diff --git a/sccfm-ansible/galaxy.yml b/sccfm-ansible/galaxy.yml index 5f73a1a2..eb19c760 100644 --- a/sccfm-ansible/galaxy.yml +++ b/sccfm-ansible/galaxy.yml @@ -39,3 +39,41 @@ build_ignore: - '**/*.pyc' - ci - e2e +- .vault_pass +- '**/.vault_pass' +- .vault_pass_* +- '**/.vault_pass_*' +- .vault_pass-* +- '**/.vault_pass-*' +- vault.yml +- '**/vault.yml' +- vault.yaml +- '**/vault.yaml' +- .env +- .env.* +- '**/.env' +- '**/.env.*' +- .envrc* +- '**/.envrc*' +- id_rsa* +- '**/id_rsa*' +- id_dsa* +- '**/id_dsa*' +- id_ecdsa* +- '**/id_ecdsa*' +- id_ed25519* +- '**/id_ed25519*' +- '*.pem' +- '**/*.pem' +- '*.key' +- '**/*.key' +- '*.p12' +- '**/*.p12' +- '*.pfx' +- '**/*.pfx' +- '*.jks' +- '**/*.jks' +- '*.keystore' +- '**/*.keystore' +- '*.kdbx' +- '**/*.kdbx' diff --git a/sccfm-ansible/plugins/modules/onboard_cdfmc_ftd.py b/sccfm-ansible/plugins/modules/onboard_cdfmc_ftd.py index 41dbfd49..1e052a77 100644 --- a/sccfm-ansible/plugins/modules/onboard_cdfmc_ftd.py +++ b/sccfm-ansible/plugins/modules/onboard_cdfmc_ftd.py @@ -92,7 +92,7 @@ - name: Onboard FTD device cisco.sccfm.onboard_cdfmc_ftd: name: "My FTD" - fmc_access_policy_uid: "7131daad-e813-4b8f-8f42-be1e241e8cdb" + fmc_access_policy_uid: "your-access-policy-uid" licenses: - BASE region: "{{ sccfm_region }}" @@ -102,7 +102,7 @@ - name: Onboard virtual FTD cisco.sccfm.onboard_cdfmc_ftd: name: "My vFTD" - fmc_access_policy_uid: "7131daad-e813-4b8f-8f42-be1e241e8cdb" + fmc_access_policy_uid: "your-access-policy-uid" licenses: - BASE - CARRIER @@ -113,7 +113,7 @@ - name: Onboard FTD with labels cisco.sccfm.onboard_cdfmc_ftd: name: "Branch FTD" - fmc_access_policy_uid: "7131daad-e813-4b8f-8f42-be1e241e8cdb" + fmc_access_policy_uid: "your-access-policy-uid" licenses: - BASE ungrouped_labels: @@ -134,7 +134,7 @@ - name: Onboard branch FTD cisco.sccfm.onboard_cdfmc_ftd: name: "Branch FTD" - fmc_access_policy_uid: "7131daad-e813-4b8f-8f42-be1e241e8cdb" + fmc_access_policy_uid: "your-access-policy-uid" licenses: - BASE """ diff --git a/sccfm-ansible/plugins/modules/onboard_cdfmc_ftd_ztp.py b/sccfm-ansible/plugins/modules/onboard_cdfmc_ftd_ztp.py index 491a35b3..8c354d5b 100644 --- a/sccfm-ansible/plugins/modules/onboard_cdfmc_ftd_ztp.py +++ b/sccfm-ansible/plugins/modules/onboard_cdfmc_ftd_ztp.py @@ -94,7 +94,7 @@ serial_number: "FTD1234567890" licenses: - BASE - fmc_access_policy_uid: "7131daad-e813-4b8f-8f42-be1e241e8cdb" + fmc_access_policy_uid: "your-access-policy-uid" region: "{{ sccfm_region }}" api_token: "{{ sccfm_api_token }}" @@ -106,9 +106,9 @@ licenses: - BASE - CARRIER - fmc_access_policy_uid: "7131daad-e813-4b8f-8f42-be1e241e8cdb" + fmc_access_policy_uid: "your-access-policy-uid" admin_password: "{{ ftd_admin_password }}" - device_group_uid: "abcd1234-0000-0000-0000-000000000001" + device_group_uid: "your-device-group-uid" # Example 3: Using module_defaults (recommended) - name: Onboard cdFMC-managed FTD with ZTP @@ -125,7 +125,7 @@ serial_number: "FTD1234567890" licenses: - BASE - fmc_access_policy_uid: "7131daad-e813-4b8f-8f42-be1e241e8cdb" + fmc_access_policy_uid: "your-access-policy-uid" """ RETURN = r""" diff --git a/skills/sccfm-ansible/SKILL.md b/skills/sccfm-ansible/SKILL.md index 09ee5456..02ca6194 100644 --- a/skills/sccfm-ansible/SKILL.md +++ b/skills/sccfm-ansible/SKILL.md @@ -216,9 +216,10 @@ Rules: 8. Use Write/Edit only for non-secret playbook, inventory, vars template, or documentation artifacts. -Use `change-tokens` for local credential setup only when the user explicitly -asks for it. It configures `.env`, CLI profile state, Ansible vars, and encrypted -vault files. +Use `change-tokens` for local credential setup only when the user explicitly asks for it. It +configures `.env`, CLI profile state, Ansible vars, and encrypted vault files. Its default Ansible +path is `sccfm-ansible/examples`; generated credential files there are Git-ignored and excluded +from collection artifacts. Use `--path` only when the user supplies a different examples directory. ## Step 1: Match User Intent Conservatively diff --git a/tests/test_token_workspace.py b/tests/test_token_workspace.py new file mode 100644 index 00000000..a45996d1 --- /dev/null +++ b/tests/test_token_workspace.py @@ -0,0 +1,321 @@ +# Copyright 2026 Cisco Systems, Inc. and its affiliates +# +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import stat +import subprocess +from pathlib import Path + +import click +import pytest +from click.testing import CliRunner + +import cisco_sccfm_scripts.setup_tokens as setup_tokens +from cisco_sccfm_scripts.setup_tokens import ( + _ensure_vault_pass_headless, + _resolve_examples_path, + _update_vars_region, + _write_env_file, + main, +) +from cisco_sccfm_scripts.token_store import SavedToken, VaultTokenStore + + +def _mode(path: Path) -> int: + return stat.S_IMODE(path.stat().st_mode) + + +def _create_examples_layout(root: Path) -> Path: + examples = root / "sccfm-ansible" / "examples" + (examples / "group_vars").mkdir(parents=True) + (examples / ".vault_pass.example").write_text("placeholder\n") + return examples + + +def test_default_path_resolves_collection_examples( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + examples = _create_examples_layout(tmp_path) + monkeypatch.chdir(tmp_path) + + assert _resolve_examples_path(None) == examples.resolve() + + +def test_current_examples_directory_is_supported( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + examples = _create_examples_layout(tmp_path) + monkeypatch.chdir(examples) + + assert _resolve_examples_path(None) == examples.resolve() + + +def test_explicit_examples_path_is_supported(tmp_path: Path) -> None: + examples = tmp_path / "examples" + examples.mkdir() + + assert _resolve_examples_path(examples) == examples.resolve() + + +def test_missing_default_path_has_actionable_error( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.chdir(tmp_path) + + with pytest.raises(click.ClickException, match="Run from the project root"): + _resolve_examples_path(None) + + +def test_generated_files_use_private_modes(tmp_path: Path) -> None: + root = tmp_path / "project" + workspace = root / "sccfm-ansible" / "examples" + workspace.mkdir(parents=True, mode=0o755) + root_mode = _mode(root) + workspace_mode = _mode(workspace) + + env_path = _write_env_file(root, "us", "synthetic-token") + vault_pass = _ensure_vault_pass_headless(workspace, "synthetic-password") + _update_vars_region(workspace, "us") + vars_path = workspace / "group_vars" / "all" / "vars.yml" + + assert _mode(root) == root_mode + assert _mode(workspace) == workspace_mode + assert _mode(workspace / "group_vars") == 0o700 + assert _mode(workspace / "group_vars" / "all") == 0o700 + assert _mode(env_path) == 0o600 + assert _mode(vault_pass) == 0o600 + assert _mode(vars_path) == 0o600 + + +def test_headless_cli_keeps_path_optional(monkeypatch: pytest.MonkeyPatch) -> None: + captured: dict[str, object] = {} + + def fake_run_headless(**kwargs: object) -> None: + captured.update(kwargs) + + monkeypatch.setattr(setup_tokens, "_run_headless", fake_run_headless) + result = CliRunner().invoke( + main, + ["--region", "us", "--api-token", "synthetic-token"], + ) + + assert result.exit_code == 0, result.output + assert captured["path"] is None + + +def test_headless_cli_forwards_typed_workspace( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + workspace = tmp_path / "workspace" + workspace.mkdir() + captured: dict[str, object] = {} + + def fake_run_headless(**kwargs: object) -> None: + captured.update(kwargs) + + monkeypatch.setattr("cisco_sccfm_scripts.setup_tokens._run_headless", fake_run_headless) + result = CliRunner().invoke( + main, + [ + "--region", + "us", + "--api-token", + "synthetic-token", + "--path", + str(workspace), + ], + ) + + assert result.exit_code == 0, result.output + assert captured["path"] == workspace.resolve() + + +def test_headless_setup_routes_env_to_project_root( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + root = tmp_path / "project" + examples = _create_examples_layout(root) + monkeypatch.chdir(root) + captured: dict[str, Path] = {} + + class FakeStore: + def __init__(self, path: Path) -> None: + captured["store"] = path + + def list_tokens(self) -> list[SavedToken]: + return [] + + def save_active_and_tokens(self, active: SavedToken, tokens: list[SavedToken]) -> Path: + return examples / "group_vars" / "all" / "vault.yml" + + def fake_write_env(path: Path, region: str, api_token: str) -> Path: + captured["env"] = path + return path / ".env" + + monkeypatch.setattr(setup_tokens, "_project_root", lambda: root) + monkeypatch.setattr(setup_tokens, "_verify_ansible_vault", lambda: None) + monkeypatch.setattr( + setup_tokens, + "_ensure_vault_pass_headless", + lambda path, password: path / ".vault_pass", + ) + monkeypatch.setattr(setup_tokens, "VaultTokenStore", FakeStore) + monkeypatch.setattr(setup_tokens, "_write_env_file", fake_write_env) + monkeypatch.setattr(setup_tokens, "_update_vars_region", lambda path, region: None) + monkeypatch.setattr(setup_tokens, "_update_cli_config", lambda *args, **kwargs: None) + + setup_tokens._run_headless( + region="us", + api_token="synthetic-token", + name="default", + profile="default", + vault_password="synthetic-password", + path=None, + ) + + assert captured == {"store": examples.resolve(), "env": root} + + +def test_vault_store_encrypts_atomically_with_private_mode( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + workspace = tmp_path / "workspace" + workspace.mkdir() + ansible_tmp = tmp_path / "ansible-tmp" + ansible_tmp.mkdir() + monkeypatch.setenv("ANSIBLE_LOCAL_TEMP", str(ansible_tmp)) + _ensure_vault_pass_headless(workspace, "synthetic-password") + store = VaultTokenStore(workspace) + token = SavedToken(name="test", region="us", token="synthetic-token") + + vault_path = store.save_active_and_tokens(token, [token]) + + assert _mode(vault_path) == 0o600 + assert vault_path.read_bytes().startswith(b"$ANSIBLE_VAULT;") + assert store.list_tokens() == [token] + assert list(vault_path.parent.glob(".vault.*.tmp")) == [] + + +def test_vault_store_removes_plaintext_temporary_file_on_failure( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + workspace = tmp_path / "workspace" + workspace.mkdir() + _ensure_vault_pass_headless(workspace, "synthetic-password") + vault_path = workspace / "group_vars" / "all" / "vault.yml" + vault_path.parent.mkdir(parents=True) + original = b"$ANSIBLE_VAULT;1.1;AES256\nexisting-ciphertext\n" + vault_path.write_bytes(original) + store = VaultTokenStore(workspace) + token = SavedToken(name="test", region="us", token="synthetic-token") + + def failed_encrypt(*args: object, **kwargs: object) -> subprocess.CompletedProcess[str]: + return subprocess.CompletedProcess(args=[], returncode=1, stdout="", stderr="failed") + + monkeypatch.setattr("cisco_sccfm_scripts.token_store.subprocess.run", failed_encrypt) + with pytest.raises(RuntimeError, match="ansible-vault encrypt failed"): + store.save_active_and_tokens(token, [token]) + + assert vault_path.read_bytes() == original + assert list(vault_path.parent.glob(".vault.*.tmp")) == [] + + +def test_vault_store_rejects_success_without_ciphertext( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + workspace = tmp_path / "workspace" + workspace.mkdir() + _ensure_vault_pass_headless(workspace, "synthetic-password") + vault_path = workspace / "group_vars" / "all" / "vault.yml" + vault_path.parent.mkdir(parents=True) + original = b"$ANSIBLE_VAULT;1.1;AES256\nexisting-ciphertext\n" + vault_path.write_bytes(original) + store = VaultTokenStore(workspace) + token = SavedToken(name="test", region="us", token="synthetic-token") + + def false_success(*args: object, **kwargs: object) -> subprocess.CompletedProcess[str]: + return subprocess.CompletedProcess(args=[], returncode=0, stdout="", stderr="") + + monkeypatch.setattr("cisco_sccfm_scripts.token_store.subprocess.run", false_success) + with pytest.raises(RuntimeError, match="valid encrypted output"): + store.save_active_and_tokens(token, [token]) + + assert vault_path.read_bytes() == original + assert list(vault_path.parent.glob(".vault.*.tmp")) == [] + + +def test_vault_store_failure_without_previous_vault_leaves_no_destination( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + workspace = tmp_path / "workspace" + workspace.mkdir() + _ensure_vault_pass_headless(workspace, "synthetic-password") + store = VaultTokenStore(workspace) + token = SavedToken(name="test", region="us", token="synthetic-token") + + def failed_encrypt(*args: object, **kwargs: object) -> subprocess.CompletedProcess[str]: + return subprocess.CompletedProcess(args=[], returncode=1, stdout="", stderr="failed") + + monkeypatch.setattr("cisco_sccfm_scripts.token_store.subprocess.run", failed_encrypt) + with pytest.raises(RuntimeError, match="ansible-vault encrypt failed"): + store.save_active_and_tokens(token, [token]) + + vault_path = workspace / "group_vars" / "all" / "vault.yml" + assert not vault_path.exists() + assert list(vault_path.parent.glob(".vault.*.tmp")) == [] + + +def test_vault_store_uses_separate_private_temporary_files( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + workspace = tmp_path / "workspace" + workspace.mkdir() + _ensure_vault_pass_headless(workspace, "synthetic-password") + store = VaultTokenStore(workspace) + token = SavedToken(name="test", region="us", token="synthetic-token") + captured: dict[str, Path] = {} + + def inspect_encrypt(command: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]: + plaintext_path = Path(command[2]) + ciphertext_path = Path(command[command.index("--output") + 1]) + assert plaintext_path != ciphertext_path + assert _mode(plaintext_path) == 0o600 + assert _mode(ciphertext_path) == 0o600 + ciphertext_path.write_bytes(b"$ANSIBLE_VAULT;1.1;AES256\nsynthetic-ciphertext\n") + captured.update(plaintext=plaintext_path, ciphertext=ciphertext_path) + return subprocess.CompletedProcess(args=command, returncode=0, stdout="", stderr="") + + monkeypatch.setattr("cisco_sccfm_scripts.token_store.subprocess.run", inspect_encrypt) + vault_path = store.save_active_and_tokens(token, [token]) + + assert vault_path.read_bytes().startswith(b"$ANSIBLE_VAULT;") + assert not captured["plaintext"].exists() + assert not captured["ciphertext"].exists() + + +def test_vault_store_cleans_temporary_files_when_encryption_is_interrupted( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + workspace = tmp_path / "workspace" + workspace.mkdir() + _ensure_vault_pass_headless(workspace, "synthetic-password") + store = VaultTokenStore(workspace) + token = SavedToken(name="test", region="us", token="synthetic-token") + + def interrupted_encrypt(*args: object, **kwargs: object) -> subprocess.CompletedProcess[str]: + raise KeyboardInterrupt + + monkeypatch.setattr("cisco_sccfm_scripts.token_store.subprocess.run", interrupted_encrypt) + with pytest.raises(KeyboardInterrupt): + store.save_active_and_tokens(token, [token]) + + vault_path = workspace / "group_vars" / "all" / "vault.yml" + assert not vault_path.exists() + assert list(vault_path.parent.glob(".vault.*.tmp")) == [] diff --git a/tests/test_verify_ansible_collection.py b/tests/test_verify_ansible_collection.py new file mode 100644 index 00000000..ffdec9d5 --- /dev/null +++ b/tests/test_verify_ansible_collection.py @@ -0,0 +1,359 @@ +# Copyright 2026 Cisco Systems, Inc. and its affiliates +# +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import hashlib +import io +import json +import os +import shutil +import subprocess +import tarfile +from pathlib import Path +from typing import Any + +import pytest +import yaml + +from cisco_sccfm_scripts.build_ansible_collection import _find_collection_symlink +from cisco_sccfm_scripts.verify_ansible_collection import ( + ArtifactVerificationError, + verify_collection_artifact, +) + +_VERSION = "1.2.3" +_REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +_COLLECTION_SOURCE = _REPOSITORY_ROOT / "sccfm-ansible" +_COLLECTION_METADATA = yaml.safe_load((_COLLECTION_SOURCE / "galaxy.yml").read_text()) +_COLLECTION_VERSION = str(_COLLECTION_METADATA["version"]) + +_MINIMUM_DIRECTORIES = { + "examples", + "examples/group_vars", + "examples/group_vars/all", + "meta", + "plugins", + "plugins/inventory", + "plugins/module_utils", + "plugins/modules", +} +_MINIMUM_FILES = { + "LICENSE": b"Apache License\nVersion 2.0, January 2004\n", + "README.md": b"# Test collection\n", + "__init__.py": b"", + "examples/.vault_pass.example": b"replace-me\n", + "examples/group_vars/all/vault.yml.example": b"---\nsccfm_api_token: placeholder\n", + "examples/show_devices.yml": b"---\n- name: Synthetic example\n hosts: localhost\n", + "meta/runtime.yml": b"requires_ansible: '>=2.15.0'\n", + "requirements.txt": b"example-package\n", +} + + +def _manifest_entry(name: str, content: bytes | None) -> dict[str, Any]: + if content is None: + return {"name": name, "ftype": "dir"} + return { + "name": name, + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": hashlib.sha256(content).hexdigest(), + } + + +def _write_tar_member(archive: tarfile.TarFile, name: str, content: bytes | None) -> None: + member = tarfile.TarInfo(name) + member.uid = 0 + member.gid = 0 + member.uname = "" + member.gname = "" + if content is None: + member.type = tarfile.DIRTYPE + member.mode = 0o755 + archive.addfile(member) + return + member.mode = 0o644 + member.size = len(content) + archive.addfile(member, io.BytesIO(content)) + + +def _build_synthetic_artifact( + tmp_path: Path, + *, + extra_files: dict[str, bytes] | None = None, + tamper_files_checksum: bool = False, +) -> Path: + directories = set(_MINIMUM_DIRECTORIES) + files = dict(_MINIMUM_FILES) + files.update(extra_files or {}) + + file_entries = [ + *(_manifest_entry(name, None) for name in sorted(directories)), + *(_manifest_entry(name, content) for name, content in sorted(files.items())), + ] + files_manifest = {"format": 1, "files": file_entries} + files_raw = json.dumps(files_manifest, indent=2).encode() + files_digest = hashlib.sha256(files_raw).hexdigest() + if tamper_files_checksum: + files_digest = "0" * 64 + manifest = { + "collection_info": { + "namespace": "cisco", + "name": "sccfm", + "version": _VERSION, + }, + "file_manifest_file": { + "name": "FILES.json", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": files_digest, + "format": 1, + }, + "format": 1, + } + manifest_raw = json.dumps(manifest, indent=2).encode() + + artifact = tmp_path / f"cisco-sccfm-{_VERSION}.tar.gz" + with tarfile.open(artifact, mode="w:gz") as archive: + _write_tar_member(archive, "MANIFEST.json", manifest_raw) + _write_tar_member(archive, "FILES.json", files_raw) + for name in sorted(directories): + _write_tar_member(archive, name, None) + for name, content in sorted(files.items()): + _write_tar_member(archive, name, content) + return artifact + + +def test_verifier_accepts_valid_collection(tmp_path: Path) -> None: + artifact = _build_synthetic_artifact(tmp_path) + + result = verify_collection_artifact(artifact, expected_version=_VERSION) + + assert result.sha256 == hashlib.sha256(artifact.read_bytes()).hexdigest() + assert result.file_count == len(_MINIMUM_FILES) + 2 + assert result.uncompressed_bytes > 0 + + +@pytest.mark.parametrize( + "path", + [ + "examples/.vault_pass", + "examples/.vault_pass_new", + "examples/group_vars/all/vault.yml", + "examples/.env", + "examples/.env.production", + "examples/.envrc", + "examples/token.txt", + "examples/id_rsa", + "examples/id_ed25519.pub", + "examples/private.pem", + "examples/local.sqlite3", + "examples/SECRETS.YML", + ], +) +def test_verifier_rejects_sensitive_paths(tmp_path: Path, path: str) -> None: + artifact = _build_synthetic_artifact(tmp_path, extra_files={path: b"synthetic\n"}) + + with pytest.raises(ArtifactVerificationError): + verify_collection_artifact(artifact, expected_version=_VERSION) + + +def test_verifier_rejects_secret_content_without_echoing_it(tmp_path: Path) -> None: + synthetic_secret = b"eyJ" + b"a" * 12 + b"." + b"b" * 12 + b"." + b"c" * 12 + artifact = _build_synthetic_artifact( + tmp_path, + extra_files={"examples/show_devices.yml": synthetic_secret}, + ) + + with pytest.raises(ArtifactVerificationError) as error: + verify_collection_artifact(artifact, expected_version=_VERSION) + + assert synthetic_secret.decode() not in str(error.value) + assert "JWT-like token" in str(error.value) + + +def test_verifier_rejects_private_key_content(tmp_path: Path) -> None: + marker = b"-----BEGIN " + b"PRIVATE KEY-----\nsynthetic\n" + artifact = _build_synthetic_artifact( + tmp_path, + extra_files={"examples/show_devices.yml": marker}, + ) + + with pytest.raises(ArtifactVerificationError, match="private key"): + verify_collection_artifact(artifact, expected_version=_VERSION) + + +def test_verifier_rejects_manifest_checksum_mismatch(tmp_path: Path) -> None: + artifact = _build_synthetic_artifact(tmp_path, tamper_files_checksum=True) + + with pytest.raises(ArtifactVerificationError, match="FILES.json checksum"): + verify_collection_artifact(artifact, expected_version=_VERSION) + + +def test_verifier_rejects_wrong_license_content(tmp_path: Path) -> None: + artifact = _build_synthetic_artifact( + tmp_path, + extra_files={"LICENSE": b"Not the declared license\n"}, + ) + + with pytest.raises(ArtifactVerificationError, match="Apache-2.0"): + verify_collection_artifact(artifact, expected_version=_VERSION) + + +def test_builder_detects_collection_source_symlink(tmp_path: Path) -> None: + collection = tmp_path / "collection" + examples = collection / "examples" + examples.mkdir(parents=True) + outside = tmp_path / "outside.txt" + outside.write_text("harmless sentinel\n") + link = examples / "linked.txt" + link.symlink_to(outside) + + assert _find_collection_symlink(collection) == Path("examples/linked.txt") + + +def _ignore_sensitive_source_paths(directory: str, names: list[str]) -> set[str]: + """Keep real local credential paths out of the temporary test copy.""" + relative_directory = Path(directory).resolve().relative_to(_COLLECTION_SOURCE.resolve()) + ignored: set[str] = set() + for name in names: + candidate = Path(directory) / name + relative = (relative_directory / name).as_posix().lower() + basename = name.lower() + if relative == "examples/.vault_pass.example": + continue + if ( + candidate.is_symlink() + or basename in {".vault_pass", "vault.yml", "vault.yaml", ".env"} + or basename.startswith((".vault_pass", ".env")) + or basename.startswith(("id_rsa", "id_dsa", "id_ecdsa", "id_ed25519")) + or basename.endswith( + ( + ".bak", + ".db", + ".jks", + ".kdbx", + ".key", + ".keystore", + ".log", + ".orig", + ".p12", + ".pem", + ".pfx", + ".retry", + ".sqlite", + ".sqlite3", + ".swo", + ".swp", + "~", + ) + ) + or "__pycache__" in relative.split("/") + ): + ignored.add(name) + return ignored + + +def test_real_build_excludes_sentinels_and_remains_installable(tmp_path: Path) -> None: + collection_copy = tmp_path / "collection" + shutil.copytree( + _COLLECTION_SOURCE, + collection_copy, + ignore=_ignore_sensitive_source_paths, + ) + shutil.copyfile(_REPOSITORY_ROOT / "LICENSE", collection_copy / "LICENSE") + + sentinel_paths = ( + collection_copy / "examples" / ".vault_pass", + collection_copy / "examples" / ".vault_pass_new", + collection_copy / "examples" / "group_vars" / "all" / "vault.yml", + collection_copy / "examples" / ".env", + collection_copy / "examples" / ".envrc", + collection_copy / "examples" / "id_rsa", + collection_copy / "examples" / "private.pem", + ) + for sentinel in sentinel_paths: + sentinel.parent.mkdir(parents=True, exist_ok=True) + sentinel.write_text("harmless sentinel\n") + + output_dir = tmp_path / "dist" + output_dir.mkdir() + ansible_tmp = tmp_path / "ansible-tmp" + ansible_tmp.mkdir() + environment = {**os.environ, "ANSIBLE_LOCAL_TEMP": str(ansible_tmp)} + build = subprocess.run( + [ + "ansible-galaxy", + "collection", + "build", + str(collection_copy), + "--output-path", + str(output_dir), + "--force", + ], + capture_output=True, + text=True, + env=environment, + check=False, + ) + assert build.returncode == 0, build.stderr + + artifact = output_dir / f"cisco-sccfm-{_COLLECTION_VERSION}.tar.gz" + with tarfile.open(artifact, mode="r:gz") as archive: + member_names = {member.name for member in archive.getmembers()} + + for sentinel in sentinel_paths: + assert sentinel.relative_to(collection_copy).as_posix() not in member_names + assert "examples/.vault_pass.example" in member_names + assert "examples/group_vars/all/vault.yml.example" in member_names + + verify_collection_artifact(artifact, expected_version=_COLLECTION_VERSION) + + install_root = tmp_path / "installed" + install = subprocess.run( + [ + "ansible-galaxy", + "collection", + "install", + str(artifact), + "--collections-path", + str(install_root), + "--force", + ], + capture_output=True, + text=True, + env=environment, + check=False, + ) + assert install.returncode == 0, install.stderr + + discovery_environment = { + **environment, + "ANSIBLE_COLLECTIONS_PATH": str(install_root), + } + discovery = subprocess.run( + ["ansible-doc", "-j", "-l", "-t", "module", "cisco.sccfm"], + capture_output=True, + text=True, + env=discovery_environment, + check=False, + ) + assert discovery.returncode == 0, discovery.stderr + discovered_modules = json.loads(discovery.stdout) + expected_modules = { + f"cisco.sccfm.{module.stem}" + for module in (_COLLECTION_SOURCE / "plugins" / "modules").glob("*.py") + if module.name != "__init__.py" + } + assert set(discovered_modules) == expected_modules + + inventory_discovery = subprocess.run( + ["ansible-doc", "-j", "-l", "-t", "inventory", "cisco.sccfm"], + capture_output=True, + text=True, + env=discovery_environment, + check=False, + ) + assert inventory_discovery.returncode == 0, inventory_discovery.stderr + assert set(json.loads(inventory_discovery.stdout)) == {"cisco.sccfm.sccfm"} From b58da4310a89f84f9235920203e813e0b8e30766 Mon Sep 17 00:00:00 2001 From: Andrei Huides Date: Tue, 11 Aug 2026 12:40:05 +0300 Subject: [PATCH 02/19] fix(lh-102436): Inventory plugin remove exports of the API token --- README.md | 7 +- docs/ansible/inventory/sccfm.md | 2 + sccfm-ansible/README.md | 29 ++-- sccfm-ansible/examples/access_rules.yml | 2 +- .../examples/add_object_override.yml | 2 +- sccfm-ansible/examples/asa_ha_check.yml | 2 +- .../examples/change_asa_boot_image.yml | 2 +- .../examples/change_asa_local_password.yml | 2 +- .../examples/create_network_groups.yml | 2 +- .../examples/create_network_objects.yml | 2 +- .../examples/delete_network_groups.yml | 2 +- .../examples/delete_network_objects.yml | 2 +- sccfm-ansible/examples/deploy_cdfmc_ftd.yml | 2 +- sccfm-ansible/examples/execute_asa_cli.yml | 2 +- sccfm-ansible/examples/execute_ftd_cli.yml | 2 +- sccfm-ansible/examples/inventory.sccfm.yml | 9 +- .../examples/list_asa_boot_registry.yml | 2 +- .../examples/list_asa_compatible_versions.yml | 2 +- .../examples/list_asa_disk_files.yml | 2 +- .../examples/list_asa_local_users.yml | 2 +- .../examples/list_asa_not_on_version.yml | 2 +- .../examples/list_ftd_compatible_versions.yml | 2 +- .../examples/list_ftd_not_on_version.yml | 2 +- .../examples/list_network_groups.yml | 2 +- .../examples/list_network_objects.yml | 2 +- sccfm-ansible/examples/manage_asa_shun.yml | 8 +- .../examples/manage_network_group_members.yml | 2 +- sccfm-ansible/examples/network_objects.yml | 2 +- sccfm-ansible/examples/onboard_asas.yml | 2 +- sccfm-ansible/examples/onboard_cdfmc_ftd.yml | 2 +- .../examples/onboard_cdfmc_ftd_ztp.yml | 2 +- .../examples/trigger_asa_upgrade.yml | 2 +- .../examples/trigger_ftd_upgrade.yml | 2 +- .../examples/update_network_groups.yml | 2 +- .../examples/update_network_objects.yml | 2 +- sccfm-ansible/plugins/inventory/sccfm.py | 7 +- .../tests/test_inventory_plugin_security.py | 140 ++++++++++++++++++ skills/sccfm-ansible/SKILL.md | 9 +- 38 files changed, 217 insertions(+), 54 deletions(-) create mode 100644 sccfm-ansible/plugins/modules/tests/test_inventory_plugin_security.py diff --git a/README.md b/README.md index ae2eb46c..23762fb2 100644 --- a/README.md +++ b/README.md @@ -96,8 +96,13 @@ The package root exports the supported public service classes and response model Git-ignored and explicitly excluded from collection artifacts. Use `--path` to override the examples directory when needed. - 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. -- Configure SCCFM region (`int`, `us`, `eu`, `apj`, `au`, `uae`, `in`, or `ci`) plus `SCCFM_API_TOKEN`; you can set them via env vars or inline (i.e., write the values directly in the inventory file—useful for local dev, but prefer env vars or Ansible Vault for anything shared). +- Configure SCCFM region (`int`, `us`, `eu`, `apj`, `au`, `uae`, `in`, or `ci`) plus + `SCCFM_API_TOKEN` in the controller environment. Never commit a plaintext token to an inventory + source. - Point Ansible at an inventory file that uses the plugin, e.g. `ansible-inventory -i sccfm-ansible/examples/inventory.sccfm.yml --graph`. +- The inventory plugin consumes its API token only during refresh and never exports it as a host + or group variable. Do not use inventory output modes that render vars when your own + `group_vars` or `host_vars` contain secrets. - A starter playbook is in `sccfm-ansible/examples/show_devices.yml`; it runs against the SCCFM devices discovered by the inventory plugin. - Generated Ansible reference docs can be previewed locally with `generate-ansible-docs`; see [docs/README.md](docs/README.md) for details. diff --git a/docs/ansible/inventory/sccfm.md b/docs/ansible/inventory/sccfm.md index 98d4611e..1835296b 100644 --- a/docs/ansible/inventory/sccfm.md +++ b/docs/ansible/inventory/sccfm.md @@ -17,6 +17,8 @@ $ ansible-doc -t inventory cisco.sccfm.sccfm enumerate devices using the REST APIs. Each device becomes an inventory host with SCCFM metadata attached as host variables. + Authentication values are consumed only while refreshing inventory + and are never attached to groups or hosts. OPTIONS (= indicates it is required): diff --git a/sccfm-ansible/README.md b/sccfm-ansible/README.md index 5565ffa8..fe818eda 100644 --- a/sccfm-ansible/README.md +++ b/sccfm-ansible/README.md @@ -158,11 +158,12 @@ Edit the `onboard_asas.yml` playbook, and change the `asas_to_onboard` list to m **Graph inventory:** ```bash -export SCCFM_REGION=int -export SCCFM_API_TOKEN=$(ansible-vault view ./examples/group_vars/all/vault.yml --vault-password-file ./examples/.vault_pass | grep sccfm_api_token | cut -d '"' -f2) +# Load SCCFM_REGION and SCCFM_API_TOKEN without putting the token on argv. +# `change-tokens` writes the repository .env for use with direnv. ansible-inventory -i examples/inventory.sccfm.yml \ --graph \ - --playbook-dir examples + --playbook-dir examples \ + --vault-password-file examples/.vault_pass ``` **Show all devices:** @@ -181,10 +182,13 @@ ansible-playbook onboard_asas.yml --vault-password-file .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 ``` +Do not use `--list`, `--yaml`, or `--graph --vars` while decrypted `group_vars` contain +secrets: those output formats can print any variables supplied by Ansible Vault or other vars +plugins. Plain `--graph` validates discovery without rendering variables. + ### Host Variables Each device gets the following variables: @@ -196,6 +200,11 @@ Each device gets the following variables: - `sccfm_config_state` - Device configuration state - `sccfm_software_version` - Device software version +The inventory plugin never adds its API token to a group or host. It consumes the configured +token only while refreshing inventory. This guarantee does not apply to secrets that users place +in `group_vars`, which are ordinary Ansible inventory data and may be rendered by inventory +commands. + ## Modules Generated module and inventory reference docs can be previewed locally. Generate them with: @@ -230,7 +239,7 @@ Onboard an ASA device to your SCCFM tenant. module_defaults: group/cisco.sccfm.all: region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: Onboard branch ASA @@ -344,7 +353,7 @@ Instead of repeating `region` and `api_token` for every task, use `module_defaul module_defaults: group/cisco.sccfm.all: region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: Onboard device 1 @@ -379,6 +388,8 @@ Three ways to provide credentials (in order of precedence): 5. **Rotate API tokens regularly** and update vault files accordingly 6. **Use `.gitignore`** to prevent accidental commits of sensitive files 7. **Use `no_log: true`** for password parameters in custom tasks +8. **Do not serialize secret-bearing inventory** with `ansible-inventory --list`, `--yaml`, or + `--graph --vars` ## Troubleshooting @@ -392,9 +403,9 @@ Three ways to provide credentials (in order of precedence): - Or provide `region` parameter in module defaults ### "api_token is required" error -- Verify `sccfm_api_token` is in encrypted `group_vars/all/vault.yml` -- Or set `SCCFM_API_TOKEN` environment variable -- Or provide `api_token` parameter in module defaults +- Verify `SCCFM_API_TOKEN` is set in the controller environment +- Or provide a Vault-backed `api_token` parameter in module defaults; keep the Vault + playbook-local instead of placing it in inventory or `group_vars` ### Inventory returns no hosts - Check your API token has proper permissions diff --git a/sccfm-ansible/examples/access_rules.yml b/sccfm-ansible/examples/access_rules.yml index 64cc11d5..82e915d1 100644 --- a/sccfm-ansible/examples/access_rules.yml +++ b/sccfm-ansible/examples/access_rules.yml @@ -24,7 +24,7 @@ module_defaults: group/cisco.sccfm.all: region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" vars: device_uid: "YOUR_DEVICE_UID" diff --git a/sccfm-ansible/examples/add_object_override.yml b/sccfm-ansible/examples/add_object_override.yml index f338ddb7..28b8512c 100644 --- a/sccfm-ansible/examples/add_object_override.yml +++ b/sccfm-ansible/examples/add_object_override.yml @@ -27,7 +27,7 @@ module_defaults: group/cisco.sccfm.all: region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: # ------------------------------------------------------------------------- diff --git a/sccfm-ansible/examples/asa_ha_check.yml b/sccfm-ansible/examples/asa_ha_check.yml index ea0fd0bf..819e1f53 100644 --- a/sccfm-ansible/examples/asa_ha_check.yml +++ b/sccfm-ansible/examples/asa_ha_check.yml @@ -23,7 +23,7 @@ module_defaults: group/cisco.sccfm.all: region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" vars: device_uid: "" diff --git a/sccfm-ansible/examples/change_asa_boot_image.yml b/sccfm-ansible/examples/change_asa_boot_image.yml index 164e2ec6..d35173b7 100644 --- a/sccfm-ansible/examples/change_asa_boot_image.yml +++ b/sccfm-ansible/examples/change_asa_boot_image.yml @@ -4,7 +4,7 @@ module_defaults: group/cisco.sccfm.all: region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: Preview boot image change on branch ASAs diff --git a/sccfm-ansible/examples/change_asa_local_password.yml b/sccfm-ansible/examples/change_asa_local_password.yml index 884054a9..ffe81b3e 100644 --- a/sccfm-ansible/examples/change_asa_local_password.yml +++ b/sccfm-ansible/examples/change_asa_local_password.yml @@ -30,7 +30,7 @@ module_defaults: group/cisco.sccfm.all: region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" vars: device_uid: "" diff --git a/sccfm-ansible/examples/create_network_groups.yml b/sccfm-ansible/examples/create_network_groups.yml index d8bacf43..a0ed921e 100644 --- a/sccfm-ansible/examples/create_network_groups.yml +++ b/sccfm-ansible/examples/create_network_groups.yml @@ -18,7 +18,7 @@ module_defaults: group/cisco.sccfm.all: region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" vars: network_groups: diff --git a/sccfm-ansible/examples/create_network_objects.yml b/sccfm-ansible/examples/create_network_objects.yml index 7b359cf6..7ed296ce 100644 --- a/sccfm-ansible/examples/create_network_objects.yml +++ b/sccfm-ansible/examples/create_network_objects.yml @@ -20,7 +20,7 @@ module_defaults: group/cisco.sccfm.all: region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" vars: # Define network objects to create diff --git a/sccfm-ansible/examples/delete_network_groups.yml b/sccfm-ansible/examples/delete_network_groups.yml index cfd8ebfa..6d8bcd47 100644 --- a/sccfm-ansible/examples/delete_network_groups.yml +++ b/sccfm-ansible/examples/delete_network_groups.yml @@ -20,7 +20,7 @@ module_defaults: group/cisco.sccfm.all: region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" vars: # Define network groups to delete by name diff --git a/sccfm-ansible/examples/delete_network_objects.yml b/sccfm-ansible/examples/delete_network_objects.yml index aa816840..702b4811 100644 --- a/sccfm-ansible/examples/delete_network_objects.yml +++ b/sccfm-ansible/examples/delete_network_objects.yml @@ -20,7 +20,7 @@ module_defaults: group/cisco.sccfm.all: region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" vars: # Define network objects to delete by name diff --git a/sccfm-ansible/examples/deploy_cdfmc_ftd.yml b/sccfm-ansible/examples/deploy_cdfmc_ftd.yml index 0dc1ec3e..81c0cae3 100644 --- a/sccfm-ansible/examples/deploy_cdfmc_ftd.yml +++ b/sccfm-ansible/examples/deploy_cdfmc_ftd.yml @@ -33,7 +33,7 @@ module_defaults: group/cisco.sccfm.all: region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" vars: device_uids: [] diff --git a/sccfm-ansible/examples/execute_asa_cli.yml b/sccfm-ansible/examples/execute_asa_cli.yml index 3a968a1c..31199d96 100644 --- a/sccfm-ansible/examples/execute_asa_cli.yml +++ b/sccfm-ansible/examples/execute_asa_cli.yml @@ -22,7 +22,7 @@ module_defaults: group/cisco.sccfm.all: region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" vars: device_uid: "" diff --git a/sccfm-ansible/examples/execute_ftd_cli.yml b/sccfm-ansible/examples/execute_ftd_cli.yml index 4c4cfce6..81941094 100644 --- a/sccfm-ansible/examples/execute_ftd_cli.yml +++ b/sccfm-ansible/examples/execute_ftd_cli.yml @@ -17,7 +17,7 @@ module_defaults: group/cisco.sccfm.all: region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" vars: device_uid: "" diff --git a/sccfm-ansible/examples/inventory.sccfm.yml b/sccfm-ansible/examples/inventory.sccfm.yml index 2f2e470f..87d20cd8 100644 --- a/sccfm-ansible/examples/inventory.sccfm.yml +++ b/sccfm-ansible/examples/inventory.sccfm.yml @@ -1,7 +1,8 @@ -# Credentials can be provided in two ways: -# 1. Set environment variables SCCFM_REGION and SCCFM_API_TOKEN (recommended) -# - Create a .env file from .env.example and use direnv to auto-load -# 2. Use the lookup syntax below (falls back to env vars if not set) +# Set SCCFM_REGION and SCCFM_API_TOKEN in the controller environment. +# Create a .env file from .env.example and use direnv to load it. The +# lookups below keep plaintext credentials out of this inventory source. +# Authentication values are used only to refresh inventory. The plugin never +# exports them as host or group variables. plugin: cisco.sccfm.sccfm region: "{{ lookup('env', 'SCCFM_REGION') }}" api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" diff --git a/sccfm-ansible/examples/list_asa_boot_registry.yml b/sccfm-ansible/examples/list_asa_boot_registry.yml index 91009e42..e0eba74a 100644 --- a/sccfm-ansible/examples/list_asa_boot_registry.yml +++ b/sccfm-ansible/examples/list_asa_boot_registry.yml @@ -21,7 +21,7 @@ module_defaults: group/cisco.sccfm.all: region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" vars: device_uid: "" diff --git a/sccfm-ansible/examples/list_asa_compatible_versions.yml b/sccfm-ansible/examples/list_asa_compatible_versions.yml index 30763485..47ac08e5 100644 --- a/sccfm-ansible/examples/list_asa_compatible_versions.yml +++ b/sccfm-ansible/examples/list_asa_compatible_versions.yml @@ -23,7 +23,7 @@ module_defaults: group/cisco.sccfm.all: region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" vars: device_uid: "" diff --git a/sccfm-ansible/examples/list_asa_disk_files.yml b/sccfm-ansible/examples/list_asa_disk_files.yml index f0b31163..4d528465 100644 --- a/sccfm-ansible/examples/list_asa_disk_files.yml +++ b/sccfm-ansible/examples/list_asa_disk_files.yml @@ -23,7 +23,7 @@ module_defaults: group/cisco.sccfm.all: region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" vars: device_uid: "" diff --git a/sccfm-ansible/examples/list_asa_local_users.yml b/sccfm-ansible/examples/list_asa_local_users.yml index 42c07e00..f0387016 100644 --- a/sccfm-ansible/examples/list_asa_local_users.yml +++ b/sccfm-ansible/examples/list_asa_local_users.yml @@ -4,7 +4,7 @@ module_defaults: group/cisco.sccfm.all: region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: Get list of ASA local users for UIDs cisco.sccfm.list_asa_local_users: diff --git a/sccfm-ansible/examples/list_asa_not_on_version.yml b/sccfm-ansible/examples/list_asa_not_on_version.yml index 493d68fd..f767bb6b 100644 --- a/sccfm-ansible/examples/list_asa_not_on_version.yml +++ b/sccfm-ansible/examples/list_asa_not_on_version.yml @@ -27,7 +27,7 @@ module_defaults: group/cisco.sccfm.all: region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" vars: target_version: "" diff --git a/sccfm-ansible/examples/list_ftd_compatible_versions.yml b/sccfm-ansible/examples/list_ftd_compatible_versions.yml index dff09b21..a231d6c5 100644 --- a/sccfm-ansible/examples/list_ftd_compatible_versions.yml +++ b/sccfm-ansible/examples/list_ftd_compatible_versions.yml @@ -23,7 +23,7 @@ module_defaults: group/cisco.sccfm.all: region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" vars: device_uid: "" diff --git a/sccfm-ansible/examples/list_ftd_not_on_version.yml b/sccfm-ansible/examples/list_ftd_not_on_version.yml index 72202c0b..89ee6d34 100644 --- a/sccfm-ansible/examples/list_ftd_not_on_version.yml +++ b/sccfm-ansible/examples/list_ftd_not_on_version.yml @@ -26,7 +26,7 @@ module_defaults: group/cisco.sccfm.all: region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" vars: target_version: "" diff --git a/sccfm-ansible/examples/list_network_groups.yml b/sccfm-ansible/examples/list_network_groups.yml index e335fd49..a61f1eeb 100644 --- a/sccfm-ansible/examples/list_network_groups.yml +++ b/sccfm-ansible/examples/list_network_groups.yml @@ -16,7 +16,7 @@ module_defaults: group/cisco.sccfm.all: region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: # ============================================================ diff --git a/sccfm-ansible/examples/list_network_objects.yml b/sccfm-ansible/examples/list_network_objects.yml index 2791eafc..8ed521a2 100644 --- a/sccfm-ansible/examples/list_network_objects.yml +++ b/sccfm-ansible/examples/list_network_objects.yml @@ -16,7 +16,7 @@ module_defaults: group/cisco.sccfm.all: region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: # ============================================================ diff --git a/sccfm-ansible/examples/manage_asa_shun.yml b/sccfm-ansible/examples/manage_asa_shun.yml index fa8f2356..66b9bdc2 100644 --- a/sccfm-ansible/examples/manage_asa_shun.yml +++ b/sccfm-ansible/examples/manage_asa_shun.yml @@ -2,10 +2,10 @@ # Shun management example for an ASA device # # RUN: +# export SCCFM_REGION= +# export SCCFM_API_TOKEN= # ansible-playbook examples/manage_asa_shun.yml \ -# -e device_uid= \ -# -e sccfm_region= \ -# -e sccfm_api_token= +# -e device_uid= - name: "Manage shun entries on ASA firewall" hosts: localhost @@ -14,7 +14,7 @@ module_defaults: group/cisco.sccfm.all: region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" vars: device_uid: "" diff --git a/sccfm-ansible/examples/manage_network_group_members.yml b/sccfm-ansible/examples/manage_network_group_members.yml index 815ff8a7..12623db8 100644 --- a/sccfm-ansible/examples/manage_network_group_members.yml +++ b/sccfm-ansible/examples/manage_network_group_members.yml @@ -20,7 +20,7 @@ module_defaults: group/cisco.sccfm.all: region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" vars: group_name: web-servers diff --git a/sccfm-ansible/examples/network_objects.yml b/sccfm-ansible/examples/network_objects.yml index 454ab8cc..35653bea 100644 --- a/sccfm-ansible/examples/network_objects.yml +++ b/sccfm-ansible/examples/network_objects.yml @@ -17,7 +17,7 @@ module_defaults: group/cisco.sccfm.all: region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" vars: network_objects: diff --git a/sccfm-ansible/examples/onboard_asas.yml b/sccfm-ansible/examples/onboard_asas.yml index 674b7299..7cfedd30 100644 --- a/sccfm-ansible/examples/onboard_asas.yml +++ b/sccfm-ansible/examples/onboard_asas.yml @@ -7,7 +7,7 @@ module_defaults: group/cisco.sccfm.all: region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" vars: # List of ASAs to onboard diff --git a/sccfm-ansible/examples/onboard_cdfmc_ftd.yml b/sccfm-ansible/examples/onboard_cdfmc_ftd.yml index a4b19325..4fbc6667 100644 --- a/sccfm-ansible/examples/onboard_cdfmc_ftd.yml +++ b/sccfm-ansible/examples/onboard_cdfmc_ftd.yml @@ -30,7 +30,7 @@ module_defaults: group/cisco.sccfm.all: region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" vars: device_name: "" diff --git a/sccfm-ansible/examples/onboard_cdfmc_ftd_ztp.yml b/sccfm-ansible/examples/onboard_cdfmc_ftd_ztp.yml index d5a089d6..7cf5fcc9 100644 --- a/sccfm-ansible/examples/onboard_cdfmc_ftd_ztp.yml +++ b/sccfm-ansible/examples/onboard_cdfmc_ftd_ztp.yml @@ -43,7 +43,7 @@ module_defaults: group/cisco.sccfm.all: region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" vars: device_name: "" diff --git a/sccfm-ansible/examples/trigger_asa_upgrade.yml b/sccfm-ansible/examples/trigger_asa_upgrade.yml index abfc2f99..b48caf94 100644 --- a/sccfm-ansible/examples/trigger_asa_upgrade.yml +++ b/sccfm-ansible/examples/trigger_asa_upgrade.yml @@ -38,7 +38,7 @@ module_defaults: group/cisco.sccfm.all: region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" vars: device_uids: [] diff --git a/sccfm-ansible/examples/trigger_ftd_upgrade.yml b/sccfm-ansible/examples/trigger_ftd_upgrade.yml index cb997a10..76539e86 100644 --- a/sccfm-ansible/examples/trigger_ftd_upgrade.yml +++ b/sccfm-ansible/examples/trigger_ftd_upgrade.yml @@ -32,7 +32,7 @@ module_defaults: group/cisco.sccfm.all: region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" vars: device_uids: [] diff --git a/sccfm-ansible/examples/update_network_groups.yml b/sccfm-ansible/examples/update_network_groups.yml index 0d9d7b0e..ae2227f1 100644 --- a/sccfm-ansible/examples/update_network_groups.yml +++ b/sccfm-ansible/examples/update_network_groups.yml @@ -22,7 +22,7 @@ module_defaults: group/cisco.sccfm.all: region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: # ============================================================ diff --git a/sccfm-ansible/examples/update_network_objects.yml b/sccfm-ansible/examples/update_network_objects.yml index 1e34afde..7fe95f87 100644 --- a/sccfm-ansible/examples/update_network_objects.yml +++ b/sccfm-ansible/examples/update_network_objects.yml @@ -24,7 +24,7 @@ module_defaults: group/cisco.sccfm.all: region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: # ============================================================ diff --git a/sccfm-ansible/plugins/inventory/sccfm.py b/sccfm-ansible/plugins/inventory/sccfm.py index 68b5f413..3d1a2bf1 100644 --- a/sccfm-ansible/plugins/inventory/sccfm.py +++ b/sccfm-ansible/plugins/inventory/sccfm.py @@ -24,6 +24,8 @@ - Uses Cisco Security Cloud Control Firewall Manager (SCCFM) to enumerate devices using the REST APIs. - Each device becomes an inventory host with SCCFM metadata attached as host variables. + - Authentication values are consumed only while refreshing inventory and are never attached + to groups or hosts. options: plugin: description: Ensure this plugin gets loaded. @@ -96,9 +98,9 @@ def parse(self, inventory: Any, loader: Any, path: str, cache: bool = True) -> N api_token = self._template_string(cast(Optional[str], config_data.get("api_token"))) if region is None: - region = cast(Optional[str], os.getenv("SCCFM_REGION")) + region = os.getenv("SCCFM_REGION") if api_token is None: - api_token = cast(Optional[str], os.getenv("SCCFM_API_TOKEN")) + api_token = os.getenv("SCCFM_API_TOKEN") if not region: raise AnsibleParserError( @@ -130,7 +132,6 @@ def parse(self, inventory: Any, loader: Any, path: str, cache: bool = True) -> N if group: self.inventory.add_group(group) self.inventory.set_variable(group, "sccfm_region", region) - self.inventory.set_variable(group, "sccfm_api_token", api_token) host_builder = InventoryHostBuilder(inventory=self.inventory, region=region) diff --git a/sccfm-ansible/plugins/modules/tests/test_inventory_plugin_security.py b/sccfm-ansible/plugins/modules/tests/test_inventory_plugin_security.py new file mode 100644 index 00000000..f9733c0e --- /dev/null +++ b/sccfm-ansible/plugins/modules/tests/test_inventory_plugin_security.py @@ -0,0 +1,140 @@ +# Copyright 2026 Cisco Systems, Inc. and its affiliates +# +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Any, ClassVar, cast + +import pytest +import yaml +from ansible.inventory.data import InventoryData +from ansible.parsing.dataloader import DataLoader +from plugins.inventory import sccfm as inventory_plugin +from plugins.module_utils.config import Config +from scc_firewall_manager_sdk import Device + +_SYNTHETIC_TOKEN = "not-a-secret-sec002" +_DEVICE_NAME = "sec002-device" +_EXAMPLES_DIR = Path(__file__).resolve().parents[3] / "examples" + + +@dataclass(frozen=True) +class _SyntheticDevice: + name: str = _DEVICE_NAME + uid: str = "00000000-0000-0000-0000-000000000002" + device_type: str = "ASA" + connectivity_state: str = "ONLINE" + config_state: str = "SYNCED" + software_version: str = "1.2.3" + + +class _RecordingInventoryLoader: + captured_config: ClassVar[Config | None] = None + + def __init__(self, *, config: Config, limit: int, query: str | None) -> None: + del limit, query + type(self).captured_config = config + + def load_devices(self) -> list[Device]: + return [cast(Device, _SyntheticDevice())] + + +def _write_inventory_config(path: Path, *, use_environment: bool) -> None: + if use_environment: + region = "{{ lookup('env', 'SCCFM_REGION') }}" + api_token = "{{ lookup('env', 'SCCFM_API_TOKEN') }}" + else: + region = "us" + api_token = _SYNTHETIC_TOKEN + + path.write_text( + "\n".join( + ( + "plugin: cisco.sccfm.sccfm", + f'region: "{region}"', + f'api_token: "{api_token}"', + "group: sccfm", + "group_by_device_type: false", + "", + ) + ), + encoding="utf-8", + ) + + +def _serialized_inventory(inventory: InventoryData) -> dict[str, Any]: + group_vars = dict(inventory.groups["sccfm"].vars) + host_vars = { + **group_vars, + **dict(inventory.hosts[_DEVICE_NAME].vars), + } + return { + "_meta": {"hostvars": {_DEVICE_NAME: host_vars}}, + "sccfm": { + "hosts": [_DEVICE_NAME], + "vars": group_vars, + }, + } + + +@pytest.mark.parametrize("use_environment", [False, True], ids=["config", "environment"]) +def test_inventory_auth_token_is_consumed_but_never_exported( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + use_environment: bool, +) -> None: + inventory_path = tmp_path / "inventory.sccfm.yml" + _write_inventory_config(inventory_path, use_environment=use_environment) + if use_environment: + monkeypatch.setenv("SCCFM_REGION", "us") + monkeypatch.setenv("SCCFM_API_TOKEN", _SYNTHETIC_TOKEN) + else: + monkeypatch.delenv("SCCFM_REGION", raising=False) + monkeypatch.delenv("SCCFM_API_TOKEN", raising=False) + + _RecordingInventoryLoader.captured_config = None + monkeypatch.setattr(inventory_plugin, "InventoryLoader", _RecordingInventoryLoader) + inventory = InventoryData() + + plugin = inventory_plugin.InventoryModule() + plugin.parse(inventory, DataLoader(), str(inventory_path)) + + captured_config = _RecordingInventoryLoader.captured_config + assert captured_config is not None + assert captured_config.region == "us" + assert captured_config.api_token == _SYNTHETIC_TOKEN + + payload = _serialized_inventory(inventory) + json_output = json.dumps(payload, sort_keys=True) + yaml_output = yaml.safe_dump(payload, sort_keys=True) + for serialized in (json_output, yaml_output): + assert "sccfm_api_token" not in serialized + assert _SYNTHETIC_TOKEN not in serialized + + group_vars = payload["sccfm"]["vars"] + host_vars = payload["_meta"]["hostvars"][_DEVICE_NAME] + assert group_vars == {"sccfm_region": "us"} + sccfm_host_vars = {key: value for key, value in host_vars.items() if key.startswith("sccfm_")} + assert sccfm_host_vars == { + "sccfm_config_state": "SYNCED", + "sccfm_connectivity_state": "ONLINE", + "sccfm_device_type": "ASA", + "sccfm_name": _DEVICE_NAME, + "sccfm_region": "us", + "sccfm_software_version": "1.2.3", + "sccfm_uid": "00000000-0000-0000-0000-000000000002", + } + + +def test_packaged_examples_do_not_depend_on_inventory_token_variable() -> None: + offenders = [ + path.name + for path in sorted(_EXAMPLES_DIR.glob("*.yml")) + if "{{ sccfm_api_token }}" in path.read_text(encoding="utf-8") + ] + + assert offenders == [] diff --git a/skills/sccfm-ansible/SKILL.md b/skills/sccfm-ansible/SKILL.md index 02ca6194..02c3d459 100644 --- a/skills/sccfm-ansible/SKILL.md +++ b/skills/sccfm-ansible/SKILL.md @@ -286,8 +286,8 @@ For SCCFM modules, prefer this shape when `region` and `api_token` are supported ```yaml module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" ``` Do not repeat `region` or `api_token` inside each task unless the user asks for a @@ -350,9 +350,12 @@ When credentials are available and the user requested inventory behavior: ```bash ansible-inventory -i --graph --playbook-dir -ansible-inventory -i --list --playbook-dir ``` +Use `--list`, `--yaml`, or `--graph --vars` only after confirming that the inventory and all +adjacent `group_vars`/`host_vars` are secret-free. These formats can print variables loaded by +Ansible even though the SCCFM inventory plugin itself never exports its authentication token. + If credentials are missing, validate only the file shape and mark it as not validated against live SCCFM. From 03969377c47d8ed900a61891cd0186ecf5039f58 Mon Sep 17 00:00:00 2001 From: Andrei Huides Date: Tue, 11 Aug 2026 13:08:30 +0300 Subject: [PATCH 03/19] fix(lh-102436): hide smart licensing token --- cisco_sccfm_cli/commands/base.py | 96 ++++-- .../devices/asa/cli_result_renderer.py | 23 +- .../devices/asa/smartlicense/command.py | 140 +++++++-- .../asa/smartlicense/test_sensitive_output.py | 270 ++++++++++++++++ .../asa/smartlicense/test_token_input.py | 290 ++++++++++++++++++ .../devices/asa/test_cli_result_renderer.py | 68 ++++ cisco_sccfm_cli/commands/tests/test_schema.py | 43 ++- cisco_sccfm_cli/schema.py | 19 +- cisco_sccfm_cli/utils/__init__.py | 3 +- cisco_sccfm_cli/utils/redaction.py | 50 +++ ...-cli-inventory-devices-asa-smartlicense.md | 12 +- ...m-cli-inventory-devices-asa-smartlicense.1 | 5 +- skills/sccfm-cli/SKILL.md | 14 +- tests/test_redaction.py | 46 +++ 14 files changed, 1014 insertions(+), 65 deletions(-) create mode 100644 cisco_sccfm_cli/commands/tests/inventory/devices/asa/smartlicense/test_sensitive_output.py create mode 100644 cisco_sccfm_cli/commands/tests/inventory/devices/asa/smartlicense/test_token_input.py create mode 100644 cisco_sccfm_cli/utils/redaction.py create mode 100644 tests/test_redaction.py diff --git a/cisco_sccfm_cli/commands/base.py b/cisco_sccfm_cli/commands/base.py index 9d812ccc..7cf0a960 100644 --- a/cisco_sccfm_cli/commands/base.py +++ b/cisco_sccfm_cli/commands/base.py @@ -17,7 +17,7 @@ from scc_firewall_manager_sdk import ApiException, CdoTransaction, ConnectivityState, Device from cisco_sccfm_cli.services import ConfigService -from cisco_sccfm_cli.utils import print_json +from cisco_sccfm_cli.utils import print_json, redact_data, redact_text from cisco_sccfm_core import SccApiError from cisco_sccfm_core.constants import DEFAULT_POLLING_INTERVAL_SEC, DEFAULT_TRANSACTION_TIMEOUT_SEC from cisco_sccfm_core.models.cdo_transaction_status import CdoTransactionStatus @@ -28,6 +28,8 @@ class BaseCommand(ABC): """Base class implementing the command pattern for CLI commands.""" + _SENSITIVE_VALUES_META_KEY = "sccfm_sensitive_values" + def __init__(self, console: Console) -> None: self._console = console @@ -68,36 +70,81 @@ def build_params(self) -> Sequence[click.Parameter]: def _dispatch(self, **kwargs: Any) -> None: ctx = click.get_current_context() + self._register_sensitive_parameters(ctx, kwargs) + exit_code: int | None = None + click_exception: click.ClickException | None = None try: self.handle(ctx=ctx, **kwargs) except ApiException as e: + sensitive_values = self._sensitive_values(ctx) output_format = cast(str | None, kwargs.get("format")) error = SccApiError.from_exception(e) if output_format == "json": - print_json(error.to_dict()) + print_json(redact_data(error.to_dict(), sensitive_values)) else: self.console.print( "[yellow]Error executing operation using the SCC Firewall Manager API. " "If you think you should not be getting this error, please file a Github issue" " with the details below.[/yellow]" ) - self.console.print(f"[bold]Error message:[/bold] {error.message}") - self.console.print(f"[bold]Error Code:[/bold] {error.error_code}") self.console.print( - f"[bold]Error Details:[/bold]\n{json.dumps(error.details, indent=2)}" + f"[bold]Error message:[/bold] " + f"{redact_text(error.message, sensitive_values)}" + ) + error_code = redact_text(str(error.error_code), sensitive_values) + self.console.print(f"[bold]Error Code:[/bold] {error_code}") + error_details = redact_data(error.details, sensitive_values) + self.console.print( + f"[bold]Error Details:[/bold]\n{json.dumps(error_details, indent=2)}" ) - sys.exit(-1) - except click.ClickException: + exit_code = -1 + except click.ClickException as exc: # Preserve Click's default error handling so usage/help is shown for user errors. - raise + exc.message = redact_text(str(exc.message), self._sensitive_values(ctx)) + exc.args = (exc.message,) + exc.__context__ = None + exc.__cause__ = None + exc.__suppress_context__ = True + exc.__traceback__ = None + click_exception = exc except (click.Abort, click.exceptions.Exit): raise except KeyboardInterrupt: sys.exit(130) except Exception as e: - self.console.print(f"[red]Error: {e}[/red]") - sys.exit(-1) + message = redact_text(str(e), self._sensitive_values(ctx)) + self.console.print(f"[red]Error: {message}[/red]") + exit_code = -1 + + if click_exception is not None: + raise click_exception + if exit_code is not None: + sys.exit(exit_code) + + def _register_sensitive_value(self, ctx: click.Context, value: str) -> None: + """Register a secret for command-scoped output and exception redaction.""" + if not value: + return + values = self._sensitive_values(ctx) + if value not in values: + ctx.meta[self._SENSITIVE_VALUES_META_KEY] = (*values, value) + + def _register_sensitive_parameters(self, ctx: click.Context, kwargs: dict[str, Any]) -> None: + """Register values from Click options marked for hidden input.""" + for parameter in ctx.command.params: + if not isinstance(parameter, click.Option) or not parameter.hide_input: + continue + value = kwargs.get(parameter.name or "") + if isinstance(value, str): + self._register_sensitive_value(ctx, value) + + def _sensitive_values(self, ctx: click.Context) -> tuple[str, ...]: + """Return secrets registered for the active Click command context.""" + raw_values = ctx.meta.get(self._SENSITIVE_VALUES_META_KEY, ()) + if not isinstance(raw_values, tuple): + return () + return tuple(value for value in raw_values if isinstance(value, str) and value) @abstractmethod def handle(self, ctx: click.Context, **kwargs: Any) -> None: @@ -208,24 +255,27 @@ def is_failed_transaction(transaction: CdoTransaction) -> bool: ) def print_failed_transaction_details( - self, cdo_transaction: CdoTransaction, format: str = "table" + self, + cdo_transaction: CdoTransaction, + format: str = "table", + *, + sensitive_values: Sequence[str] = (), ) -> None: if format == "json": - print_json(cdo_transaction.to_dict()) + print_json(redact_data(cdo_transaction.to_dict(), sensitive_values)) else: - self.console.print("[yellow]The execution failed. Transaction Details:[/yellow]") - self.console.print( - "[bold]Transaction UID: [/bold]" f"{cdo_transaction.transaction_uid}" - ) - self.console.print( - "[bold]Transaction Status: [/bold]" f"{cdo_transaction.cdo_transaction_status}" - ) - self.console.print( - "[bold]Transaction Error Message: [/bold]" f"{cdo_transaction.error_message}" + transaction_uid = redact_text(str(cdo_transaction.transaction_uid), sensitive_values) + transaction_status = redact_text( + str(cdo_transaction.cdo_transaction_status), sensitive_values ) + error_message = redact_text(str(cdo_transaction.error_message), sensitive_values) + transaction_details = redact_data(cdo_transaction.transaction_details, sensitive_values) + self.console.print("[yellow]The execution failed. Transaction Details:[/yellow]") + self.console.print("[bold]Transaction UID: [/bold]" f"{transaction_uid}") + self.console.print("[bold]Transaction Status: [/bold]" f"{transaction_status}") + self.console.print("[bold]Transaction Error Message: [/bold]" f"{error_message}") self.console.print( - "[bold]Transaction Details: [/bold]\n" - f"{json.dumps(cdo_transaction.transaction_details)}" + "[bold]Transaction Details: [/bold]\n" f"{json.dumps(transaction_details)}" ) sys.exit(-1) diff --git a/cisco_sccfm_cli/commands/inventory/devices/asa/cli_result_renderer.py b/cisco_sccfm_cli/commands/inventory/devices/asa/cli_result_renderer.py index 55013914..e13439fe 100644 --- a/cisco_sccfm_cli/commands/inventory/devices/asa/cli_result_renderer.py +++ b/cisco_sccfm_cli/commands/inventory/devices/asa/cli_result_renderer.py @@ -10,7 +10,7 @@ from rich.table import Table from scc_firewall_manager_sdk import CdoCliResult, Device -from cisco_sccfm_cli.utils import print_json +from cisco_sccfm_cli.utils import print_json, redact_data, redact_text def render_cli_results( @@ -20,21 +20,25 @@ def render_cli_results( uid_to_device: Mapping[str, Device], script: str, output_format: str, + sensitive_values: Sequence[str] = (), ) -> None: if output_format == "json": - render_cli_results_json(results=results) + render_cli_results_json(results=results, sensitive_values=sensitive_values) return render_cli_results_table( console=console, results=results, uid_to_device=uid_to_device, script=script, + sensitive_values=sensitive_values, ) -def render_cli_results_json(*, results: Sequence[CdoCliResult]) -> None: +def render_cli_results_json( + *, results: Sequence[CdoCliResult], sensitive_values: Sequence[str] = () +) -> None: results_data = [item.model_dump(mode="json") for item in results] - print_json(results_data) + print_json(redact_data(results_data, sensitive_values)) def render_cli_results_table( @@ -43,8 +47,9 @@ def render_cli_results_table( results: Sequence[CdoCliResult], uid_to_device: Mapping[str, Device], script: str, + sensitive_values: Sequence[str] = (), ) -> None: - console.print(f"Executed script: {script}") + console.print(f"Executed script: {redact_text(script, sensitive_values)}") table = Table(show_lines=True) table.add_column("Name") table.add_column("UID") @@ -52,10 +57,10 @@ def render_cli_results_table( table.add_column("Error Message") for item in results: table.add_row( - uid_to_device[item.device_uid].name, - item.device_uid, - item.result, - item.error_msg or "-", + redact_text(uid_to_device[item.device_uid].name, sensitive_values), + redact_text(item.device_uid, sensitive_values), + redact_text(item.result or "-", sensitive_values), + redact_text(item.error_msg or "-", sensitive_values), ) console.print(table) diff --git a/cisco_sccfm_cli/commands/inventory/devices/asa/smartlicense/command.py b/cisco_sccfm_cli/commands/inventory/devices/asa/smartlicense/command.py index 991f61ef..d951d811 100644 --- a/cisco_sccfm_cli/commands/inventory/devices/asa/smartlicense/command.py +++ b/cisco_sccfm_cli/commands/inventory/devices/asa/smartlicense/command.py @@ -2,7 +2,11 @@ # # SPDX-License-Identifier: Apache-2.0 -from typing import Any, Final, Sequence, cast +from __future__ import annotations + +import sys +from pathlib import Path +from typing import Any, Final, Mapping, Sequence, cast import click from scc_firewall_manager_sdk import CdoCliResult, CdoTransaction, Device @@ -12,15 +16,18 @@ ) from cisco_sccfm_cli.commands.inventory.devices.asa.shared import ( AsaDeviceTargetCommand, + AsaDeviceTargets, asa_check_option, asa_device_filter_params, ) from cisco_sccfm_cli.commands.inventory.options import config_path_option, format_option from cisco_sccfm_cli.utils import with_spinner from cisco_sccfm_core import AsaCommandLineService +from cisco_sccfm_core.types import ConfigLike class SmartlicenseCommand(AsaDeviceTargetCommand): + _TOKEN_ENVVAR: Final[str] = "SCCFM_SMART_LICENSE_TOKEN" _ASAV_SMART_LICENSE_SCRIPT: Final[str] = ( "license smart\n" "feature tier {feature_tier}\n" @@ -46,19 +53,12 @@ def help_text(self) -> str: " valid and must have at least as many uses as there are devices)." ) - @with_spinner("Applying Smart Licenses...") def handle(self, ctx: click.Context, **kwargs: Any) -> None: check = cast(bool, kwargs.get("check", False)) response_format = cast(str, kwargs.get("format")) config = self.get_profile(ctx=ctx, **kwargs) - targets = self.resolve_asa_targets_from_kwargs( - ctx=ctx, - kwargs=kwargs, - config=config, - include_device_name=False, - require_exactly_one_filter=True, - ) + targets = self._resolve_targets(ctx=ctx, kwargs=kwargs, config=config) if check: self.report_check_targets( @@ -68,12 +68,9 @@ def handle(self, ctx: click.Context, **kwargs: Any) -> None: ) return - token = cast(str, kwargs.get("token")) feature_tier = cast(str, kwargs.get("feature_tier")) throughput_level = cast(str | None, kwargs.get("throughput_level")) - if not token: - ctx.fail("--token is required when not using --check.") if not feature_tier: ctx.fail("--feature-tier is required when not using --check.") @@ -83,12 +80,13 @@ def handle(self, ctx: click.Context, **kwargs: Any) -> None: must_be_virtual=throughput_level is not None, ) + token = self._resolve_token(ctx=ctx, **kwargs) + self._register_sensitive_value(ctx, token) script_commands = self._build_script(feature_tier, throughput_level, token) - - asa_cli_service = AsaCommandLineService(config=config) - results = asa_cli_service.execute_cli( - device_uids=targets.device_uids, - asa_commands=script_commands, + results = self._execute_cli( + config=config, + targets=targets, + script_commands=script_commands, ) self._render_results( @@ -96,8 +94,83 @@ def handle(self, ctx: click.Context, **kwargs: Any) -> None: uid_to_device=targets.uid_to_device, script_text="\n".join(script_commands), format=response_format, + sensitive_values=(token,), + ) + + @with_spinner("Finding ASA devices...") + def _resolve_targets( + self, + *, + ctx: click.Context, + kwargs: Mapping[str, Any], + config: ConfigLike, + ) -> AsaDeviceTargets: + return self.resolve_asa_targets_from_kwargs( + ctx=ctx, + kwargs=kwargs, + config=config, + include_device_name=False, + require_exactly_one_filter=True, ) + @with_spinner("Applying Smart Licenses...") + def _execute_cli( + self, + *, + config: ConfigLike, + targets: AsaDeviceTargets, + script_commands: list[str], + ) -> list[CdoCliResult] | CdoTransaction: + asa_cli_service = AsaCommandLineService(config=config) + return asa_cli_service.execute_cli( + device_uids=targets.device_uids, + asa_commands=script_commands, + ) + + def _resolve_token(self, ctx: click.Context, **kwargs: Any) -> str: + token = cast(str | None, kwargs.get("token")) + token_file = cast(Path | None, kwargs.get("token_file")) + + if token is not None and token_file is not None: + ctx.fail( + "Use only one Smart Licensing token source: --token, " + f"{self._TOKEN_ENVVAR}, or --token-file." + ) + + if token_file is not None: + token = self._read_token_file(ctx=ctx, token_file=token_file) + elif token is None: + if not self._can_prompt(): + ctx.fail( + "A Smart Licensing token is required. Set " + f"{self._TOKEN_ENVVAR}, use --token-file, or run interactively " + "for a hidden prompt." + ) + token = click.prompt("Smart Licensing token", hide_input=True) + + return self._validate_token(ctx=ctx, token=token) + + def _read_token_file(self, ctx: click.Context, token_file: Path) -> str: + try: + if token_file == Path("-"): + contents = click.get_text_stream("stdin").read() + else: + contents = token_file.read_text(encoding="utf-8") + except (OSError, UnicodeError): + ctx.fail(f"Unable to read the Smart Licensing token from {token_file}.") + + return contents.rstrip("\r\n") + + def _validate_token(self, ctx: click.Context, token: str) -> str: + if not token: + ctx.fail("The Smart Licensing token cannot be empty.") + if any(character.isspace() or not character.isprintable() for character in token): + ctx.fail("The Smart Licensing token must be a single printable value without spaces.") + return token + + def _can_prompt(self) -> bool: + return sys.stdin.isatty() + def _build_script( self, feature_tier: str, throughput_level: str | None, token: str ) -> list[str]: @@ -117,9 +190,14 @@ def _render_results( uid_to_device: dict[str, Device], script_text: str, format: str, + sensitive_values: tuple[str, ...], ) -> None: if isinstance(results, CdoTransaction): - self.print_failed_transaction_details(cdo_transaction=results, format="table") + self.print_failed_transaction_details( + cdo_transaction=results, + format=format, + sensitive_values=sensitive_values, + ) return render_cli_results( @@ -128,6 +206,7 @@ def _render_results( uid_to_device=uid_to_device, script=script_text, output_format=format, + sensitive_values=sensitive_values, ) def _validate_virtual_devices( @@ -166,8 +245,29 @@ def build_params(self) -> Sequence[click.Parameter]: type=str, required=False, default=None, - help="The smart license token for your virtual account, generated on " - "https://software.cisco.com/clc", + envvar=self._TOKEN_ENVVAR, + show_envvar=True, + hide_input=True, + help=( + "Smart Licensing token for your virtual account. Passing it directly is " + "supported for compatibility but may expose it in process listings and shell " + f"history; prefer {self._TOKEN_ENVVAR}, --token-file, or the hidden prompt." + ), + ), + click.Option( + ["--token-file"], + type=click.Path( + exists=True, + file_okay=True, + dir_okay=False, + readable=True, + resolve_path=True, + allow_dash=True, + path_type=Path, + ), + required=False, + default=None, + help="Read the Smart Licensing token from a file; use '-' to read from stdin.", ), click.Option( ["--throughput-level"], diff --git a/cisco_sccfm_cli/commands/tests/inventory/devices/asa/smartlicense/test_sensitive_output.py b/cisco_sccfm_cli/commands/tests/inventory/devices/asa/smartlicense/test_sensitive_output.py new file mode 100644 index 00000000..a3bdcd73 --- /dev/null +++ b/cisco_sccfm_cli/commands/tests/inventory/devices/asa/smartlicense/test_sensitive_output.py @@ -0,0 +1,270 @@ +# Copyright 2026 Cisco Systems, Inc. and its affiliates +# +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import json +import traceback +from typing import Any + +import click +import pytest +from _pytest.monkeypatch import MonkeyPatch +from click.testing import CliRunner, Result +from scc_firewall_manager_sdk import ( + ApiException, + CdoCliResult, + CdoTransaction, + Device, + DevicePage, + EntityType, +) + +from cisco_sccfm_cli.cli import cli +from cisco_sccfm_cli.models import Config +from cisco_sccfm_cli.utils.redaction import REDACTED_VALUE +from cisco_sccfm_core.services import AsaCommandLineService, InventoryService + +_TOKEN_ENVVAR = "SCCFM_SMART_LICENSE_TOKEN" +_SYNTHETIC_TOKEN = "sec004-sensitive-output-sentinel-5d91f" + + +@pytest.mark.parametrize("output_format", ["table", "json"]) +def test_should_redact_sensitive_cli_result_fields( + cli_runner: CliRunner, + default_config: Config, + mock_inventory_service: None, + monkeypatch: MonkeyPatch, + caplog: pytest.LogCaptureFixture, + output_format: str, +) -> None: + device = _sensitive_device() + result_model = CdoCliResult( + uid=f"result-{_SYNTHETIC_TOKEN}", + device_uid=device.uid, + execution_uid=f"execution-{_SYNTHETIC_TOKEN}", + result=f"result containing {_SYNTHETIC_TOKEN}", + error_msg=f"error containing {_SYNTHETIC_TOKEN}", + script=f"license smart register idtoken {_SYNTHETIC_TOKEN}", + ) + captured = _stub_cli_execution(monkeypatch, device, [result_model]) + + result = _invoke(cli_runner, output_format) + + assert result.exit_code == 0, result.output + _assert_raw_token_reached_service(captured) + _assert_redacted_everywhere(result, caplog.text) + + +@pytest.mark.parametrize("output_format", ["table", "json"]) +def test_should_redact_sensitive_failed_transaction_fields( + cli_runner: CliRunner, + default_config: Config, + mock_inventory_service: None, + monkeypatch: MonkeyPatch, + caplog: pytest.LogCaptureFixture, + output_format: str, +) -> None: + device = _sensitive_device() + transaction = CdoTransaction( + cdo_transaction_status="ERROR", + entity_uid=f"entity-{_SYNTHETIC_TOKEN}", + entity_url=f"https://example.invalid/{_SYNTHETIC_TOKEN}", + error_details={"failure": _SYNTHETIC_TOKEN}, + error_message=f"transaction failed with {_SYNTHETIC_TOKEN}", + tenant_uid=f"tenant-{_SYNTHETIC_TOKEN}", + transaction_details={ + f"key-{_SYNTHETIC_TOKEN}": f"detail-{_SYNTHETIC_TOKEN}", + "script": f"license smart register idtoken {_SYNTHETIC_TOKEN}", + }, + transaction_polling_url=f"https://example.invalid/poll/{_SYNTHETIC_TOKEN}", + transaction_type="EXECUTE_CLI_COMMAND", + transaction_uid=f"transaction-{_SYNTHETIC_TOKEN}", + ) + captured = _stub_cli_execution(monkeypatch, device, transaction) + + result = _invoke(cli_runner, output_format) + + assert result.exit_code != 0 + _assert_raw_token_reached_service(captured) + _assert_redacted_everywhere(result, caplog.text) + + +@pytest.mark.parametrize("output_format", ["table", "json"]) +def test_should_redact_sensitive_api_exception_body_and_details( + cli_runner: CliRunner, + default_config: Config, + mock_inventory_service: None, + monkeypatch: MonkeyPatch, + caplog: pytest.LogCaptureFixture, + output_format: str, +) -> None: + error_body = json.dumps( + { + "errorMsg": f"API failure containing {_SYNTHETIC_TOKEN}", + "errorCode": f"CODE-{_SYNTHETIC_TOKEN}", + "details": { + f"key-{_SYNTHETIC_TOKEN}": f"detail-{_SYNTHETIC_TOKEN}", + "script": f"license smart register idtoken {_SYNTHETIC_TOKEN}", + }, + } + ) + _stub_inventory_failure( + monkeypatch, + ApiException(status=400, reason=f"reason-{_SYNTHETIC_TOKEN}", body=error_body), + ) + + result = _invoke(cli_runner, output_format) + + assert result.exit_code != 0 + _assert_redacted_everywhere(result, caplog.text) + + +def test_should_redact_runtime_error_before_handle_registers_token( + cli_runner: CliRunner, + default_config: Config, + mock_inventory_service: None, + monkeypatch: MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + _stub_inventory_failure( + monkeypatch, + RuntimeError(f"inventory failure containing {_SYNTHETIC_TOKEN}"), + ) + + result = _invoke(cli_runner, "table") + + assert result.exit_code != 0 + _assert_redacted_everywhere(result, caplog.text) + + +def test_should_redact_click_exception_before_handle_registers_token( + cli_runner: CliRunner, + default_config: Config, + mock_inventory_service: None, + monkeypatch: MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + _stub_inventory_failure( + monkeypatch, + click.ClickException(f"validation failure containing {_SYNTHETIC_TOKEN}"), + ) + + result = _invoke(cli_runner, "table") + + assert result.exit_code != 0 + _assert_redacted_everywhere(result, caplog.text) + + +def _invoke(cli_runner: CliRunner, output_format: str) -> Result: + return cli_runner.invoke( + cli, + [ + "inventory", + "devices", + "asa", + "smartlicense", + "--device-uids", + "requested-device", + "--feature-tier", + "standard", + "--format", + output_format, + ], + env={_TOKEN_ENVVAR: _SYNTHETIC_TOKEN}, + ) + + +def _sensitive_device() -> Device: + device = Device( + uid=f"device-{_SYNTHETIC_TOKEN}", + name=f"asa-{_SYNTHETIC_TOKEN}", + device_type=EntityType.ASA, + ) + device.hardware_model = "ASA5516-X" + return device + + +def _stub_cli_execution( + monkeypatch: MonkeyPatch, + device: Device, + response: list[CdoCliResult] | CdoTransaction, +) -> dict[str, Any]: + captured: dict[str, Any] = {} + + def fake_get_devices( + self: InventoryService, + *, + limit: int, + offset: int, + query: str | None = None, + ) -> DevicePage: + return DevicePage(count=1, items=[device]) + + def stub_cli_init(self: AsaCommandLineService, config: Any) -> None: + return None + + def fake_execute_cli( + self: AsaCommandLineService, + *, + device_uids: list[str], + asa_commands: list[str], + ) -> list[CdoCliResult] | CdoTransaction: + captured["device_uids"] = device_uids + captured["asa_commands"] = asa_commands + return response + + monkeypatch.setattr(InventoryService, "get_devices", fake_get_devices) + monkeypatch.setattr(AsaCommandLineService, "__init__", stub_cli_init) + monkeypatch.setattr(AsaCommandLineService, "execute_cli", fake_execute_cli) + return captured + + +def _stub_inventory_failure(monkeypatch: MonkeyPatch, error: Exception) -> None: + def fake_get_devices( + self: InventoryService, + *, + limit: int, + offset: int, + query: str | None = None, + ) -> DevicePage: + raise error + + monkeypatch.setattr(InventoryService, "get_devices", fake_get_devices) + + +def _assert_raw_token_reached_service(captured: dict[str, Any]) -> None: + commands = captured["asa_commands"] + assert isinstance(commands, list) + assert f"license smart register idtoken {_SYNTHETIC_TOKEN}" in commands + + +def _assert_redacted_everywhere(result: Result, log_text: str) -> None: + surfaces = ( + result.stdout, + result.stderr, + _exception_chain_text(result.exception), + "".join(traceback.format_exception(*result.exc_info)) if result.exc_info else "", + log_text, + ) + if any(_SYNTHETIC_TOKEN in surface for surface in surfaces): + pytest.fail("Sensitive value was exposed by the CLI.", pytrace=False) + assert REDACTED_VALUE in f"{result.stdout}\n{result.stderr}" + + +def _exception_chain_text(exception: BaseException | None) -> str: + pending = [exception] if exception is not None else [] + seen: set[int] = set() + rendered: list[str] = [] + while pending: + current = pending.pop() + if id(current) in seen: + continue + seen.add(id(current)) + rendered.extend((repr(current), repr(vars(current)))) + if current.__context__ is not None: + pending.append(current.__context__) + if current.__cause__ is not None: + pending.append(current.__cause__) + return "\n".join(rendered) diff --git a/cisco_sccfm_cli/commands/tests/inventory/devices/asa/smartlicense/test_token_input.py b/cisco_sccfm_cli/commands/tests/inventory/devices/asa/smartlicense/test_token_input.py new file mode 100644 index 00000000..f89e4b44 --- /dev/null +++ b/cisco_sccfm_cli/commands/tests/inventory/devices/asa/smartlicense/test_token_input.py @@ -0,0 +1,290 @@ +# Copyright 2026 Cisco Systems, Inc. and its affiliates +# +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import pytest +from _pytest.monkeypatch import MonkeyPatch +from click.testing import CliRunner, Result +from scc_firewall_manager_sdk import CdoCliResult, Device, DevicePage + +from cisco_sccfm_cli.cli import cli +from cisco_sccfm_cli.commands.inventory.devices.asa.smartlicense.command import ( + SmartlicenseCommand, +) +from cisco_sccfm_cli.models import Config +from cisco_sccfm_core.services import AsaCommandLineService, InventoryService + +_TOKEN_ENVVAR = "SCCFM_SMART_LICENSE_TOKEN" + + +def test_should_read_smart_license_token_from_environment( + cli_runner: CliRunner, + default_config: Config, + mock_inventory_service: None, + monkeypatch: MonkeyPatch, + sample_devices: list[Device], + sample_cli_results: list[CdoCliResult], + caplog: pytest.LogCaptureFixture, +) -> None: + token = _sentinel("environment") + captured = _stub_execution(monkeypatch, sample_devices, sample_cli_results) + + result = cli_runner.invoke( + cli, + _command_args(), + env={_TOKEN_ENVVAR: token}, + ) + + assert result.exit_code == 0, result.output + assert f"license smart register idtoken {token}" in captured["asa_commands"] + _assert_not_exposed(result, caplog.text, token) + + +def test_should_read_smart_license_token_from_file( + cli_runner: CliRunner, + default_config: Config, + mock_inventory_service: None, + monkeypatch: MonkeyPatch, + sample_devices: list[Device], + sample_cli_results: list[CdoCliResult], + tmp_path: Path, + caplog: pytest.LogCaptureFixture, +) -> None: + token = _sentinel("file") + token_file = tmp_path / "smart-license-token" + token_file.write_text(f"{token}\n", encoding="utf-8") + captured = _stub_execution(monkeypatch, sample_devices, sample_cli_results) + + result = cli_runner.invoke( + cli, + _command_args("--token-file", str(token_file)), + ) + + assert result.exit_code == 0, result.output + assert f"license smart register idtoken {token}" in captured["asa_commands"] + _assert_not_exposed(result, caplog.text, token) + + +def test_should_read_smart_license_token_from_stdin( + cli_runner: CliRunner, + default_config: Config, + mock_inventory_service: None, + monkeypatch: MonkeyPatch, + sample_devices: list[Device], + sample_cli_results: list[CdoCliResult], + caplog: pytest.LogCaptureFixture, +) -> None: + token = _sentinel("stdin") + captured = _stub_execution(monkeypatch, sample_devices, sample_cli_results) + + result = cli_runner.invoke( + cli, + _command_args("--token-file", "-"), + input=f"{token}\n", + ) + + assert result.exit_code == 0, result.output + assert f"license smart register idtoken {token}" in captured["asa_commands"] + _assert_not_exposed(result, caplog.text, token) + + +def test_should_prompt_for_smart_license_token_without_echoing_it( + cli_runner: CliRunner, + default_config: Config, + mock_inventory_service: None, + monkeypatch: MonkeyPatch, + sample_devices: list[Device], + sample_cli_results: list[CdoCliResult], + caplog: pytest.LogCaptureFixture, +) -> None: + token = _sentinel("prompt") + captured = _stub_execution(monkeypatch, sample_devices, sample_cli_results) + monkeypatch.setattr(SmartlicenseCommand, "_can_prompt", lambda self: True) + + result = cli_runner.invoke( + cli, + _command_args(), + input=f"{token}\n", + ) + + assert result.exit_code == 0, result.output + assert "Smart Licensing token:" in result.output + assert f"license smart register idtoken {token}" in captured["asa_commands"] + _assert_not_exposed(result, caplog.text, token) + + +def test_should_fail_noninteractively_without_smart_license_token( + cli_runner: CliRunner, + default_config: Config, + mock_inventory_service: None, + monkeypatch: MonkeyPatch, + sample_devices: list[Device], + sample_cli_results: list[CdoCliResult], +) -> None: + captured = _stub_execution(monkeypatch, sample_devices, sample_cli_results) + monkeypatch.setattr(SmartlicenseCommand, "_can_prompt", lambda self: False) + + result = cli_runner.invoke(cli, _command_args()) + + assert result.exit_code != 0 + assert _TOKEN_ENVVAR in result.output + assert "--token-file" in result.output + assert "asa_commands" not in captured + + +def test_should_reject_multiple_smart_license_token_sources_without_exposing_them( + cli_runner: CliRunner, + default_config: Config, + mock_inventory_service: None, + monkeypatch: MonkeyPatch, + sample_devices: list[Device], + sample_cli_results: list[CdoCliResult], + tmp_path: Path, + caplog: pytest.LogCaptureFixture, +) -> None: + environment_token = _sentinel("environment-conflict") + file_token = _sentinel("file-conflict") + token_file = tmp_path / "smart-license-token" + token_file.write_text(file_token, encoding="utf-8") + captured = _stub_execution(monkeypatch, sample_devices, sample_cli_results) + + result = cli_runner.invoke( + cli, + _command_args("--token-file", str(token_file)), + env={_TOKEN_ENVVAR: environment_token}, + ) + + assert result.exit_code != 0 + assert "only one Smart Licensing token source" in result.output + assert "asa_commands" not in captured + _assert_not_exposed(result, caplog.text, environment_token, file_token) + + +@pytest.mark.parametrize( + "token", + [ + "", + "token with spaces", + "token\nwrite memory", + "token\rwrite memory", + "token\twrite-memory", + ], +) +def test_should_reject_invalid_smart_license_tokens_before_execution( + cli_runner: CliRunner, + default_config: Config, + mock_inventory_service: None, + monkeypatch: MonkeyPatch, + sample_devices: list[Device], + sample_cli_results: list[CdoCliResult], + token: str, + caplog: pytest.LogCaptureFixture, +) -> None: + captured = _stub_execution(monkeypatch, sample_devices, sample_cli_results) + + result = cli_runner.invoke( + cli, + _command_args("--token", token), + ) + + assert result.exit_code != 0 + assert "Smart Licensing token" in result.output + assert "asa_commands" not in captured + if token: + _assert_not_exposed(result, caplog.text, token) + + +def test_should_keep_legacy_argv_token_compatible( + cli_runner: CliRunner, + default_config: Config, + mock_inventory_service: None, + monkeypatch: MonkeyPatch, + sample_devices: list[Device], + sample_cli_results: list[CdoCliResult], + caplog: pytest.LogCaptureFixture, +) -> None: + token = _sentinel("legacy-argv") + captured = _stub_execution(monkeypatch, sample_devices, sample_cli_results) + + result = cli_runner.invoke( + cli, + _command_args("--token", token), + ) + + assert result.exit_code == 0, result.output + assert f"license smart register idtoken {token}" in captured["asa_commands"] + _assert_not_exposed(result, caplog.text, token) + + +def _command_args(*token_args: str) -> list[str]: + return [ + "inventory", + "devices", + "asa", + "smartlicense", + "--device-uids", + "uid-1", + "--feature-tier", + "standard", + "--format", + "json", + *token_args, + ] + + +def _stub_execution( + monkeypatch: MonkeyPatch, + sample_devices: list[Device], + sample_cli_results: list[CdoCliResult], +) -> dict[str, Any]: + captured: dict[str, Any] = {} + + def fake_get_devices( + self: InventoryService, + *, + limit: int, + offset: int, + query: str | None = None, + ) -> DevicePage: + return DevicePage(count=len(sample_devices), items=sample_devices) + + def stub_cli_init(self: AsaCommandLineService, config: Any) -> None: + return None + + def fake_execute_cli( + self: AsaCommandLineService, + *, + device_uids: list[str], + asa_commands: list[str], + ) -> list[CdoCliResult]: + captured["device_uids"] = device_uids + captured["asa_commands"] = asa_commands + return sample_cli_results + + monkeypatch.setattr(InventoryService, "get_devices", fake_get_devices) + monkeypatch.setattr(AsaCommandLineService, "__init__", stub_cli_init) + monkeypatch.setattr(AsaCommandLineService, "execute_cli", fake_execute_cli) + return captured + + +def _sentinel(source: str) -> str: + return f"sec004-{source}-sentinel-7a29f4" + + +def _assert_not_exposed(result: Result, log_text: str, *tokens: str) -> None: + observed = "\n".join( + [ + result.stdout, + result.stderr, + repr(result.exception), + log_text, + ] + ) + for token in tokens: + if token in observed: + pytest.fail("Sensitive value was exposed by the CLI.", pytrace=False) diff --git a/cisco_sccfm_cli/commands/tests/inventory/devices/asa/test_cli_result_renderer.py b/cisco_sccfm_cli/commands/tests/inventory/devices/asa/test_cli_result_renderer.py index eb3315be..27ea5abc 100644 --- a/cisco_sccfm_cli/commands/tests/inventory/devices/asa/test_cli_result_renderer.py +++ b/cisco_sccfm_cli/commands/tests/inventory/devices/asa/test_cli_result_renderer.py @@ -71,3 +71,71 @@ def test_render_cli_results_table() -> None: assert "uid-1" in output assert "uid-2" in output assert "timeout" in output + + +@pytest.mark.parametrize("output_format", ["table", "json"]) +def test_render_cli_results_redacts_sensitive_values( + output_format: str, + capsys: pytest.CaptureFixture[str], +) -> None: + sentinel = "SEC004-SYNTHETIC-SENTINEL" + result = CdoCliResult( + uid="result-sensitive", + device_uid="uid-1", + script=f"license smart register idtoken {sentinel}", + result=f"device echoed {sentinel}", + error_msg=f"failed to apply {sentinel}", + ) + stream = StringIO() + + render_cli_results( + console=Console(file=stream, force_terminal=False, width=120), + results=[result], + uid_to_device=_sample_uid_to_device(), + script=f"license smart register idtoken {sentinel}", + output_format=output_format, + sensitive_values=(sentinel,), + ) + + output = capsys.readouterr().out if output_format == "json" else stream.getvalue() + _assert_not_exposed(output, sentinel) + assert "" in output + + if output_format == "json": + payload = json.loads(output) + assert payload[0]["script"] == "license smart register idtoken " + assert payload[0]["result"] == "device echoed " + assert payload[0]["error_msg"] == "failed to apply " + + +@pytest.mark.parametrize("output_format", ["table", "json"]) +def test_render_cli_results_defensively_redacts_smart_license_token( + output_format: str, + capsys: pytest.CaptureFixture[str], +) -> None: + sentinel = "UNREGISTERED-SMART-LICENSE-TOKEN" + result = CdoCliResult( + uid="result-sensitive", + device_uid="uid-1", + script=f"LICENSE SMART REGISTER IDTOKEN {sentinel}", + result=f"license smart register idtoken {sentinel}", + error_msg=None, + ) + stream = StringIO() + + render_cli_results( + console=Console(file=stream, force_terminal=False, width=120), + results=[result], + uid_to_device=_sample_uid_to_device(), + script=f"license smart register idtoken\t{sentinel}", + output_format=output_format, + ) + + output = capsys.readouterr().out if output_format == "json" else stream.getvalue() + _assert_not_exposed(output, sentinel) + assert "" in output + + +def _assert_not_exposed(output: str, sensitive_value: str) -> None: + if sensitive_value in output: + pytest.fail("Sensitive value was exposed in rendered output.", pytrace=False) diff --git a/cisco_sccfm_cli/commands/tests/test_schema.py b/cisco_sccfm_cli/commands/tests/test_schema.py index 6db96300..e1df190a 100644 --- a/cisco_sccfm_cli/commands/tests/test_schema.py +++ b/cisco_sccfm_cli/commands/tests/test_schema.py @@ -128,10 +128,22 @@ def test_schema_export_should_include_mutation_and_handler_constraints( "At least one update field must be provided." ) assert _constraint(smartlicense["constraints"], "required_unless")["options"] == [ - "token", "feature_tier", ] - assert "--token " in smartlicense["examples"][1] + smartlicense_token = _option(smartlicense["options"], "token") + smartlicense_token_file = _option(smartlicense["options"], "token_file") + token_source_constraint = _constraint_for_options( + smartlicense["constraints"], + "mutually_exclusive", + ["token", "token_file"], + ) + assert smartlicense_token["sensitive"] is True + assert smartlicense_token["envvar"] == "SCCFM_SMART_LICENSE_TOKEN" + assert smartlicense_token_file["type"] == "path" + assert token_source_constraint["min_required"] == 0 + assert token_source_constraint["max_allowed"] == 1 + assert "--token" not in smartlicense["examples"][1] + assert "--token-file" not in smartlicense["examples"][1] assert "--feature-tier standard" in smartlicense["examples"][1] ftd_virtual_dependency = _constraint(ftd_onboard["constraints"], "depends_on") assert ftd_virtual_dependency["option"] == "virtual" @@ -286,6 +298,21 @@ def test_schema_examples_should_reference_declared_options(cli_runner: CliRunner assert set(example_flags) <= declared_aliases +def test_schema_examples_should_omit_sensitive_argv_options(cli_runner: CliRunner) -> None: + result = cli_runner.invoke(cli, ["schema", "export"], prog_name="sccfm-cli") + assert result.exit_code == 0, result.output + + for command in json.loads(result.output)["commands"]: + sensitive_aliases = { + alias + for option in command["options"] + if option["sensitive"] + for alias in option["aliases"] + } + for example in command["examples"]: + assert sensitive_aliases.isdisjoint(shlex.split(example)) + + def _commands_by_name(payload: dict[str, Any]) -> dict[str, dict[str, Any]]: return {command["command"]: command for command in payload["commands"]} @@ -314,6 +341,18 @@ def _constraint(constraints: list[dict[str, Any]], constraint_type: str) -> dict return next(constraint for constraint in constraints if constraint["type"] == constraint_type) +def _constraint_for_options( + constraints: list[dict[str, Any]], + constraint_type: str, + options: list[str], +) -> dict[str, Any]: + return next( + constraint + for constraint in constraints + if constraint["type"] == constraint_type and constraint.get("options") == options + ) + + def _option_group(option_groups: list[dict[str, Any]], name: str) -> dict[str, Any]: return next(option_group for option_group in option_groups if option_group["name"] == name) diff --git a/cisco_sccfm_cli/schema.py b/cisco_sccfm_cli/schema.py index 2cf9c738..f2cddcbf 100644 --- a/cisco_sccfm_cli/schema.py +++ b/cisco_sccfm_cli/schema.py @@ -356,6 +356,7 @@ def _option_schema(option: click.Option, *, scope: str) -> dict[str, Any]: "nargs": option.nargs, "is_flag": bool(option.is_flag), "is_bool_flag": bool(getattr(option, "is_bool_flag", False)), + "sensitive": bool(option.hide_input), "envvar": _envvar(option.envvar), "metavar": option.metavar, } @@ -624,7 +625,21 @@ def _path_specific_constraints( ] ) if path == ("inventory", "devices", "asa", "smartlicense"): - constraints.append(_required_unless("token", "feature_tier", unless="check")) + constraints.extend( + [ + _required_unless("feature_tier", unless="check"), + { + "type": "mutually_exclusive", + "options": ["token", "token_file"], + "min_required": 0, + "max_allowed": 1, + "description": ( + "Use at most one explicit Smart Licensing token source; omit both " + "for the hidden interactive prompt." + ), + }, + ] + ) if path == ("objects", "network", "create") and "value" in option_names: constraints.append(_required_unless("value", unless="check")) if path == ("objects", "network-group", "create"): @@ -828,7 +843,7 @@ def _example_option_parts( parts: list[str] = [] for option_name in option_names: option = option_by_name.get(option_name) - if option is None: + if option is None or option.hide_input: continue flag = _preferred_flag(option) if option.is_flag: diff --git a/cisco_sccfm_cli/utils/__init__.py b/cisco_sccfm_cli/utils/__init__.py index 51baf0b5..65ae4fea 100644 --- a/cisco_sccfm_cli/utils/__init__.py +++ b/cisco_sccfm_cli/utils/__init__.py @@ -5,6 +5,7 @@ from __future__ import annotations from cisco_sccfm_cli.utils.json_output import json_text, print_json +from cisco_sccfm_cli.utils.redaction import redact_data, redact_text from cisco_sccfm_cli.utils.spinner import with_spinner -__all__ = ["json_text", "print_json", "with_spinner"] +__all__ = ["json_text", "print_json", "redact_data", "redact_text", "with_spinner"] diff --git a/cisco_sccfm_cli/utils/redaction.py b/cisco_sccfm_cli/utils/redaction.py new file mode 100644 index 00000000..b84979e0 --- /dev/null +++ b/cisco_sccfm_cli/utils/redaction.py @@ -0,0 +1,50 @@ +# Copyright 2026 Cisco Systems, Inc. and its affiliates +# +# SPDX-License-Identifier: Apache-2.0 + +"""Redact sensitive values before rendering CLI output.""" + +from __future__ import annotations + +import re +from collections.abc import Sequence +from typing import Any + +REDACTED_VALUE = "" + +_SMART_LICENSE_TOKEN = re.compile( + r"(\blicense\s+smart\s+register\s+idtoken\s+)(?:\S+)", + flags=re.IGNORECASE, +) + + +def redact_text(value: str, sensitive_values: Sequence[str] = ()) -> str: + """Return text with exact secrets and Smart Licensing tokens redacted.""" + redacted = value + for sensitive_value in _longest_first(sensitive_values): + redacted = redacted.replace(sensitive_value, REDACTED_VALUE) + return _SMART_LICENSE_TOKEN.sub(rf"\1{REDACTED_VALUE}", redacted) + + +def redact_data(value: Any, sensitive_values: Sequence[str] = ()) -> Any: + """Recursively redact strings in JSON-like data without mutating the input.""" + if isinstance(value, str): + return redact_text(value, sensitive_values) + if isinstance(value, dict): + return { + redact_data(key, sensitive_values): redact_data(item, sensitive_values) + for key, item in value.items() + } + if isinstance(value, list): + return [redact_data(item, sensitive_values) for item in value] + if isinstance(value, tuple): + return tuple(redact_data(item, sensitive_values) for item in value) + if isinstance(value, set): + return {redact_data(item, sensitive_values) for item in value} + if isinstance(value, frozenset): + return frozenset(redact_data(item, sensitive_values) for item in value) + return value + + +def _longest_first(sensitive_values: Sequence[str]) -> list[str]: + return sorted({value for value in sensitive_values if value}, key=len, reverse=True) diff --git a/docs/cli/sccfm-cli-inventory-devices-asa-smartlicense.md b/docs/cli/sccfm-cli-inventory-devices-asa-smartlicense.md index b31c7b6e..3ac8e52b 100644 --- a/docs/cli/sccfm-cli-inventory-devices-asa-smartlicense.md +++ b/docs/cli/sccfm-cli-inventory-devices-asa-smartlicense.md @@ -27,9 +27,15 @@ Options: --format [table|json] Output format [default: table] --config-path PATH Path to the configuration file (defaults to ~/.sccfm-cli/config.json). - -t, --token TEXT The smart license token for your virtual - account, generated on - https://software.cisco.com/clc + -t, --token TEXT Smart Licensing token for your virtual account. + Passing it directly is supported for + compatibility but may expose it in process + listings and shell history; prefer + SCCFM_SMART_LICENSE_TOKEN, --token-file, or the + hidden prompt. [env var: + SCCFM_SMART_LICENSE_TOKEN] + --token-file FILE Read the Smart Licensing token from a file; use + '-' to read from stdin. --throughput-level [100M|1G] The throughput level of your ASA (required only for virtual ASAs) --feature-tier [standard] The feature tier of your ASA diff --git a/docs/man/man1/sccfm-cli-inventory-devices-asa-smartlicense.1 b/docs/man/man1/sccfm-cli-inventory-devices-asa-smartlicense.1 index 2828a444..c9196fed 100644 --- a/docs/man/man1/sccfm-cli-inventory-devices-asa-smartlicense.1 +++ b/docs/man/man1/sccfm-cli-inventory-devices-asa-smartlicense.1 @@ -30,7 +30,10 @@ Output format [default: table] Path to the configuration file (defaults to ~/.sccfm-cli/config.json). .TP \fB\-t,\fP \-\-token TEXT -The smart license token for your virtual account, generated on https://software.cisco.com/clc +Smart Licensing token for your virtual account. Passing it directly is supported for compatibility but may expose it in process listings and shell history; prefer SCCFM_SMART_LICENSE_TOKEN, --token-file, or the hidden prompt. [env var: SCCFM_SMART_LICENSE_TOKEN] +.TP +\fB\-\-token\-file\fP FILE +Read the Smart Licensing token from a file; use '-' to read from stdin. .TP \fB\-\-throughput\-level\fP [100M|1G] The throughput level of your ASA (required only for virtual ASAs) diff --git a/skills/sccfm-cli/SKILL.md b/skills/sccfm-cli/SKILL.md index 72ca6648..de6be111 100644 --- a/skills/sccfm-cli/SKILL.md +++ b/skills/sccfm-cli/SKILL.md @@ -222,7 +222,8 @@ Parse the JSON output. The schema contains: - `option_groups`: inter-option constraints - `constraints`: validation and preflight constraints - `global_options`: flags that must appear before the command path -- `options`: accepted flags, types, defaults, choices, and descriptions +- `options`: accepted flags, types, defaults, choices, sensitivity, environment sources, and + descriptions - `examples`: declared usage examples, if any Cache the schema in memory for the session. Do not re-export unless: @@ -331,11 +332,16 @@ Do not add optional flags because they seem convenient. ### Sensitive and Risky Flags 1. Never include API tokens in chat output. -2. Do not pass diagnostic or verbose flags unless the user explicitly asked for +2. Treat every option with `sensitive: true` as a secret even when its name is neutral. Never put + its value on argv or in a generated command. Prefer the schema-declared `envvar`, a hidden local + prompt, or another documented non-argv source. +3. When a sensitive value is required, tell the user which environment variable or local prompt + the command uses without asking for or displaying the value. +4. Do not pass diagnostic or verbose flags unless the user explicitly asked for diagnostic output on a failed readonly command. -3. Do not pass local output/export/config path options unless the user explicitly +5. Do not pass local output/export/config path options unless the user explicitly asked for local writes and provided the destination path. -4. Never rely on schema default output paths for customer data exports. +6. Never rely on schema default output paths for customer data exports. ### Target Identity Rules diff --git a/tests/test_redaction.py b/tests/test_redaction.py new file mode 100644 index 00000000..40d5127a --- /dev/null +++ b/tests/test_redaction.py @@ -0,0 +1,46 @@ +# Copyright 2026 Cisco Systems, Inc. and its affiliates +# +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from cisco_sccfm_cli.utils import redact_data, redact_text + + +def test_redact_text_replaces_longest_sensitive_values_first() -> None: + assert ( + redact_text( + "long-secret and short", + ("short", "long-secret", "secret", ""), + ) + == " and " + ) + + +def test_redact_text_redacts_smart_license_tokens_without_known_values() -> None: + assert ( + redact_text("LICENSE smart register IDTOKEN synthetic-token\nwrite memory") + == "LICENSE smart register IDTOKEN \nwrite memory" + ) + + +def test_redact_data_recurses_without_mutating_input() -> None: + sentinel = "SEC004-NESTED-SENTINEL" + payload = { + f"key-{sentinel}": [ + f"value-{sentinel}", + (f"tuple-{sentinel}", {f"set-{sentinel}"}), + ], + "unchanged": 42, + } + + redacted = redact_data(payload, (sentinel,)) + + assert sentinel in next(iter(payload)) + assert redacted == { + "key-": [ + "value-", + ("tuple-", {"set-"}), + ], + "unchanged": 42, + } From 2b3ee4b4050906b2045f0f06bc9bb6a1ecdf0c16 Mon Sep 17 00:00:00 2001 From: Andrei Huides Date: Tue, 11 Aug 2026 13:50:04 +0300 Subject: [PATCH 04/19] fix(lh-102436): install resolves a broken SDK --- .github/workflows/ci.yml | 44 +++++++++++++++++-- .../tests/test_packaging_metadata.py | 10 +++++ poetry.lock | 2 +- pyproject.toml | 2 +- sccfm-ansible/requirements.txt | 2 +- 5 files changed, 53 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b6b39b6e..52215050 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -122,6 +122,45 @@ jobs: --max-archive-depth=1 \ "${{ steps.bump.outputs.artifact_path }}" + - name: Build and verify wheel + if: steps.bump.outputs.bumped == 'true' + id: wheel + run: | + set -euo pipefail + poetry build -f wheel + PACKAGE_VERSION="$(poetry version -s)" + WHEEL_RELATIVE_PATH="dist/cisco_sccfm_devkit-${PACKAGE_VERSION}-py3-none-any.whl" + WHEEL_PATH="${GITHUB_WORKSPACE}/${WHEEL_RELATIVE_PATH}" + test -f "${WHEEL_PATH}" + SMOKE_ROOT="$(mktemp -d "${RUNNER_TEMP}/sccfm-wheel-smoke.XXXXXX")" + python -m venv "${SMOKE_ROOT}/venv" + SMOKE_PYTHON="${SMOKE_ROOT}/venv/bin/python" + SMOKE_CLI="${SMOKE_ROOT}/venv/bin/sccfm-cli" + unset PYTHONHOME PYTHONPATH POETRY_ACTIVE + cd "${SMOKE_ROOT}" + "${SMOKE_PYTHON}" -I -m pip install --no-cache-dir "${WHEEL_PATH}" + "${SMOKE_PYTHON}" -I -m pip check + "${SMOKE_PYTHON}" -I - <<'PY' + import importlib + from importlib.metadata import version + + expected_sdk = "1.17.27" + installed_sdk = version("scc-firewall-manager-sdk") + if installed_sdk != expected_sdk: + raise SystemExit(f"expected SDK {expected_sdk}, installed {installed_sdk}") + for package in ( + "scc_firewall_manager_sdk", + "cisco_sccfm_cli", + "cisco_sccfm_core", + "cisco_sccfm_scripts", + ): + importlib.import_module(package) + PY + "${SMOKE_CLI}" --help >/dev/null + "${SMOKE_CLI}" schema export --format json | "${SMOKE_PYTHON}" -I -c \ + 'import json, sys; commands = json.load(sys.stdin).get("commands"); assert isinstance(commands, list) and len(commands) == 57, f"expected 57 commands, got {len(commands) if isinstance(commands, list) else 0}"' + echo "path=${WHEEL_RELATIVE_PATH}" >> "$GITHUB_OUTPUT" + - name: Commit and tag verified release if: steps.bump.outputs.bumped == 'true' env: @@ -139,13 +178,10 @@ jobs: git push origin HEAD:${BRANCH} git push origin --tags - - name: Build wheel - run: poetry build -f wheel - - name: Create release if: steps.bump.outputs.bumped == 'true' uses: ncipollo/release-action@v1 with: tag: ${{ steps.bump.outputs.new_tag }} - artifacts: "dist/*.whl,${{ steps.bump.outputs.artifact_path }}" + artifacts: "${{ steps.wheel.outputs.path }},${{ steps.bump.outputs.artifact_path }}" token: ${{ secrets.GITHUB_TOKEN }} diff --git a/cisco_sccfm_core/tests/test_packaging_metadata.py b/cisco_sccfm_core/tests/test_packaging_metadata.py index 352bc356..0010eda8 100644 --- a/cisco_sccfm_core/tests/test_packaging_metadata.py +++ b/cisco_sccfm_core/tests/test_packaging_metadata.py @@ -35,6 +35,16 @@ def test_published_packages_use_cisco_prefix() -> None: assert all(target.startswith("cisco_sccfm_") for target in script_targets) +def test_generated_sdk_is_pinned_to_the_verified_compatible_version() -> None: + poetry = _poetry_config() + collection_requirements = (PROJECT_ROOT / "sccfm-ansible" / "requirements.txt").read_text( + encoding="utf-8" + ) + + assert poetry["dependencies"]["scc-firewall-manager-sdk"] == "1.17.27" + assert "scc-firewall-manager-sdk==1.17.27" in collection_requirements.splitlines() + + def test_pyinstaller_spec_uses_repository_relative_entrypoint() -> None: spec = (PROJECT_ROOT / "sccfm-cli.spec").read_text(encoding="utf-8") diff --git a/poetry.lock b/poetry.lock index 5baae9b7..d63bcf5b 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1899,4 +1899,4 @@ files = [ [metadata] lock-version = "2.1" python-versions = "^3.12" -content-hash = "5b71f343ebbea0ea086567ce5be38c6501c80021d974292dde9381f96f3d1598" +content-hash = "415f0c5c91746ee48f47f0f4a5e2f4c16c2dec9ed9c2b28ccf4b4f8521376e22" diff --git a/pyproject.toml b/pyproject.toml index 66692fd5..2554f552 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,7 +38,7 @@ python = "^3.12" click = ">=8.0.0,<9" rich = "^14.2.0" click-option-group = "^0.5.9" -scc-firewall-manager-sdk = "^1.17.27" +scc-firewall-manager-sdk = "1.17.27" questionary = "^2.1.1" paramiko = "^3.5.0" diff --git a/sccfm-ansible/requirements.txt b/sccfm-ansible/requirements.txt index 92c37dbd..f648e10a 100644 --- a/sccfm-ansible/requirements.txt +++ b/sccfm-ansible/requirements.txt @@ -2,7 +2,7 @@ # Install with: pip install -r requirements.txt # Core dependencies -scc-firewall-manager-sdk>=1.17.27 +scc-firewall-manager-sdk==1.17.27 paramiko>=3.5.0 # Note: cisco-sccfm-devkit includes both cisco_sccfm_cli and cisco_sccfm_core # For local development, use: poetry install from parent directory From bb75c2edbc2b35a21b5ba55aaf3776ffcae4c287 Mon Sep 17 00:00:00 2001 From: Andrei Huides Date: Tue, 11 Aug 2026 14:56:36 +0300 Subject: [PATCH 05/19] fix(lh-102436): hide api-token --- AGENTS.md | 9 +- CONTRIBUTING.md | 8 +- README.md | 7 +- cisco_sccfm_cli/commands/configure.py | 50 ++++- .../commands/tests/test_configure.py | 133 ++++++++++++- cisco_sccfm_cli/commands/tests/test_schema.py | 6 + cisco_sccfm_cli/models/config.py | 4 +- cisco_sccfm_cli/models/tests/test_config.py | 18 ++ cisco_sccfm_cli/services/config_service.py | 55 +++++- .../services/tests/test_config_service.py | 176 ++++++++++++++++++ cisco_sccfm_scripts/devkit_cli.py | 8 +- cisco_sccfm_scripts/setup_tokens.py | 48 +++-- cisco_sccfm_scripts/token_store.py | 4 +- docs/cli/sccfm-cli-configure.md | 6 +- docs/man/man1/sccfm-cli-configure.1 | 2 +- skills/sccfm-cli/SKILL.md | 4 +- tests/test_token_workspace.py | 59 ++++++ 17 files changed, 555 insertions(+), 42 deletions(-) create mode 100644 cisco_sccfm_cli/models/tests/test_config.py diff --git a/AGENTS.md b/AGENTS.md index 344c92b9..7002b7e7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,7 +49,7 @@ This repository ships skill files that document how to interact with the CLI and source cisco_sccfm_scripts/activate.sh # Configure credentials once -sccfm-cli configure --region us --api-token +sccfm-cli configure --region us # API token is entered at a hidden prompt # Check connectivity sccfm-cli status @@ -63,14 +63,17 @@ devkit ## Required environment variables -Copy `.env.example` to `.env` and fill in your values (loaded automatically by direnv): +For non-interactive use, pre-set these values in the environment (loaded automatically by direnv): ```bash export SCCFM_REGION=us # int | us | eu | apj | au | uae | in | ci export SCCFM_API_TOKEN="..." # from SCCFM UI > Settings > API Tokens ``` -Credentials are also stored under `~/.sccfm-cli/` after running `sccfm-cli configure`. Override the path with `--config-path` or `SCCFM_CONFIG`. +Credentials are also stored under `~/.sccfm-cli/` after running `sccfm-cli configure`. On POSIX, +the CLI enforces mode `0700` on that directory and `0600` on its configuration file. On Windows, +keep it in the user profile and rely on filesystem ACLs. Override the path with `--config-path` or +`SCCFM_CONFIG`, and keep custom paths private. ## Testing instructions diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 42afc396..7e87d4dc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -51,11 +51,15 @@ reserve breaking changes until the next major version release. ``` 3. Set up your SCCFM credentials: + ```bash - cp .env.example .env - # Edit .env with your API token + devkit + # Select change-tokens; the API token is entered at a hidden prompt. ``` + This creates the local credential files with private POSIX permissions. On Windows, keep them + under your user profile and rely on the filesystem's per-user access controls. + Now whenever you `cd` into the project, the virtualenv activates and env vars load automatically. ## Committing Changes diff --git a/README.md b/README.md index 23762fb2..facac0c2 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ devkit # interactive developer toolkit menu ## Commands -- `sccfm-cli configure [--region REGION] [--api-token TOKEN] [--config-path PATH]`: Captures the SCCFM region (`int`, `us`, `eu`, `apj`, `au`, `uae`, `in`, or `ci`) plus an API token (see the [auth guide](https://developer.cisco.com/docs/cisco-security-cloud-control-firewall-manager/authentication/)) and stores it under `~/.sccfm-cli/` (override with `--config-path` or `SCCFM_CONFIG`). +- `sccfm-cli configure --region REGION [--config-path PATH]`: Captures the SCCFM region (`int`, `us`, `eu`, `apj`, `au`, `uae`, `in`, or `ci`) plus an API token (see the [auth guide](https://developer.cisco.com/docs/cisco-security-cloud-control-firewall-manager/authentication/)). The token comes from a pre-set `SCCFM_API_TOKEN` or, in an interactive terminal, a hidden prompt. Direct `--api-token` input remains available for compatibility but can expose the token in shell history and process listings. - `sccfm-cli status [--config-path PATH]`: Shows the current profile plus SCCFM connectivity health using Rich tables. - `sccfm-cli inventory devices list [--limit N] [--offset N] [--query TEXT] [--format table|json]`: Lists device inventory with pagination and optional name filtering. - `sccfm-cli inventory manager list [--limit N] [--offset N] [--query TEXT] [--format table|json]`: Lists manager inventory with the same filters. @@ -45,6 +45,11 @@ devkit # interactive developer toolkit menu Set the active profile once via the global option: `sccfm-cli --profile lab status`. Every command lives in `cisco_sccfm_cli/commands/` as a concrete implementation of the command-pattern friendly `BaseCommand`, keeping files small and behavior isolated. +By default, configuration is stored in `~/.sccfm-cli/config.json`. On POSIX systems the CLI +enforces mode `0700` on `~/.sccfm-cli` and `0600` on the configuration file, including existing +storage. On Windows, keep the configuration in your user profile and rely on the filesystem's +per-user access controls. Keep custom configuration paths private on every platform. + Generated CLI reference docs can be previewed locally: ```bash diff --git a/cisco_sccfm_cli/commands/configure.py b/cisco_sccfm_cli/commands/configure.py index 95e3ff4a..42be5104 100644 --- a/cisco_sccfm_cli/commands/configure.py +++ b/cisco_sccfm_cli/commands/configure.py @@ -4,10 +4,12 @@ from __future__ import annotations +import sys from pathlib import Path -from typing import Any, Sequence +from typing import Any, Final, Sequence, cast import click +from click.core import ParameterSource from click_option_group import GroupedOption, OptionGroup from rich.console import Console @@ -18,6 +20,8 @@ class ConfigureCommand(BaseCommand): + _API_TOKEN_ENVVAR: Final[str] = "SCCFM_API_TOKEN" + def __init__( self, console: Console, @@ -59,16 +63,25 @@ def build_params(self) -> Sequence[click.Parameter]: ), GroupedOption( ["--api-token"], - help="API token for the chosen region", + type=str, + default=None, + envvar=self._API_TOKEN_ENVVAR, + show_envvar=True, + hide_input=True, + help=( + "API token for the chosen region. Passing it directly is supported for " + "compatibility but may expose it in process listings and shell history; " + f"prefer {self._API_TOKEN_ENVVAR} or the hidden prompt." + ), group=credential_group, - required=True, + required=False, ), ] def handle(self, ctx: click.Context, **kwargs: Any) -> None: profile = ctx.obj["profile"] region = kwargs["region"] - api_token = kwargs["api_token"] + api_token = self._resolve_api_token(ctx=ctx, **kwargs) config_path = kwargs["config_path"] config_service = ConfigService(config_path) @@ -77,3 +90,32 @@ def handle(self, ctx: click.Context, **kwargs: Any) -> None: config = Config(profile=profile, region=normalized_region, api_token=api_token) config_service.save(config) self.console.print(f"[green]Profile '{profile}' updated[/green]") + + def _resolve_api_token(self, ctx: click.Context, **kwargs: Any) -> str: + api_token = cast(str | None, kwargs.get("api_token")) + source = ctx.get_parameter_source("api_token") + + if api_token is None: + if not self._can_prompt(): + ctx.fail( + "An API token is required. Set " + f"{self._API_TOKEN_ENVVAR} or run interactively for a hidden prompt." + ) + api_token = click.prompt("API token", hide_input=True) + self._register_sensitive_value(ctx, api_token) + elif source is ParameterSource.COMMANDLINE: + click.echo( + "Warning: passing --api-token directly may expose it in process listings and " + f"shell history; prefer {self._API_TOKEN_ENVVAR} or the hidden prompt.", + err=True, + ) + + return self._validate_api_token(ctx=ctx, api_token=api_token) + + def _validate_api_token(self, ctx: click.Context, api_token: str) -> str: + if not api_token.strip(): + ctx.fail("The API token cannot be empty.") + return api_token + + def _can_prompt(self) -> bool: + return sys.stdin.isatty() diff --git a/cisco_sccfm_cli/commands/tests/test_configure.py b/cisco_sccfm_cli/commands/tests/test_configure.py index 9d7bdd6f..b9d5053a 100644 --- a/cisco_sccfm_cli/commands/tests/test_configure.py +++ b/cisco_sccfm_cli/commands/tests/test_configure.py @@ -4,13 +4,19 @@ from __future__ import annotations +import hmac from pathlib import Path +from _pytest.monkeypatch import MonkeyPatch from click.testing import CliRunner from cisco_sccfm_cli.cli import cli +from cisco_sccfm_cli.commands.configure import ConfigureCommand +from cisco_sccfm_cli.models import Config from cisco_sccfm_cli.services import ConfigService +_API_TOKEN_ENVVAR = "SCCFM_API_TOKEN" + def test_should_create_new_profile(cli_runner: CliRunner, config_path: Path) -> None: """Configure command should create a new profile with provided credentials.""" @@ -30,12 +36,123 @@ def test_should_create_new_profile(cli_runner: CliRunner, config_path: Path) -> ) assert result.exit_code == 0 + assert "process listings" in result.stderr + _assert_not_exposed(result.output, "token-xyz") service = ConfigService(path=config_path) stored = service.load("lab") assert stored is not None assert stored.region == "eu" - assert stored.api_token == "token-xyz" + _assert_same_secret(stored.api_token, "token-xyz") + + +def test_should_read_api_token_from_environment(cli_runner: CliRunner, config_path: Path) -> None: + """Configure should avoid argv exposure by accepting an environment token.""" + api_token = "sec005-environment-token-63ae1" + result = cli_runner.invoke( + cli, + ["--profile", "lab", "configure", "--region", "eu"], + env={_API_TOKEN_ENVVAR: api_token}, + ) + + assert result.exit_code == 0 + assert "process listings" not in result.output + _assert_not_exposed(result.output, api_token) + + stored = ConfigService(path=config_path).load("lab") + assert stored is not None + _assert_same_secret(stored.api_token, api_token) + + +def test_should_prompt_for_api_token_without_echoing_it( + cli_runner: CliRunner, + config_path: Path, + monkeypatch: MonkeyPatch, +) -> None: + """Configure should use a hidden prompt when no non-interactive source is supplied.""" + api_token = "sec005-prompt-token-91bc2" + monkeypatch.setattr(ConfigureCommand, "_can_prompt", lambda self: True) + + result = cli_runner.invoke( + cli, + ["--profile", "lab", "configure", "--region", "eu"], + input=f"{api_token}\n", + env={_API_TOKEN_ENVVAR: None}, + ) + + assert result.exit_code == 0 + assert "API token:" in result.output + _assert_not_exposed(result.output, api_token) + + stored = ConfigService(path=config_path).load("lab") + assert stored is not None + _assert_same_secret(stored.api_token, api_token) + + +def test_should_redact_prompted_api_token_from_save_failures( + cli_runner: CliRunner, + config_path: Path, + monkeypatch: MonkeyPatch, +) -> None: + """Prompted tokens should enter command-scoped redaction before configuration is saved.""" + api_token = "sec005-prompt-failure-token-a471e" + monkeypatch.setattr(ConfigureCommand, "_can_prompt", lambda self: True) + + def fail_save(self: ConfigService, config: Config) -> None: + raise RuntimeError(f"Synthetic save failure involving {config.api_token}") + + monkeypatch.setattr(ConfigService, "save", fail_save) + + result = cli_runner.invoke( + cli, + [ + "--profile", + "lab", + "configure", + "--region", + "eu", + "--config-path", + str(config_path), + ], + input=f"{api_token}\n", + env={_API_TOKEN_ENVVAR: None}, + ) + + assert result.exit_code != 0 + assert "" in result.output + _assert_not_exposed(result.output, api_token) + _assert_not_exposed(repr(result.exception), api_token) + + +def test_should_fail_clearly_without_api_token_in_non_interactive_session( + cli_runner: CliRunner, + config_path: Path, + monkeypatch: MonkeyPatch, +) -> None: + """Configure should not attempt to read a secret from redirected stdin.""" + monkeypatch.setattr(ConfigureCommand, "_can_prompt", lambda self: False) + + result = cli_runner.invoke( + cli, + ["configure", "--region", "eu", "--config-path", str(config_path)], + env={_API_TOKEN_ENVVAR: None}, + ) + + assert result.exit_code == 2 + assert f"Set {_API_TOKEN_ENVVAR}" in result.output + assert "hidden prompt" in result.output + + +def test_should_reject_blank_api_token(cli_runner: CliRunner, config_path: Path) -> None: + """Configure should reject environment tokens containing only whitespace.""" + result = cli_runner.invoke( + cli, + ["configure", "--region", "eu", "--config-path", str(config_path)], + env={_API_TOKEN_ENVVAR: " "}, + ) + + assert result.exit_code == 2 + assert "API token cannot be empty" in result.output def test_should_allow_modification_of_existing_profile( @@ -68,7 +185,7 @@ def test_should_allow_modification_of_existing_profile( f"{ConfigService(path=config_path).list_profiles()}" ) assert stored.region == old_region - assert stored.api_token == old_token + _assert_same_secret(stored.api_token, old_token) result2 = cli_runner.invoke( cli, @@ -87,7 +204,7 @@ def test_should_allow_modification_of_existing_profile( updated_stored = ConfigService(path=config_path).load(profile_name) assert updated_stored is not None assert updated_stored.region == new_region - assert updated_stored.api_token == new_token + _assert_same_secret(updated_stored.api_token, new_token) assert result2.exit_code == 0 @@ -114,3 +231,13 @@ def test_should_normalize_legacy_region_aliases(cli_runner: CliRunner, config_pa stored = ConfigService(path=config_path).load("lab") assert stored is not None assert stored.region == "au" + + +def _assert_not_exposed(output: str, api_token: str) -> None: + if api_token in output: + raise AssertionError("API token was exposed in command output") + + +def _assert_same_secret(actual: str, expected: str) -> None: + if not hmac.compare_digest(actual, expected): + raise AssertionError("Stored API token did not match the supplied token") diff --git a/cisco_sccfm_cli/commands/tests/test_schema.py b/cisco_sccfm_cli/commands/tests/test_schema.py index e1df190a..5e1e209c 100644 --- a/cisco_sccfm_cli/commands/tests/test_schema.py +++ b/cisco_sccfm_cli/commands/tests/test_schema.py @@ -54,6 +54,7 @@ def test_schema_export_should_describe_options_and_auth_requirements( commands = _commands_by_name(json.loads(result.output)) configure = commands["sccfm-cli configure"] region = _option(configure["options"], "region") + api_token = _option(configure["options"], "api_token") assert configure["readonly"] is True assert configure["side_effects"] == [ @@ -64,6 +65,11 @@ def test_schema_export_should_describe_options_and_auth_requirements( assert region["type"] == "choice" assert "us" in region["values"] assert region["required"] is True + assert api_token["required"] is False + assert api_token["sensitive"] is True + assert api_token["envvar"] == "SCCFM_API_TOKEN" + assert "--api-token" not in configure["examples"][1] + assert "--region int" in configure["examples"][1] status = commands["sccfm-cli status"] assert status["auth"]["mode"] == "sccfm_profile" diff --git a/cisco_sccfm_cli/models/config.py b/cisco_sccfm_cli/models/config.py index 9339f69f..f3b7bb8e 100644 --- a/cisco_sccfm_cli/models/config.py +++ b/cisco_sccfm_cli/models/config.py @@ -4,11 +4,11 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field @dataclass(frozen=True) class Config: profile: str region: str - api_token: str + api_token: str = field(repr=False) diff --git a/cisco_sccfm_cli/models/tests/test_config.py b/cisco_sccfm_cli/models/tests/test_config.py new file mode 100644 index 00000000..b6dc3a9b --- /dev/null +++ b/cisco_sccfm_cli/models/tests/test_config.py @@ -0,0 +1,18 @@ +# Copyright 2026 Cisco Systems, Inc. and its affiliates +# +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from cisco_sccfm_cli.models import Config + + +def test_config_repr_should_not_expose_api_token() -> None: + api_token = "sec005-repr-token-8c0d3" + config = Config(profile="default", region="us", api_token=api_token) + + representation = repr(config) + + if api_token in representation: + raise AssertionError("Config repr exposed its API token") + assert representation == "Config(profile='default', region='us')" diff --git a/cisco_sccfm_cli/services/config_service.py b/cisco_sccfm_cli/services/config_service.py index faea8673..741d0b8a 100644 --- a/cisco_sccfm_cli/services/config_service.py +++ b/cisco_sccfm_cli/services/config_service.py @@ -5,18 +5,22 @@ from __future__ import annotations import json +import os from pathlib import Path -from typing import Any, Dict, Mapping +from typing import Any, Dict, Mapping, TextIO from cisco_sccfm_cli.models import Config _CONFIG_DIR = Path.home() / ".sccfm-cli" _CONFIG_FILE = _CONFIG_DIR / "config.json" +_CONFIG_DIR_MODE = 0o700 +_CONFIG_FILE_MODE = 0o600 class ConfigService: def __init__(self, path: Path | None = None) -> None: self._path = path or _CONFIG_FILE + self._uses_default_path = self._path == _CONFIG_FILE def load(self, profile: str) -> Config | None: profiles = self._load_profiles() @@ -45,6 +49,7 @@ def list_profiles(self) -> list[Config]: ] def _load_profiles(self) -> Dict[str, Dict[str, Any]]: + self._harden_existing_storage() if not self._path.exists(): return {} with self._path.open("r", encoding="utf-8") as handle: @@ -52,6 +57,50 @@ def _load_profiles(self) -> Dict[str, Dict[str, Any]]: return dict(data.get("profiles", {})) def _persist(self, payload: Mapping[str, Any]) -> None: - self._path.parent.mkdir(parents=True, exist_ok=True) - with self._path.open("w", encoding="utf-8") as handle: + self._ensure_parent_directory() + self._harden_existing_file() + with self._open_directly_for_write() as handle: json.dump(payload, handle, indent=2) + + def _ensure_parent_directory(self) -> None: + created = False + try: + self._path.parent.mkdir(parents=True, mode=_CONFIG_DIR_MODE) + created = True + except FileExistsError: + if not self._path.parent.is_dir(): + raise + + if self._supports_posix_permissions() and (created or self._uses_default_path): + self._path.parent.chmod(_CONFIG_DIR_MODE) + + def _harden_existing_storage(self) -> None: + if not self._supports_posix_permissions(): + return + if self._uses_default_path and self._path.parent.exists(): + self._path.parent.chmod(_CONFIG_DIR_MODE) + self._harden_existing_file() + + def _harden_existing_file(self) -> None: + if self._supports_posix_permissions() and self._path.exists(): + self._path.chmod(_CONFIG_FILE_MODE) + + def _open_directly_for_write(self) -> TextIO: + if not self._supports_posix_permissions(): + return self._path.open("w", encoding="utf-8") + + descriptor = os.open( + self._path, + os.O_WRONLY | os.O_CREAT | os.O_TRUNC, + _CONFIG_FILE_MODE, + ) + try: + os.fchmod(descriptor, _CONFIG_FILE_MODE) + return os.fdopen(descriptor, "w", encoding="utf-8") + except BaseException: + os.close(descriptor) + raise + + @staticmethod + def _supports_posix_permissions() -> bool: + return os.name == "posix" diff --git a/cisco_sccfm_cli/services/tests/test_config_service.py b/cisco_sccfm_cli/services/tests/test_config_service.py index 7a13330e..43a21193 100644 --- a/cisco_sccfm_cli/services/tests/test_config_service.py +++ b/cisco_sccfm_cli/services/tests/test_config_service.py @@ -4,10 +4,49 @@ from __future__ import annotations +import json +import os +import stat from pathlib import Path +from typing import Any, TextIO + +import pytest from cisco_sccfm_cli.models import Config from cisco_sccfm_cli.services import ConfigService +from cisco_sccfm_cli.services import config_service as config_service_module + +POSIX_ONLY = pytest.mark.skipif( + os.name != "posix", + reason="POSIX permission bits are not portable to this platform", +) + + +def _mode(path: Path) -> int: + return stat.S_IMODE(path.stat().st_mode) + + +def _write_config(path: Path, profile: str = "default") -> Config: + expected = Config(profile=profile, region="us", api_token="example-token") + path.write_text( + json.dumps( + { + "profiles": { + profile: { + "region": expected.region, + "api_token": expected.api_token, + } + } + } + ), + encoding="utf-8", + ) + return expected + + +def _use_default_path(monkeypatch: pytest.MonkeyPatch, config_path: Path) -> None: + monkeypatch.setattr(config_service_module, "_CONFIG_DIR", config_path.parent) + monkeypatch.setattr(config_service_module, "_CONFIG_FILE", config_path) def test_should_save_and_load_config(tmp_path: Path) -> None: @@ -32,3 +71,140 @@ def test_should_list_all_profiles(tmp_path: Path) -> None: profiles = service.list_profiles() assert profiles == [expected] + + +@POSIX_ONLY +def test_new_custom_storage_is_private_before_payload_is_written( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """New custom storage should be private as soon as its payload is written.""" + config_path = tmp_path / "custom" / "config.json" + original_dump = json.dump + modes_during_write: list[int] = [] + + def observe_mode(payload: Any, handle: TextIO, *, indent: int) -> None: + modes_during_write.append(_mode(config_path)) + original_dump(payload, handle, indent=indent) + + monkeypatch.setattr(config_service_module.json, "dump", observe_mode) + + ConfigService(path=config_path).save( + Config(profile="default", region="us", api_token="example-token") + ) + + assert modes_during_write == [0o600] + assert _mode(config_path.parent) == 0o700 + assert _mode(config_path) == 0o600 + + +@POSIX_ONLY +def test_new_default_storage_is_private( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The default config directory and file should be private when created.""" + config_path = tmp_path / ".sccfm-cli" / "config.json" + _use_default_path(monkeypatch, config_path) + + ConfigService().save(Config(profile="default", region="us", api_token="example-token")) + + assert _mode(config_path.parent) == 0o700 + assert _mode(config_path) == 0o600 + + +@POSIX_ONLY +def test_load_hardens_existing_custom_file_without_changing_parent(tmp_path: Path) -> None: + """Loading should harden the file but respect an existing custom directory.""" + custom_parent = tmp_path / "shared-config" + custom_parent.mkdir() + custom_parent.chmod(0o750) + config_path = custom_parent / "config.json" + expected = _write_config(config_path) + config_path.chmod(0o644) + + loaded = ConfigService(path=config_path).load(expected.profile) + + assert loaded == expected + assert _mode(config_path) == 0o600 + assert _mode(custom_parent) == 0o750 + + +@POSIX_ONLY +def test_save_hardens_existing_file_without_replacing_it(tmp_path: Path) -> None: + """Saving should remain a direct write while hardening existing storage.""" + custom_parent = tmp_path / "shared-config" + custom_parent.mkdir() + custom_parent.chmod(0o750) + config_path = custom_parent / "config.json" + existing = _write_config(config_path, profile="existing") + config_path.chmod(0o644) + original_inode = config_path.stat().st_ino + added = Config(profile="added", region="eu", api_token="another-example-token") + + service = ConfigService(path=config_path) + service.save(added) + + assert config_path.stat().st_ino == original_inode + assert _mode(config_path) == 0o600 + assert _mode(custom_parent) == 0o750 + assert service.load(existing.profile) == existing + assert service.load(added.profile) == added + + +@POSIX_ONLY +def test_load_hardens_existing_default_storage( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Loading from the default location should harden its directory and file.""" + config_path = tmp_path / ".sccfm-cli" / "config.json" + config_path.parent.mkdir() + config_path.parent.chmod(0o755) + expected = _write_config(config_path) + config_path.chmod(0o644) + _use_default_path(monkeypatch, config_path) + + loaded = ConfigService().load(expected.profile) + + assert loaded == expected + assert _mode(config_path.parent) == 0o700 + assert _mode(config_path) == 0o600 + + +@POSIX_ONLY +def test_save_hardens_existing_default_storage( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Saving to the default location should harden its directory and file.""" + config_path = tmp_path / ".sccfm-cli" / "config.json" + config_path.parent.mkdir() + config_path.parent.chmod(0o755) + _write_config(config_path) + config_path.chmod(0o644) + _use_default_path(monkeypatch, config_path) + + ConfigService().save(Config(profile="added", region="eu", api_token="example-token-2")) + + assert _mode(config_path.parent) == 0o700 + assert _mode(config_path) == 0o600 + + +def test_non_posix_fallback_preserves_save_and_load_behavior( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Platforms without POSIX permissions should still persist configuration.""" + config_path = tmp_path / "config.json" + expected = Config(profile="default", region="us", api_token="example-token") + monkeypatch.setattr( + ConfigService, + "_supports_posix_permissions", + staticmethod(lambda: False), + ) + + service = ConfigService(path=config_path) + service.save(expected) + + assert service.load(expected.profile) == expected diff --git a/cisco_sccfm_scripts/devkit_cli.py b/cisco_sccfm_scripts/devkit_cli.py index 45bbf128..bea17d71 100644 --- a/cisco_sccfm_scripts/devkit_cli.py +++ b/cisco_sccfm_scripts/devkit_cli.py @@ -311,7 +311,7 @@ def _update_token() -> None: token_choices: list[questionary.Choice | str] = [ questionary.Choice( - title=f"{t.name} ({t.region}) …{t.token[-6:]}", + title=f"{t.name} ({t.region})", value=t.name, ) for t in tokens @@ -327,7 +327,7 @@ def _update_token() -> None: console.print("[red]Token not found.[/red]") return - new_token_value = questionary.text( + new_token_value = questionary.password( f"Paste new API token for '{token_to_update.name}':", ).unsafe_ask() new_token_value = new_token_value.strip() @@ -368,10 +368,10 @@ def _remove_token() -> None: console.print("[yellow]Only one token saved — cannot remove the last token.[/yellow]") return - # Use Choice so the display shows region/token context but the value is just the name. + # Use Choice so the display shows region context but the value is just the name. token_choices: list[questionary.Choice | str] = [ questionary.Choice( - title=f"{t.name} ({t.region}) …{t.token[-6:]}", + title=f"{t.name} ({t.region})", value=t.name, ) for t in tokens diff --git a/cisco_sccfm_scripts/setup_tokens.py b/cisco_sccfm_scripts/setup_tokens.py index 49c55828..96985e4b 100644 --- a/cisco_sccfm_scripts/setup_tokens.py +++ b/cisco_sccfm_scripts/setup_tokens.py @@ -7,8 +7,9 @@ """Setup for SCCFM API tokens, .env, and Ansible Vault. Runs **interactively** by default (prompts for region, token, etc.). -Supply ``--region`` and ``--api-token`` to run **headless** — suitable -for CI pipelines and scripted workflows. +Supply ``--region`` with ``SCCFM_API_TOKEN`` to run **headless** — suitable +for CI pipelines and scripted workflows. The legacy ``--api-token`` option +remains available but can expose the token in shell history and process listings. Manages a local token store so tokens can be reused across setups. Creates / updates: @@ -23,14 +24,12 @@ # Interactive (default) python cisco_sccfm_scripts/setup_tokens.py - # Headless — minimal - python cisco_sccfm_scripts/setup_tokens.py --region us --api-token eyJ… + # Headless — SCCFM_API_TOKEN is injected by the CI secret environment + python cisco_sccfm_scripts/setup_tokens.py --region us - # Headless — all options + # Headless — optional non-secret settings python cisco_sccfm_scripts/setup_tokens.py \\ - --region int --api-token eyJ… \\ - --name staging --profile staging \\ - --vault-password s3cret + --region int --name staging --profile staging """ from __future__ import annotations @@ -44,6 +43,7 @@ import click import questionary +from click.core import ParameterSource from rich.console import Console from rich.panel import Panel from rich.table import Table @@ -167,7 +167,7 @@ def _choose_from_saved_or_new( """Present saved tokens and an 'Add new' option.""" choices: list[questionary.Choice] = [ questionary.Choice( - title=f"{t.name:<20} region={t.region} token=…{t.token[-6:]}", + title=f"{t.name:<20} region={t.region}", value=t.name, ) for t in saved @@ -238,7 +238,7 @@ def _prompt_region() -> str: def _prompt_token() -> str: """Ask the user to paste their API token (hidden input).""" - token: str = click.prompt("\nPaste your SCCFM API token") + token: str = click.prompt("\nPaste your SCCFM API token", hide_input=True) token = token.strip() if not token: raise click.ClickException("API token cannot be empty.") @@ -467,7 +467,7 @@ def _run_headless( @click.command( help="Setup SCCFM API tokens, .env, and Ansible Vault.\n\n" - "Runs interactively by default. Supply --region and --api-token " + "Runs interactively by default. Supply --region with SCCFM_API_TOKEN " "to run in headless mode (no prompts).", ) @click.option( @@ -475,13 +475,19 @@ def _run_headless( "-r", default=None, type=click.Choice(_VALID_REGIONS, case_sensitive=False), - help="SCCFM region. Enables headless mode when combined with --api-token.", + help="SCCFM region. Enables headless mode when combined with SCCFM_API_TOKEN.", ) @click.option( "--api-token", "-t", default=None, - help="SCCFM API token. Enables headless mode when combined with --region.", + envvar="SCCFM_API_TOKEN", + show_envvar=True, + hide_input=True, + help=( + "SCCFM API token. Passing it directly is supported for compatibility but may expose it " + "in process listings and shell history; prefer SCCFM_API_TOKEN." + ), ) @click.option( "--name", @@ -524,11 +530,25 @@ def main( path: Path | None, ) -> None: """Setup tokens — auto-detects interactive vs headless mode.""" + ctx = click.get_current_context() + if ( + api_token is not None + and ctx.get_parameter_source("api_token") is ParameterSource.COMMANDLINE + ): + click.echo( + "Warning: passing --api-token directly may expose it in process listings and " + "shell history; prefer SCCFM_API_TOKEN.", + err=True, + ) + headless = region is not None or api_token is not None if headless: if not region or not api_token: - raise click.UsageError("Headless mode requires both --region and --api-token.") + raise click.UsageError( + "Headless mode requires --region and an API token from SCCFM_API_TOKEN " + "or the legacy --api-token option." + ) _run_headless( region=region, api_token=api_token, diff --git a/cisco_sccfm_scripts/token_store.py b/cisco_sccfm_scripts/token_store.py index afc2663e..d1544638 100644 --- a/cisco_sccfm_scripts/token_store.py +++ b/cisco_sccfm_scripts/token_store.py @@ -28,7 +28,7 @@ import stat import subprocess import tempfile -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path from typing import cast @@ -41,7 +41,7 @@ class SavedToken: name: str region: str - token: str + token: str = field(repr=False) class VaultTokenStore: diff --git a/docs/cli/sccfm-cli-configure.md b/docs/cli/sccfm-cli-configure.md index 7da96a83..7672c3d3 100644 --- a/docs/cli/sccfm-cli-configure.md +++ b/docs/cli/sccfm-cli-configure.md @@ -20,6 +20,10 @@ Options: --region [int|us|eu|apj|au|uae|in|ci|aus] SCCFM region (int, us, eu, apj, au, uae, in, ci) [required] - --api-token TEXT API token for the chosen region [required] + --api-token TEXT API token for the chosen region. Passing it + directly is supported for compatibility but + may expose it in process listings and shell + history; prefer SCCFM_API_TOKEN or the hidden + prompt. [env var: SCCFM_API_TOKEN] --help Show this message and exit. ``` diff --git a/docs/man/man1/sccfm-cli-configure.1 b/docs/man/man1/sccfm-cli-configure.1 index 3a48790f..b8e3e92a 100644 --- a/docs/man/man1/sccfm-cli-configure.1 +++ b/docs/man/man1/sccfm-cli-configure.1 @@ -15,4 +15,4 @@ Path to the configuration file (defaults to ~/.sccfm-cli/config.json). SCCFM region (int, us, eu, apj, au, uae, in, ci) [required] .TP \fB\-\-api\-token\fP TEXT -API token for the chosen region [required] +API token for the chosen region. Passing it directly is supported for compatibility but may expose it in process listings and shell history; prefer SCCFM_API_TOKEN or the hidden prompt. [env var: SCCFM_API_TOKEN] diff --git a/skills/sccfm-cli/SKILL.md b/skills/sccfm-cli/SKILL.md index de6be111..77dba87b 100644 --- a/skills/sccfm-cli/SKILL.md +++ b/skills/sccfm-cli/SKILL.md @@ -155,8 +155,8 @@ Use the selected command's `auth` object: 3. Never log tokens or include them in final answers. 4. Never use internal SystemDB credentials. 5. If a profile is missing, guide the user to run the documented configuration - flow locally, or generate a validated configuration command with a placeholder - token. + flow locally. The token must come from its hidden prompt or schema-declared + environment source, never from a generated argv option. 6. Only configure a profile yourself when the user explicitly provides a secure, local mechanism for the token. diff --git a/tests/test_token_workspace.py b/tests/test_token_workspace.py index a45996d1..bf993493 100644 --- a/tests/test_token_workspace.py +++ b/tests/test_token_workspace.py @@ -103,6 +103,65 @@ def fake_run_headless(**kwargs: object) -> None: assert result.exit_code == 0, result.output assert captured["path"] is None + assert "process listings and shell history" in result.stderr + if "synthetic-token" in result.output: + pytest.fail("Sensitive value was exposed by change-tokens.", pytrace=False) + + +def test_headless_cli_reads_api_token_from_environment( + monkeypatch: pytest.MonkeyPatch, +) -> None: + token = "sec005-environment-sentinel-9f31" + captured: dict[str, object] = {} + + def fake_run_headless(**kwargs: object) -> None: + captured.update(kwargs) + + monkeypatch.setattr(setup_tokens, "_run_headless", fake_run_headless) + result = CliRunner().invoke( + main, + ["--region", "us"], + env={"SCCFM_API_TOKEN": token}, + ) + + assert result.exit_code == 0, result.output + assert captured["api_token"] == token + assert "process listings and shell history" not in result.output + observed = f"{result.stdout}\n{result.stderr}\n{result.exception!r}" + if token in observed: + pytest.fail("Sensitive value was exposed by change-tokens.", pytrace=False) + + +def test_interactive_api_token_prompt_hides_input(monkeypatch: pytest.MonkeyPatch) -> None: + captured: dict[str, object] = {} + + def fake_prompt(prompt: str, **kwargs: object) -> str: + captured.update(prompt=prompt, **kwargs) + return "synthetic-token" + + monkeypatch.setattr(setup_tokens.click, "prompt", fake_prompt) + + assert setup_tokens._prompt_token() == "synthetic-token" + assert captured["hide_input"] is True + + +def test_change_tokens_help_recommends_environment_input() -> None: + result = CliRunner().invoke(main, ["--help"]) + + assert result.exit_code == 0, result.output + assert "SCCFM_API_TOKEN" in result.output + assert "process listings and shell history" in result.output + + +def test_saved_token_representation_omits_token_value() -> None: + token = "sec005-repr-sentinel-284c" + + rendered = repr(SavedToken(name="default", region="us", token=token)) + + if token in rendered: + pytest.fail("Sensitive value was exposed by SavedToken repr.", pytrace=False) + assert "name='default'" in rendered + assert "region='us'" in rendered def test_headless_cli_forwards_typed_workspace( From d7921f6ef5d0e2dbbb48b4424442aaed182251b1 Mon Sep 17 00:00:00 2001 From: Andrei Huides Date: Tue, 11 Aug 2026 16:00:48 +0300 Subject: [PATCH 06/19] fix(lh-102436): unresolved runtime dependency --- .github/workflows/ci.yml | 57 ++++++++++++ poetry.lock | 157 ++++++++++++++++----------------- pyproject.toml | 6 +- sccfm-ansible/requirements.txt | 3 +- 4 files changed, 138 insertions(+), 85 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 52215050..ecf4d8ac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,17 @@ on: permissions: contents: read +env: + PIP_AUDIT_VERSION: "2.10.1" + DEP002_EXCEPTION_EXPIRES: "2026-09-10" + DEP002_PIP_AUDIT_EXCEPTIONS: >- + --ignore-vuln PYSEC-2026-141 + --ignore-vuln PYSEC-2026-1994 + --ignore-vuln PYSEC-2026-1995 + --ignore-vuln PYSEC-2026-1996 + --ignore-vuln PYSEC-2026-1998 + --ignore-vuln PYSEC-2026-1999 + jobs: lint-and-test: runs-on: ubuntu-latest @@ -33,6 +44,31 @@ jobs: - name: Install dependencies run: poetry install --no-interaction --with dev + - name: Audit locked runtime dependencies + run: | + set -euo pipefail + TODAY_UTC="$(date -u +%F)" + if [[ "${TODAY_UTC}" > "${DEP002_EXCEPTION_EXPIRES}" ]]; then + echo "::error::DEP-002 exceptions expired on ${DEP002_EXCEPTION_EXPIRES}" + exit 1 + fi + RUNTIME_REQUIREMENTS="${RUNNER_TEMP}/sccfm-runtime-requirements.txt" + poetry show --only main --no-ansi \ + | awk 'NF >= 2 {print $1 "==" $2}' \ + > "${RUNTIME_REQUIREMENTS}" + test -s "${RUNTIME_REQUIREMENTS}" + read -r -a AUDIT_EXCEPTION_ARGS <<< "${DEP002_PIP_AUDIT_EXCEPTIONS}" + pipx run --spec "pip-audit==${PIP_AUDIT_VERSION}" pip-audit \ + --strict \ + --no-deps \ + --disable-pip \ + --vulnerability-service osv \ + --progress-spinner off \ + --aliases on \ + --desc off \ + "${AUDIT_EXCEPTION_ARGS[@]}" \ + --requirement "${RUNTIME_REQUIREMENTS}" + - name: License headers run: git ls-files '*.py' | xargs poetry run reuse lint-file @@ -140,6 +176,27 @@ jobs: cd "${SMOKE_ROOT}" "${SMOKE_PYTHON}" -I -m pip install --no-cache-dir "${WHEEL_PATH}" "${SMOKE_PYTHON}" -I -m pip check + TODAY_UTC="$(date -u +%F)" + if [[ "${TODAY_UTC}" > "${DEP002_EXCEPTION_EXPIRES}" ]]; then + echo "::error::DEP-002 exceptions expired on ${DEP002_EXCEPTION_EXPIRES}" + exit 1 + fi + RUNTIME_REQUIREMENTS="${SMOKE_ROOT}/runtime-requirements.txt" + "${SMOKE_PYTHON}" -I -m pip freeze \ + --exclude cisco-sccfm-devkit \ + > "${RUNTIME_REQUIREMENTS}" + test -s "${RUNTIME_REQUIREMENTS}" + read -r -a AUDIT_EXCEPTION_ARGS <<< "${DEP002_PIP_AUDIT_EXCEPTIONS}" + pipx run --spec "pip-audit==${PIP_AUDIT_VERSION}" pip-audit \ + --strict \ + --no-deps \ + --disable-pip \ + --vulnerability-service osv \ + --progress-spinner off \ + --aliases on \ + --desc off \ + "${AUDIT_EXCEPTION_ARGS[@]}" \ + --requirement "${RUNTIME_REQUIREMENTS}" "${SMOKE_PYTHON}" -I - <<'PY' import importlib from importlib.metadata import version diff --git a/poetry.lock b/poetry.lock index d63bcf5b..1c93ef1c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.3.1 and should not be changed by hand. [[package]] name = "annotated-types" @@ -439,14 +439,14 @@ files = [ [[package]] name = "click" -version = "8.3.1" +version = "8.4.2" description = "Composable command line interface toolkit" optional = false python-versions = ">=3.10" groups = ["main", "dev"] files = [ - {file = "click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6"}, - {file = "click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a"}, + {file = "click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76"}, + {file = "click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6"}, ] [package.dependencies] @@ -632,80 +632,65 @@ toml = ["tomli ; python_full_version <= \"3.11.0a6\""] [[package]] name = "cryptography" -version = "46.0.3" +version = "50.0.0" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." optional = false -python-versions = "!=3.9.0,!=3.9.1,>=3.8" +python-versions = "!=3.9.0,!=3.9.1,>=3.9" groups = ["main", "dev"] files = [ - {file = "cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a"}, - {file = "cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc"}, - {file = "cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d"}, - {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb"}, - {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849"}, - {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8"}, - {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec"}, - {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91"}, - {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e"}, - {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926"}, - {file = "cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71"}, - {file = "cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac"}, - {file = "cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018"}, - {file = "cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb"}, - {file = "cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c"}, - {file = "cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217"}, - {file = "cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5"}, - {file = "cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715"}, - {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54"}, - {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459"}, - {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422"}, - {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7"}, - {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044"}, - {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665"}, - {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3"}, - {file = "cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20"}, - {file = "cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de"}, - {file = "cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914"}, - {file = "cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db"}, - {file = "cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21"}, - {file = "cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936"}, - {file = "cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683"}, - {file = "cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d"}, - {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0"}, - {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc"}, - {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3"}, - {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971"}, - {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac"}, - {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04"}, - {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506"}, - {file = "cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963"}, - {file = "cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4"}, - {file = "cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df"}, - {file = "cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f"}, - {file = "cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372"}, - {file = "cryptography-46.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a23582810fedb8c0bc47524558fb6c56aac3fc252cb306072fd2815da2a47c32"}, - {file = "cryptography-46.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:e7aec276d68421f9574040c26e2a7c3771060bc0cff408bae1dcb19d3ab1e63c"}, - {file = "cryptography-46.0.3-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7ce938a99998ed3c8aa7e7272dca1a610401ede816d36d0693907d863b10d9ea"}, - {file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:191bb60a7be5e6f54e30ba16fdfae78ad3a342a0599eb4193ba88e3f3d6e185b"}, - {file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c70cc23f12726be8f8bc72e41d5065d77e4515efae3690326764ea1b07845cfb"}, - {file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:9394673a9f4de09e28b5356e7fff97d778f8abad85c9d5ac4a4b7e25a0de7717"}, - {file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:94cd0549accc38d1494e1f8de71eca837d0509d0d44bf11d158524b0e12cebf9"}, - {file = "cryptography-46.0.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6b5063083824e5509fdba180721d55909ffacccc8adbec85268b48439423d78c"}, - {file = "cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1"}, + {file = "cryptography-50.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03"}, + {file = "cryptography-50.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645"}, + {file = "cryptography-50.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7"}, + {file = "cryptography-50.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3"}, + {file = "cryptography-50.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f"}, + {file = "cryptography-50.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae"}, + {file = "cryptography-50.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a"}, + {file = "cryptography-50.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987"}, + {file = "cryptography-50.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169"}, + {file = "cryptography-50.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f"}, + {file = "cryptography-50.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105"}, + {file = "cryptography-50.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef"}, + {file = "cryptography-50.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30"}, + {file = "cryptography-50.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41"}, + {file = "cryptography-50.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc"}, + {file = "cryptography-50.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f"}, + {file = "cryptography-50.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3"}, + {file = "cryptography-50.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533"}, + {file = "cryptography-50.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037"}, + {file = "cryptography-50.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f"}, + {file = "cryptography-50.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11"}, + {file = "cryptography-50.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d"}, + {file = "cryptography-50.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c"}, + {file = "cryptography-50.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c"}, + {file = "cryptography-50.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95"}, + {file = "cryptography-50.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269"}, + {file = "cryptography-50.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07"}, + {file = "cryptography-50.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3"}, + {file = "cryptography-50.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f"}, + {file = "cryptography-50.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5"}, + {file = "cryptography-50.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f"}, + {file = "cryptography-50.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025"}, + {file = "cryptography-50.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a"}, + {file = "cryptography-50.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b"}, + {file = "cryptography-50.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708"}, + {file = "cryptography-50.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47"}, + {file = "cryptography-50.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9"}, + {file = "cryptography-50.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7"}, + {file = "cryptography-50.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba"}, + {file = "cryptography-50.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:5e34edd123674534acd70147f0ca331eaa2c74e6325fb2028c886aa26ba0b68c"}, + {file = "cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:8eb5e1172eb569ea8a872796576e6a67c276351728b6455d5beb01242b027c6a"}, + {file = "cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:910d11e1a385c654bf738bf3e6b8e6ed5de0f5610fcae2be9e5b398d8081d20e"}, + {file = "cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:62598a8a57f815db4c6259a4e97d857dab56697e7de8e8ab02352ab74da1995d"}, + {file = "cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:07479a1cb08219ab719147e742e76090c9c773321959bb94946fffdd397a6437"}, + {file = "cryptography-50.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c99c003e088647b8a5b7c145d6f78c335f6348332b62e142d411c4b63d1460b9"}, + {file = "cryptography-50.0.0.tar.gz", hash = "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9"}, ] [package.dependencies] -cffi = {version = ">=2.0.0", markers = "python_full_version >= \"3.9.0\" and platform_python_implementation != \"PyPy\""} +cffi = {version = ">=2.0.0", markers = "platform_python_implementation != \"PyPy\""} [package.extras] -docs = ["sphinx (>=5.3.0)", "sphinx-inline-tabs", "sphinx-rtd-theme (>=3.0.0)"] -docstest = ["pyenchant (>=3)", "readme-renderer (>=30.0)", "sphinxcontrib-spelling (>=7.3.1)"] -nox = ["nox[uv] (>=2024.4.15)"] -pep8test = ["check-sdist", "click (>=8.0.1)", "mypy (>=1.14)", "ruff (>=0.11.11)"] -sdist = ["build (>=1.0.0)"] ssh = ["bcrypt (>=3.1.5)"] -test = ["certifi (>=2024)", "cryptography-vectors (==46.0.3)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"] -test-randomorder = ["pytest-randomly"] [[package]] name = "decli" @@ -787,6 +772,18 @@ files = [ {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, ] +[[package]] +name = "invoke" +version = "3.0.3" +description = "Pythonic task execution" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "invoke-3.0.3-py3-none-any.whl", hash = "sha256:f11327165e5cbb89b2ad1d88d3292b5113332c43b8553b494da435d6ec6f5053"}, + {file = "invoke-3.0.3.tar.gz", hash = "sha256:437b6a622223824380bfb4e64f612711a6b648c795f565efc8625af66fb57f0c"}, +] + [[package]] name = "isort" version = "7.0.0" @@ -1171,26 +1168,22 @@ files = [ [[package]] name = "paramiko" -version = "3.5.1" +version = "5.0.0" description = "SSH2 protocol library" optional = false -python-versions = ">=3.6" +python-versions = ">=3.9" groups = ["main"] files = [ - {file = "paramiko-3.5.1-py3-none-any.whl", hash = "sha256:43b9a0501fc2b5e70680388d9346cf252cfb7d00b0667c39e80eb43a408b8f61"}, - {file = "paramiko-3.5.1.tar.gz", hash = "sha256:b2c665bc45b2b215bd7d7f039901b14b067da00f3a11e6640995fd58f2664822"}, + {file = "paramiko-5.0.0-py3-none-any.whl", hash = "sha256:b7044611c30140d9a75261653210e2002977b71a0497ff3ba0d98d7edbf62f7c"}, + {file = "paramiko-5.0.0.tar.gz", hash = "sha256:36763b5b95c2a0dcfdf1abc48e48156ee425b21efe2f0e787c2dd5a95c0e5e79"}, ] [package.dependencies] bcrypt = ">=3.2" cryptography = ">=3.3" +invoke = ">=2.0" pynacl = ">=1.5" -[package.extras] -all = ["gssapi (>=1.4.1) ; platform_system != \"Windows\"", "invoke (>=2.0)", "pyasn1 (>=0.1.7)", "pywin32 (>=2.1.8) ; platform_system == \"Windows\""] -gssapi = ["gssapi (>=1.4.1) ; platform_system != \"Windows\"", "pyasn1 (>=0.1.7)", "pywin32 (>=2.1.8) ; platform_system == \"Windows\""] -invoke = ["invoke (>=2.0)"] - [[package]] name = "pathspec" version = "0.12.1" @@ -1465,14 +1458,14 @@ files = [ [[package]] name = "pygments" -version = "2.19.2" +version = "2.20.0" description = "Pygments is a syntax highlighting package written in Python." optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["main", "dev"] files = [ - {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, - {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, + {file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"}, + {file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"}, ] [package.extras] @@ -1899,4 +1892,4 @@ files = [ [metadata] lock-version = "2.1" python-versions = "^3.12" -content-hash = "415f0c5c91746ee48f47f0f4a5e2f4c16c2dec9ed9c2b28ccf4b4f8521376e22" +content-hash = "96418f3823680f1341fed942705a4d46b24da878c738e35c0863de9605addfbb" diff --git a/pyproject.toml b/pyproject.toml index 2554f552..9df65122 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,12 +35,14 @@ packages = [ [tool.poetry.dependencies] python = "^3.12" -click = ">=8.0.0,<9" +click = ">=8.3.3,<9" rich = "^14.2.0" click-option-group = "^0.5.9" scc-firewall-manager-sdk = "1.17.27" questionary = "^2.1.1" -paramiko = "^3.5.0" +paramiko = ">=5.0.0,<6" +cryptography = ">=50.0.0,<51" +pygments = ">=2.20.0,<3" [tool.poetry.scripts] sccfm-cli = "cisco_sccfm_cli.cli:cli" diff --git a/sccfm-ansible/requirements.txt b/sccfm-ansible/requirements.txt index f648e10a..babeb980 100644 --- a/sccfm-ansible/requirements.txt +++ b/sccfm-ansible/requirements.txt @@ -3,6 +3,7 @@ # Core dependencies scc-firewall-manager-sdk==1.17.27 -paramiko>=3.5.0 +paramiko>=5.0.0,<6 +cryptography>=50.0.0,<51 # Note: cisco-sccfm-devkit includes both cisco_sccfm_cli and cisco_sccfm_core # For local development, use: poetry install from parent directory From 1d6d3039077c7137103f19fd2ec0a14836e713b8 Mon Sep 17 00:00:00 2001 From: Andrei Huides Date: Tue, 11 Aug 2026 16:14:25 +0300 Subject: [PATCH 07/19] fix(lh-102436): query correct distribution name --- .github/workflows/ci.yml | 2 +- cisco_sccfm_cli/commands/tests/test_schema.py | 51 +++++++++++++++++++ cisco_sccfm_cli/schema.py | 11 ++-- 3 files changed, 56 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ecf4d8ac..b5c4f1a6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -215,7 +215,7 @@ jobs: PY "${SMOKE_CLI}" --help >/dev/null "${SMOKE_CLI}" schema export --format json | "${SMOKE_PYTHON}" -I -c \ - 'import json, sys; commands = json.load(sys.stdin).get("commands"); assert isinstance(commands, list) and len(commands) == 57, f"expected 57 commands, got {len(commands) if isinstance(commands, list) else 0}"' + 'from importlib.metadata import version; import json, sys; payload = json.load(sys.stdin); commands = payload.get("commands"); schema_version = payload.get("version"); installed_version = version("cisco-sccfm-devkit"); assert schema_version == installed_version, f"expected schema version {installed_version}, got {schema_version}"; assert isinstance(commands, list) and len(commands) == 57, f"expected 57 commands, got {len(commands) if isinstance(commands, list) else 0}"' echo "path=${WHEEL_RELATIVE_PATH}" >> "$GITHUB_OUTPUT" - name: Commit and tag verified release diff --git a/cisco_sccfm_cli/commands/tests/test_schema.py b/cisco_sccfm_cli/commands/tests/test_schema.py index 5e1e209c..92f91912 100644 --- a/cisco_sccfm_cli/commands/tests/test_schema.py +++ b/cisco_sccfm_cli/commands/tests/test_schema.py @@ -7,15 +7,66 @@ import json import shlex import tomllib +from importlib.metadata import PackageNotFoundError from pathlib import Path from typing import Any import click +import pytest from click.testing import CliRunner +from cisco_sccfm_cli import schema as schema_module from cisco_sccfm_cli.cli import cli +def test_package_version_should_prefer_installed_distribution_metadata( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def installed_version(distribution_name: str) -> str: + assert distribution_name == "cisco-sccfm-devkit" + return "9.8.7" + + def source_version() -> str | None: + return "1.2.3" + + monkeypatch.setattr(schema_module, "version", installed_version) + monkeypatch.setattr(schema_module, "_pyproject_version", source_version) + + assert schema_module._package_version() == "9.8.7" + + +def test_package_version_should_fall_back_to_source_metadata( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def missing_version(distribution_name: str) -> str: + assert distribution_name == "cisco-sccfm-devkit" + raise PackageNotFoundError(distribution_name) + + def source_version() -> str | None: + return "1.2.3" + + monkeypatch.setattr(schema_module, "version", missing_version) + monkeypatch.setattr(schema_module, "_pyproject_version", source_version) + + assert schema_module._package_version() == "1.2.3" + + +def test_package_version_should_report_unknown_without_metadata( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def missing_version(distribution_name: str) -> str: + assert distribution_name == "cisco-sccfm-devkit" + raise PackageNotFoundError(distribution_name) + + def source_version() -> str | None: + return None + + monkeypatch.setattr(schema_module, "version", missing_version) + monkeypatch.setattr(schema_module, "_pyproject_version", source_version) + + assert schema_module._package_version() == "unknown" + + def test_schema_export_should_emit_machine_readable_command_tree( cli_runner: CliRunner, ) -> None: diff --git a/cisco_sccfm_cli/schema.py b/cisco_sccfm_cli/schema.py index f2cddcbf..1d233887 100644 --- a/cisco_sccfm_cli/schema.py +++ b/cisco_sccfm_cli/schema.py @@ -17,6 +17,7 @@ from scc_firewall_manager_sdk import ConfigState, ConnectivityState, EntityType SCHEMA_VERSION = "1.0" +_DISTRIBUTION_NAME = "cisco-sccfm-devkit" _SCCFM_FREE_COMMANDS = { ("configure",), @@ -244,14 +245,10 @@ def _is_object_query_path(path: tuple[str, ...]) -> bool: def _package_version() -> str: - project_version = _pyproject_version() - if project_version is not None: - return project_version - try: - return version("sccfm") + return version(_DISTRIBUTION_NAME) except PackageNotFoundError: - return "unknown" + return _pyproject_version() or "unknown" def _pyproject_version() -> str | None: @@ -387,7 +384,7 @@ def _option_type(option: click.Option) -> str: def _type_metadata( - parameter_type: click.ParamType, + parameter_type: click.ParamType[Any], ) -> tuple[list[str] | None, dict[str, Any] | None]: if isinstance(parameter_type, click.Choice): return list(parameter_type.choices), {"case_sensitive": parameter_type.case_sensitive} From 684b70ea3f7fd844f4107dd475e2bb9c342928be Mon Sep 17 00:00:00 2001 From: Andrei Huides Date: Tue, 11 Aug 2026 18:39:24 +0300 Subject: [PATCH 08/19] fix(lh-102436): remove tests and e2e files from pypi wheel --- .github/workflows/ci.yml | 42 +- .github/workflows/publish-to-pypi.yml | 29 +- AGENTS.md | 5 +- INSTALL.md | 23 +- README.md | 2 +- .../tests/test_packaging_metadata.py | 19 +- .../verify_python_artifacts.py | 383 ++++++++++++++++++ .../claude-consistency.md | 8 +- devtools/cisco_sccfm_devtools/__init__.py | 5 + devtools/pyproject.toml | 25 ++ poetry.lock | 22 +- pyproject.toml | 25 +- sccfm-ansible/README.md | 31 +- tests/test_development_commands.py | 92 +++++ tests/test_verify_python_artifacts.py | 153 +++++++ 15 files changed, 801 insertions(+), 63 deletions(-) create mode 100644 cisco_sccfm_scripts/verify_python_artifacts.py create mode 100644 devtools/cisco_sccfm_devtools/__init__.py create mode 100644 devtools/pyproject.toml create mode 100644 tests/test_development_commands.py create mode 100644 tests/test_verify_python_artifacts.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b5c4f1a6..caaec79c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,11 +76,26 @@ jobs: run: | poetry run black --check . poetry run isort --check-only . - poetry run mypy cisco_sccfm_cli cisco_sccfm_core + poetry run mypy \ + cisco_sccfm_cli \ + cisco_sccfm_core \ + cisco_sccfm_scripts/verify_python_artifacts.py - name: Test run: poetry run pytest --color=yes + - name: Build and verify Python artifacts + run: | + set -euo pipefail + poetry build + PACKAGE_VERSION="$(poetry version -s)" + WHEEL_PATH="dist/cisco_sccfm_devkit-${PACKAGE_VERSION}-py3-none-any.whl" + SDIST_PATH="dist/cisco_sccfm_devkit-${PACKAGE_VERSION}.tar.gz" + test -f "${WHEEL_PATH}" + test -f "${SDIST_PATH}" + poetry run python -m cisco_sccfm_scripts.verify_python_artifacts \ + "${WHEEL_PATH}" "${SDIST_PATH}" + release: needs: lint-and-test if: github.ref == 'refs/heads/main' @@ -158,16 +173,21 @@ jobs: --max-archive-depth=1 \ "${{ steps.bump.outputs.artifact_path }}" - - name: Build and verify wheel + - name: Build and verify Python artifacts if: steps.bump.outputs.bumped == 'true' id: wheel run: | set -euo pipefail - poetry build -f wheel + poetry build PACKAGE_VERSION="$(poetry version -s)" WHEEL_RELATIVE_PATH="dist/cisco_sccfm_devkit-${PACKAGE_VERSION}-py3-none-any.whl" + SDIST_RELATIVE_PATH="dist/cisco_sccfm_devkit-${PACKAGE_VERSION}.tar.gz" WHEEL_PATH="${GITHUB_WORKSPACE}/${WHEEL_RELATIVE_PATH}" + SDIST_PATH="${GITHUB_WORKSPACE}/${SDIST_RELATIVE_PATH}" test -f "${WHEEL_PATH}" + test -f "${SDIST_PATH}" + poetry run python -m cisco_sccfm_scripts.verify_python_artifacts \ + "${WHEEL_PATH}" "${SDIST_PATH}" SMOKE_ROOT="$(mktemp -d "${RUNNER_TEMP}/sccfm-wheel-smoke.XXXXXX")" python -m venv "${SMOKE_ROOT}/venv" SMOKE_PYTHON="${SMOKE_ROOT}/venv/bin/python" @@ -199,7 +219,8 @@ jobs: --requirement "${RUNTIME_REQUIREMENTS}" "${SMOKE_PYTHON}" -I - <<'PY' import importlib - from importlib.metadata import version + from importlib.metadata import distribution, version + from importlib.util import find_spec expected_sdk = "1.17.27" installed_sdk = version("scc-firewall-manager-sdk") @@ -209,9 +230,20 @@ jobs: "scc_firewall_manager_sdk", "cisco_sccfm_cli", "cisco_sccfm_core", - "cisco_sccfm_scripts", ): importlib.import_module(package) + if find_spec("cisco_sccfm_scripts") is not None: + raise SystemExit("cisco_sccfm_scripts must not be installed from the public wheel") + console_scripts = { + entry_point.name: entry_point.value + for entry_point in distribution("cisco-sccfm-devkit").entry_points + if entry_point.group == "console_scripts" + } + expected_console_scripts = {"sccfm-cli": "cisco_sccfm_cli.cli:cli"} + if console_scripts != expected_console_scripts: + raise SystemExit( + f"expected console scripts {expected_console_scripts}, got {console_scripts}" + ) PY "${SMOKE_CLI}" --help >/dev/null "${SMOKE_CLI}" schema export --format json | "${SMOKE_PYTHON}" -I -c \ diff --git a/.github/workflows/publish-to-pypi.yml b/.github/workflows/publish-to-pypi.yml index 268d8116..596e6bc8 100644 --- a/.github/workflows/publish-to-pypi.yml +++ b/.github/workflows/publish-to-pypi.yml @@ -24,7 +24,9 @@ jobs: python-version: "3.12" - name: Verify version matches release tag + id: version run: | + set -euo pipefail TAG="${{ github.event.release.tag_name }}" TAG_VERSION="${TAG#v}" PKG_VERSION="$(python - <<'PY' @@ -43,16 +45,39 @@ jobs: fi echo "Version OK: $PKG_VERSION" + echo "package_version=${PKG_VERSION}" >> "$GITHUB_OUTPUT" - name: Install build dependencies run: | python -m pip install --upgrade pip python -m pip install build - - name: Build package - run: python -m build + - name: Build and verify package artifacts + env: + PACKAGE_VERSION: ${{ steps.version.outputs.package_version }} + run: | + set -euo pipefail + ARTIFACT_DIR="dist" + WHEEL_PATH="${ARTIFACT_DIR}/cisco_sccfm_devkit-${PACKAGE_VERSION}-py3-none-any.whl" + SDIST_PATH="${ARTIFACT_DIR}/cisco_sccfm_devkit-${PACKAGE_VERSION}.tar.gz" + + test ! -e "${ARTIFACT_DIR}" + python -m build --outdir "${ARTIFACT_DIR}" + test -f "${WHEEL_PATH}" + test -f "${SDIST_PATH}" + + ARTIFACT_COUNT="$(find "${ARTIFACT_DIR}" -mindepth 1 -maxdepth 1 | wc -l | tr -d ' ')" + if [[ "${ARTIFACT_COUNT}" != "2" ]]; then + echo "Expected exactly one wheel and one sdist; found ${ARTIFACT_COUNT} files" + exit 1 + fi + + python -m cisco_sccfm_scripts.verify_python_artifacts \ + "${WHEEL_PATH}" \ + "${SDIST_PATH}" - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@release/v1 with: password: ${{ secrets.PYPI_API_TOKEN }} + packages-dir: dist/ diff --git a/AGENTS.md b/AGENTS.md index 7002b7e7..6f5ecf7b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -101,9 +101,12 @@ No MCP servers are currently configured for this project. Skill files under `ski ### Ansible collection ```bash -# Build and install locally +# Build and verify the collection artifact build-ansible-collection +# Install the built artifact locally +ansible-galaxy collection install dist/cisco-sccfm-*.tar.gz --force + # Set up tokens and vault (generated credential files are ignored and excluded from builds) devkit # select "change-tokens" diff --git a/INSTALL.md b/INSTALL.md index a99db732..421b2945 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -174,22 +174,17 @@ ansible-galaxy collection list | grep cisco.sccfm ### Try out examples -The fastest way to get going is to use the interactive devkit menu: +The PyPI package exposes only the `sccfm-cli` console command; it does not install the repository's +developer, collection-build, token-bootstrap, or documentation helpers. Configure the supported +CLI with its hidden token prompt: ```bash -devkit -# select "change-tokens" from the menu +sccfm-cli configure --region us ``` -Or run the token setup directly: +For Ansible, provide `SCCFM_REGION` and `SCCFM_API_TOKEN` through your controller's environment or +secret manager. If you prefer Ansible Vault, follow the manual setup in +[Trying out examples](sccfm-ansible/README.md#trying-out-examples) to create `.vault_pass`, +`vars.yml`, and an encrypted `vault.yml`; do not place plaintext credentials in tracked files. -```bash -change-tokens -``` - -This prompts for your region, API token, and vault password, then creates `.env`, `.vault_pass`, -`vars.yml`, and encrypted `vault.yml`. Local credential files are Git-ignored and explicitly -excluded from collection release artifacts. Pass `--path /path/to/examples` to override the -default `sccfm-ansible/examples` directory. - -See the [Trying out examples](sccfm-ansible/README.md#trying-out-examples) section in the Ansible collection README for the full walkthrough including how to run playbooks. +See that walkthrough for the inventory and playbook commands. diff --git a/README.md b/README.md index facac0c2..964d54cb 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ 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`). -- Build and install the collection locally: `build-ansible-collection`. +- Build the collection locally: `build-ansible-collection`. - Set up tokens interactively with `devkit` and select **change-tokens**. By default this writes `.vault_pass` and encrypted `group_vars/all/vault.yml` under `sccfm-ansible/examples`; both are Git-ignored and explicitly excluded from collection artifacts. Use `--path` to override the diff --git a/cisco_sccfm_core/tests/test_packaging_metadata.py b/cisco_sccfm_core/tests/test_packaging_metadata.py index 0010eda8..0c85571f 100644 --- a/cisco_sccfm_core/tests/test_packaging_metadata.py +++ b/cisco_sccfm_core/tests/test_packaging_metadata.py @@ -21,18 +21,29 @@ def test_distribution_uses_cisco_devkit_name() -> None: assert _poetry_config()["name"] == "cisco-sccfm-devkit" -def test_published_packages_use_cisco_prefix() -> None: +def test_published_package_contract_is_cli_and_core_only() -> None: poetry = _poetry_config() included_packages = {package["include"] for package in poetry["packages"]} - script_targets = set(poetry["scripts"].values()) assert included_packages == { "cisco_sccfm_cli", "cisco_sccfm_core", + } + assert poetry["scripts"] == {"sccfm-cli": "cisco_sccfm_cli.cli:cli"} + + +def test_published_packages_exclude_repository_only_code() -> None: + assert set(_poetry_config()["exclude"]) == { "cisco_sccfm_scripts", + "**/tests", + "**/e2e", + "**/__pycache__", + "**/.pytest_cache", + "**/.mypy_cache", + "**/*.pyc", + "**/*.pyo", + "**/.DS_Store", } - assert script_targets - assert all(target.startswith("cisco_sccfm_") for target in script_targets) def test_generated_sdk_is_pinned_to_the_verified_compatible_version() -> None: diff --git a/cisco_sccfm_scripts/verify_python_artifacts.py b/cisco_sccfm_scripts/verify_python_artifacts.py new file mode 100644 index 00000000..3931cb76 --- /dev/null +++ b/cisco_sccfm_scripts/verify_python_artifacts.py @@ -0,0 +1,383 @@ +# Copyright 2026 Cisco Systems, Inc. and its affiliates +# +# SPDX-License-Identifier: Apache-2.0 + +"""Verify the public member and entry-point policy for Python artifacts.""" + +from __future__ import annotations + +import argparse +import configparser +import io +import stat +import tarfile +import tomllib +import zipfile +from collections.abc import Sequence +from dataclasses import dataclass +from pathlib import Path, PurePosixPath +from typing import Any + +_DISTRIBUTION_STEM = "cisco_sccfm_devkit" +_PACKAGE_ROOTS = frozenset({"cisco_sccfm_cli", "cisco_sccfm_core"}) +_SDIST_METADATA_ROOTS = frozenset( + { + "LICENSE", + "LICENSES", + "PKG-INFO", + "README.md", + "pyproject.toml", + } +) +_EXPECTED_SCRIPTS = {"sccfm-cli": "cisco_sccfm_cli.cli:cli"} +_FORBIDDEN_DIRECTORY_NAMES = frozenset( + { + ".cache", + ".eggs", + ".git", + ".mypy_cache", + ".poetry_cache", + ".pytest_cache", + ".ruff_cache", + ".tox", + ".venv", + "__pycache__", + "cisco_sccfm_scripts", + "devtools", + "e2e", + "test", + "tests", + } +) +_FORBIDDEN_EXACT_NAMES = frozenset( + { + ".coverage", + ".ds_store", + ".env", + ".netrc", + ".vault_pass", + "cachedir.tag", + "coverage.xml", + "credentials", + "credentials.json", + "credentials.yaml", + "credentials.yml", + "secrets.json", + "secrets.yaml", + "secrets.yml", + "vault.json", + "vault.yaml", + "vault.yml", + } +) +_FORBIDDEN_KEY_PREFIXES = ("id_dsa", "id_ecdsa", "id_ed25519", "id_rsa") +_FORBIDDEN_SUFFIXES = ( + ".bak", + ".db", + ".jks", + ".kdbx", + ".key", + ".keystore", + ".log", + ".orig", + ".p12", + ".pem", + ".pfx", + ".pyc", + ".pyo", + ".retry", + ".sqlite", + ".sqlite3", + ".swo", + ".swp", +) + + +class PythonArtifactVerificationError(RuntimeError): + """Raised when a wheel or sdist violates the public artifact policy.""" + + +class _EntryPointParser(configparser.ConfigParser): + """Config parser that preserves case-sensitive entry-point names.""" + + def optionxform(self, optionstr: str) -> str: + """Return an entry-point name unchanged.""" + return optionstr + + +@dataclass(frozen=True) +class PythonArtifactVerification: + """Counts from a successfully verified wheel and sdist.""" + + wheel_files: int + sdist_files: int + + +def _wheel_version(path: Path) -> str: + """Return the version encoded in the expected pure-Python wheel filename.""" + parts = path.name.removesuffix(".whl").split("-") + if path.suffix != ".whl" or len(parts) != 5: + raise PythonArtifactVerificationError(f"unexpected wheel filename: {path.name}") + distribution, version, python_tag, abi_tag, platform_tag = parts + if ( + distribution != _DISTRIBUTION_STEM + or not version + or python_tag != "py3" + or abi_tag != "none" + or platform_tag != "any" + ): + raise PythonArtifactVerificationError(f"unexpected wheel filename: {path.name}") + return version + + +def _sdist_version(path: Path) -> str: + """Return the version encoded in the expected sdist filename.""" + prefix = f"{_DISTRIBUTION_STEM}-" + suffix = ".tar.gz" + if not path.name.startswith(prefix) or not path.name.endswith(suffix): + raise PythonArtifactVerificationError(f"unexpected sdist filename: {path.name}") + version = path.name[len(prefix) : -len(suffix)] + if not version or "/" in version or "\\" in version: + raise PythonArtifactVerificationError(f"unexpected sdist filename: {path.name}") + return version + + +def _member_parts(raw_name: str) -> tuple[str, ...]: + """Return canonical POSIX member parts, rejecting traversal and aliases.""" + if not raw_name or "\x00" in raw_name or "\\" in raw_name or raw_name.startswith("/"): + raise PythonArtifactVerificationError("artifact contains an invalid member path") + name = raw_name[:-1] if raw_name.endswith("/") else raw_name + parts = tuple(name.split("/")) + if not name or any(part in {"", ".", ".."} for part in parts): + raise PythonArtifactVerificationError("artifact contains a non-canonical member path") + if PurePosixPath(name).as_posix() != name: + raise PythonArtifactVerificationError("artifact contains a non-canonical member path") + return parts + + +def _check_forbidden_path(parts: tuple[str, ...], display_name: str) -> None: + """Reject test, cache, credential, and local-data member paths.""" + lowered = tuple(part.lower() for part in parts) + if any(part in _FORBIDDEN_DIRECTORY_NAMES for part in lowered): + raise PythonArtifactVerificationError(f"forbidden directory in artifact: {display_name}") + + basename = lowered[-1] + if basename in {"conftest.py", "test.py", "tests.py"} or ( + basename.endswith(".py") and (basename.startswith("test_") or basename.endswith("_test.py")) + ): + raise PythonArtifactVerificationError(f"test implementation in artifact: {display_name}") + if basename in _FORBIDDEN_EXACT_NAMES: + raise PythonArtifactVerificationError( + f"sensitive or local file in artifact: {display_name}" + ) + if basename.startswith((".env", ".vault_pass")): + raise PythonArtifactVerificationError(f"credential-like file in artifact: {display_name}") + if basename.startswith(("vault.yml.", "vault.yaml.")): + raise PythonArtifactVerificationError(f"vault backup in artifact: {display_name}") + if basename.startswith(_FORBIDDEN_KEY_PREFIXES): + raise PythonArtifactVerificationError(f"private-key-like file in artifact: {display_name}") + if basename.endswith(_FORBIDDEN_SUFFIXES) or basename.endswith("~"): + raise PythonArtifactVerificationError(f"local-data file in artifact: {display_name}") + + +def _entry_points(raw: bytes) -> dict[tuple[str, str], str]: + """Parse a wheel entry-points file into an exact, comparable map.""" + parser = _EntryPointParser(interpolation=None, delimiters=("=",), strict=True) + try: + parser.read_file(io.StringIO(raw.decode("utf-8"))) + except (UnicodeDecodeError, configparser.Error) as exc: + raise PythonArtifactVerificationError("wheel has invalid entry-point metadata") from exc + if parser.defaults(): + raise PythonArtifactVerificationError("wheel has unexpected default entry points") + return { + (section, name): target.strip() + for section in parser.sections() + for name, target in parser.items(section, raw=True) + } + + +def _verify_entry_points(raw: bytes) -> None: + """Require the sole supported public console entry point.""" + expected = {("console_scripts", name): target for name, target in _EXPECTED_SCRIPTS.items()} + if _entry_points(raw) != expected: + raise PythonArtifactVerificationError("wheel does not expose exactly the sccfm-cli command") + + +def _verify_sdist_pyproject(raw: bytes) -> None: + """Ensure a wheel rebuilt from the sdist retains the public package policy.""" + try: + pyproject: dict[str, Any] = tomllib.loads(raw.decode("utf-8")) + poetry = pyproject["tool"]["poetry"] + except (KeyError, TypeError, UnicodeDecodeError, tomllib.TOMLDecodeError) as exc: + raise PythonArtifactVerificationError("sdist has invalid Poetry metadata") from exc + if not isinstance(poetry, dict): + raise PythonArtifactVerificationError("sdist has invalid Poetry metadata") + + packages = poetry.get("packages") + if not isinstance(packages, list) or len(packages) != len(_PACKAGE_ROOTS): + raise PythonArtifactVerificationError("sdist declares unexpected package roots") + package_roots: set[str] = set() + for package in packages: + if not isinstance(package, dict) or set(package) != {"include"}: + raise PythonArtifactVerificationError("sdist declares unexpected package roots") + included = package.get("include") + if not isinstance(included, str): + raise PythonArtifactVerificationError("sdist declares unexpected package roots") + package_roots.add(included) + if package_roots != _PACKAGE_ROOTS: + raise PythonArtifactVerificationError("sdist declares unexpected package roots") + + scripts = poetry.get("scripts") + if scripts != _EXPECTED_SCRIPTS: + raise PythonArtifactVerificationError("sdist does not expose exactly the sccfm-cli command") + + +def _verify_wheel(path: Path, version: str) -> int: + """Verify wheel roots, paths, member types, and entry-point metadata.""" + expected_dist_info = f"{_DISTRIBUTION_STEM}-{version}.dist-info" + allowed_roots = _PACKAGE_ROOTS | {expected_dist_info} + try: + with zipfile.ZipFile(path) as archive: + members: dict[str, zipfile.ZipInfo] = {} + for member in archive.infolist(): + parts = _member_parts(member.filename) + name = "/".join(parts) + if name in members: + raise PythonArtifactVerificationError( + f"wheel contains a duplicate member: {name}" + ) + mode = member.external_attr >> 16 + if member.is_dir() or stat.S_IFMT(mode) not in {0, stat.S_IFREG}: + raise PythonArtifactVerificationError( + f"wheel contains a non-regular member: {name}" + ) + if parts[0] not in allowed_roots: + raise PythonArtifactVerificationError( + f"unexpected wheel top-level path: {parts[0]}" + ) + _check_forbidden_path(parts, name) + members[name] = member + + actual_roots = {name.split("/", maxsplit=1)[0] for name in members} + if actual_roots != allowed_roots: + raise PythonArtifactVerificationError("wheel does not contain the expected roots") + entry_points_name = f"{expected_dist_info}/entry_points.txt" + if entry_points_name not in members: + raise PythonArtifactVerificationError("wheel has no entry-point metadata") + _verify_entry_points(archive.read(members[entry_points_name])) + except (OSError, zipfile.BadZipFile) as exc: + raise PythonArtifactVerificationError("wheel is not a readable ZIP archive") from exc + return len(members) + + +def _read_tar_member(archive: tarfile.TarFile, member: tarfile.TarInfo) -> bytes: + """Read a required regular sdist member.""" + extracted = archive.extractfile(member) + if extracted is None: + raise PythonArtifactVerificationError(f"could not read sdist member: {member.name}") + return extracted.read() + + +def _verify_sdist(path: Path, version: str) -> int: + """Verify sdist roots, paths, member types, and embedded build metadata.""" + expected_prefix = f"{_DISTRIBUTION_STEM}-{version}" + allowed_roots = _PACKAGE_ROOTS | _SDIST_METADATA_ROOTS + try: + with tarfile.open(path, mode="r:gz") as archive: + members: dict[str, tarfile.TarInfo] = {} + relative_members: dict[str, tarfile.TarInfo] = {} + for member in archive.getmembers(): + parts = _member_parts(member.name) + name = "/".join(parts) + if name in members: + raise PythonArtifactVerificationError( + f"sdist contains a duplicate member: {name}" + ) + if not (member.isfile() or member.isdir()): + raise PythonArtifactVerificationError( + f"sdist contains a non-regular member: {name}" + ) + if parts[0] != expected_prefix: + raise PythonArtifactVerificationError( + f"unexpected sdist archive prefix: {parts[0]}" + ) + members[name] = member + if len(parts) == 1: + if not member.isdir(): + raise PythonArtifactVerificationError( + "sdist root member is not a directory" + ) + continue + + relative_parts = parts[1:] + relative_name = "/".join(relative_parts) + if relative_name in relative_members: + raise PythonArtifactVerificationError( + f"sdist contains a duplicate member: {relative_name}" + ) + if relative_parts[0] not in allowed_roots: + raise PythonArtifactVerificationError( + f"unexpected sdist top-level path: {relative_parts[0]}" + ) + _check_forbidden_path(relative_parts, relative_name) + relative_members[relative_name] = member + + actual_package_roots = { + name.split("/", maxsplit=1)[0] + for name, member in relative_members.items() + if member.isfile() and name.split("/", maxsplit=1)[0] in _PACKAGE_ROOTS + } + if actual_package_roots != _PACKAGE_ROOTS: + raise PythonArtifactVerificationError( + "sdist does not contain the expected packages" + ) + pyproject_name = "pyproject.toml" + pyproject_member = relative_members.get(pyproject_name) + if pyproject_member is None or not pyproject_member.isfile(): + raise PythonArtifactVerificationError("sdist has no pyproject.toml") + _verify_sdist_pyproject(_read_tar_member(archive, pyproject_member)) + except (OSError, tarfile.TarError) as exc: + raise PythonArtifactVerificationError("sdist is not a readable tar.gz archive") from exc + return sum(member.isfile() for member in members.values()) + + +def verify_python_artifacts(wheel: Path, sdist: Path) -> PythonArtifactVerification: + """Verify one matching wheel and sdist against the public artifact policy.""" + if wheel.is_symlink() or not wheel.is_file(): + raise PythonArtifactVerificationError("wheel must be a regular file") + if sdist.is_symlink() or not sdist.is_file(): + raise PythonArtifactVerificationError("sdist must be a regular file") + + wheel_version = _wheel_version(wheel) + sdist_version = _sdist_version(sdist) + if wheel_version != sdist_version: + raise PythonArtifactVerificationError("wheel and sdist versions do not match") + + return PythonArtifactVerification( + wheel_files=_verify_wheel(wheel, wheel_version), + sdist_files=_verify_sdist(sdist, sdist_version), + ) + + +def main(argv: Sequence[str] | None = None) -> int: + """Command-line wrapper for CI and publication automation.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("wheel", type=Path) + parser.add_argument("sdist", type=Path) + args = parser.parse_args(argv) + + try: + result = verify_python_artifacts(args.wheel, args.sdist) + except PythonArtifactVerificationError as exc: + print(f"Python artifacts rejected: {exc}") + return 1 + + print( + "Python artifacts verified: " + f"wheel_files={result.wheel_files} sdist_files={result.sdist_files}" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/dev/consistency-checklists/claude-consistency.md b/dev/consistency-checklists/claude-consistency.md index c4508ede..4ae6d0cb 100644 --- a/dev/consistency-checklists/claude-consistency.md +++ b/dev/consistency-checklists/claude-consistency.md @@ -400,7 +400,8 @@ ### 16.1 Poetry / `pyproject.toml` - **Invariants:** - Dependencies added via `poetry add`; dev deps in `[tool.poetry.group.dev.dependencies]`. - - Entry points: `sccfm-cli`, `devkit`, `build-ansible-collection`, `change-tokens`. + - Public entry point: `sccfm-cli`; maintainer entry points come from the local `devtools/` + package in the development dependency group. - Tool configs (black, isort, mypy, pytest, coverage) all live in `pyproject.toml`. ### 16.2 Pre-commit @@ -416,8 +417,9 @@ - `cz` drives version bumps and `CHANGELOG.md` updates; do not edit version strings by hand. ### 16.5 Helper scripts -- **Canonical:** `cisco_sccfm_scripts/` (`devkit_cli.py`, `setup_environment.sh`, `setup_ci_environment.sh`, `setup_tokens.py`, `token_store.py`, `build_ansible_collection.py`, `cz.sh`). -- **Invariants:** any new repo-wide automation lives in `cisco_sccfm_scripts/` and is exposed via `pyproject.toml` entry points where it's user-facing. +- **Canonical:** `cisco_sccfm_scripts/` (`devkit_cli.py`, `setup_environment.sh`, `setup_ci_environment.sh`, `setup_tokens.py`, `token_store.py`, `build_ansible_collection.py`, `cz.sh`) plus the entry-point declarations in `devtools/pyproject.toml`. +- **Invariants:** new repo-wide automation remains source-only; public CLI features belong under + `sccfm-cli`. --- diff --git a/devtools/cisco_sccfm_devtools/__init__.py b/devtools/cisco_sccfm_devtools/__init__.py new file mode 100644 index 00000000..bfb460ce --- /dev/null +++ b/devtools/cisco_sccfm_devtools/__init__.py @@ -0,0 +1,5 @@ +# Copyright 2026 Cisco Systems, Inc. and its affiliates +# +# SPDX-License-Identifier: Apache-2.0 + +"""Local-only console entry points for SCCFM maintainers.""" diff --git a/devtools/pyproject.toml b/devtools/pyproject.toml new file mode 100644 index 00000000..e4a19774 --- /dev/null +++ b/devtools/pyproject.toml @@ -0,0 +1,25 @@ +# Copyright 2026 Cisco Systems, Inc. and its affiliates +# +# SPDX-License-Identifier: Apache-2.0 + +[project] +name = "cisco-sccfm-devtools" +version = "0.0.0" +description = "Local-only console entry points for SCCFM maintainers" +requires-python = ">=3.12,<4.0" + +[project.scripts] +devkit = "cisco_sccfm_scripts.devkit_cli:main" +build-ansible-collection = "cisco_sccfm_scripts.build_ansible_collection:main" +change-tokens = "cisco_sccfm_scripts.setup_tokens:main" +generate-ansible-docs = "cisco_sccfm_scripts.generate_ansible_docs:main" +generate-cli-docs = "cisco_sccfm_scripts.generate_cli_docs:main" +generate-cli-man-docs = "cisco_sccfm_scripts.generate_cli_man_docs:main" +install-cli-man-docs = "cisco_sccfm_scripts.install_cli_man_docs:main" +sync-docs-readme = "cisco_sccfm_scripts.sync_docs_readme:main" +check-doc-links = "cisco_sccfm_scripts.check_doc_links:main" +check-doc-artifacts = "cisco_sccfm_scripts.check_doc_artifacts:main" + +[build-system] +requires = ["poetry-core>=2.0.0,<3.0.0"] +build-backend = "poetry.core.masonry.api" diff --git a/poetry.lock b/poetry.lock index 1c93ef1c..ae727bee 100644 --- a/poetry.lock +++ b/poetry.lock @@ -437,6 +437,20 @@ files = [ {file = "charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a"}, ] +[[package]] +name = "cisco-sccfm-devtools" +version = "0.0.0" +description = "Local-only console entry points for SCCFM maintainers" +optional = false +python-versions = ">=3.12,<4.0" +groups = ["dev"] +files = [] +develop = true + +[package.source] +type = "directory" +url = "devtools" + [[package]] name = "click" version = "8.4.2" @@ -1254,7 +1268,7 @@ version = "3.0.52" description = "Library for building powerful interactive command lines in Python" optional = false python-versions = ">=3.8" -groups = ["main", "dev"] +groups = ["dev"] files = [ {file = "prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955"}, {file = "prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855"}, @@ -1684,7 +1698,7 @@ version = "2.1.1" description = "Python library to build pretty command line user prompts ⭐️" optional = false python-versions = ">=3.9" -groups = ["main", "dev"] +groups = ["dev"] files = [ {file = "questionary-2.1.1-py3-none-any.whl", hash = "sha256:a51af13f345f1cdea62347589fbb6df3b290306ab8930713bfae4d475a7d4a59"}, {file = "questionary-2.1.1.tar.gz", hash = "sha256:3d7e980292bb0107abaa79c68dd3eee3c561b83a0f89ae482860b181c8bd412d"}, @@ -1883,7 +1897,7 @@ version = "0.2.14" description = "Measures the displayed width of unicode strings in a terminal" optional = false python-versions = ">=3.6" -groups = ["main", "dev"] +groups = ["dev"] files = [ {file = "wcwidth-0.2.14-py2.py3-none-any.whl", hash = "sha256:a7bb560c8aee30f9957e5f9895805edd20602f2d7f720186dfd906e82b4982e1"}, {file = "wcwidth-0.2.14.tar.gz", hash = "sha256:4d478375d31bc5395a3c55c40ccdf3354688364cd61c4f6adacaa9215d0b3605"}, @@ -1892,4 +1906,4 @@ files = [ [metadata] lock-version = "2.1" python-versions = "^3.12" -content-hash = "96418f3823680f1341fed942705a4d46b24da878c738e35c0863de9605addfbb" +content-hash = "357676b89db4ee02067c9af7662de06c89561046a3a5773c7f77e64f1a4e4200" diff --git a/pyproject.toml b/pyproject.toml index 9df65122..ba410731 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,17 @@ readme = "README.md" packages = [ { include = "cisco_sccfm_cli" }, { include = "cisco_sccfm_core" }, - { include = "cisco_sccfm_scripts" }, +] +exclude = [ + "cisco_sccfm_scripts", + "**/tests", + "**/e2e", + "**/__pycache__", + "**/.pytest_cache", + "**/.mypy_cache", + "**/*.pyc", + "**/*.pyo", + "**/.DS_Store", ] [tool.poetry.urls] @@ -39,25 +49,15 @@ click = ">=8.3.3,<9" rich = "^14.2.0" click-option-group = "^0.5.9" scc-firewall-manager-sdk = "1.17.27" -questionary = "^2.1.1" paramiko = ">=5.0.0,<6" cryptography = ">=50.0.0,<51" pygments = ">=2.20.0,<3" [tool.poetry.scripts] sccfm-cli = "cisco_sccfm_cli.cli:cli" -devkit = "cisco_sccfm_scripts.devkit_cli:main" -build-ansible-collection = "cisco_sccfm_scripts.build_ansible_collection:main" -change-tokens = "cisco_sccfm_scripts.setup_tokens:main" -generate-ansible-docs = "cisco_sccfm_scripts.generate_ansible_docs:main" -generate-cli-docs = "cisco_sccfm_scripts.generate_cli_docs:main" -generate-cli-man-docs = "cisco_sccfm_scripts.generate_cli_man_docs:main" -install-cli-man-docs = "cisco_sccfm_scripts.install_cli_man_docs:main" -sync-docs-readme = "cisco_sccfm_scripts.sync_docs_readme:main" -check-doc-links = "cisco_sccfm_scripts.check_doc_links:main" -check-doc-artifacts = "cisco_sccfm_scripts.check_doc_artifacts:main" [tool.poetry.group.dev.dependencies] +cisco-sccfm-devtools = { path = "devtools", develop = true } ansible-core = "^2.17.0" black = "^25.11.0" click-man = "^0.5.1" @@ -69,6 +69,7 @@ pre-commit = "^4.5.0" flake8 = "^7.1.1" commitizen = "^3.27.0" reuse = "^6.2.0" +questionary = "^2.1.1" [tool.poetry.group.build.dependencies] pyyaml = "^6.0.0" diff --git a/sccfm-ansible/README.md b/sccfm-ansible/README.md index fe818eda..530b8dcb 100644 --- a/sccfm-ansible/README.md +++ b/sccfm-ansible/README.md @@ -57,34 +57,28 @@ See instructions in the [INSTALL.md](INSTALL.md) file. ### Local Development -**Build and install (recommended):** -```bash -devkit -# then select "build-collection" from the menu -``` +From the repository root, build the collection with the source-only helper: -Or directly: ```bash +source cisco_sccfm_scripts/activate.sh 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 +The helper reads the repository version, creates the tarball under `dist/`, and verifies the exact +artifact. Install the built collection explicitly: + +```bash +ansible-galaxy collection install dist/cisco-sccfm-*.tar.gz --force +``` ## Trying out examples ### 1. Set Up Tokens (Recommended — interactive) -The fastest way to configure your tokens, `.env`, vault, and region is with the devkit CLI: +The PyPI package exposes only the `sccfm-cli` console command; it does not install repository +maintenance helpers. In an activated source checkout, run the token helper from the repository +root: -```bash -devkit -# then select "change-tokens" from the menu -``` - -Or run the token setup directly: ```bash change-tokens ``` @@ -107,6 +101,9 @@ custom examples directory: change-tokens --path /path/to/examples ``` +Users of installed artifacts should configure `SCCFM_REGION` and `SCCFM_API_TOKEN` through their +controller environment or secret manager, or use the manual Ansible Vault setup below. +
Manual setup (alternative) diff --git a/tests/test_development_commands.py b/tests/test_development_commands.py new file mode 100644 index 00000000..4f50f1c7 --- /dev/null +++ b/tests/test_development_commands.py @@ -0,0 +1,92 @@ +# Copyright 2026 Cisco Systems, Inc. and its affiliates +# +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for the local-only maintainer command distribution.""" + +from __future__ import annotations + +import shutil +import subprocess +import tomllib +from importlib.metadata import distribution +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +DEVTOOLS_PYPROJECT = PROJECT_ROOT / "devtools" / "pyproject.toml" +COMMAND_MODULES = { + "build-ansible-collection": "cisco_sccfm_scripts.build_ansible_collection:main", + "change-tokens": "cisco_sccfm_scripts.setup_tokens:main", + "check-doc-artifacts": "cisco_sccfm_scripts.check_doc_artifacts:main", + "check-doc-links": "cisco_sccfm_scripts.check_doc_links:main", + "devkit": "cisco_sccfm_scripts.devkit_cli:main", + "generate-ansible-docs": "cisco_sccfm_scripts.generate_ansible_docs:main", + "generate-cli-docs": "cisco_sccfm_scripts.generate_cli_docs:main", + "generate-cli-man-docs": "cisco_sccfm_scripts.generate_cli_man_docs:main", + "install-cli-man-docs": "cisco_sccfm_scripts.install_cli_man_docs:main", + "sync-docs-readme": "cisco_sccfm_scripts.sync_docs_readme:main", +} +DOCUMENTATION_COMMANDS = ( + "sync-docs-readme", + "generate-cli-docs", + "generate-cli-man-docs", + "generate-ansible-docs", +) + + +def _load_pyproject(path: Path) -> dict[str, object]: + with path.open("rb") as file_handle: + return tomllib.load(file_handle) + + +def test_devtools_declares_exact_maintainer_commands() -> None: + pyproject = _load_pyproject(DEVTOOLS_PYPROJECT) + project = pyproject["project"] + + assert isinstance(project, dict) + assert project["name"] == "cisco-sccfm-devtools" + assert project["scripts"] == COMMAND_MODULES + + +def test_root_declares_devtools_only_as_a_development_dependency() -> None: + pyproject = _load_pyproject(PROJECT_ROOT / "pyproject.toml") + tool = pyproject["tool"] + + assert isinstance(tool, dict) + poetry = tool["poetry"] + assert isinstance(poetry, dict) + dependencies = poetry["group"]["dev"]["dependencies"] + assert dependencies["cisco-sccfm-devtools"] == { + "path": "devtools", + "develop": True, + } + assert "cisco-sccfm-devtools" not in poetry["dependencies"] + + +def test_installed_devtools_entry_points_match_and_load() -> None: + console_scripts = { + entry_point.name: entry_point + for entry_point in distribution("cisco-sccfm-devtools").entry_points + if entry_point.group == "console_scripts" + } + + assert {name: entry_point.value for name, entry_point in console_scripts.items()} == ( + COMMAND_MODULES + ) + assert all(callable(entry_point.load()) for entry_point in console_scripts.values()) + + +def test_requested_poetry_run_commands_work_without_activation() -> None: + poetry = shutil.which("poetry") + assert poetry is not None + + for command in DOCUMENTATION_COMMANDS: + result = subprocess.run( + [poetry, "run", command, "--help"], + cwd=PROJECT_ROOT, + check=False, + capture_output=True, + text=True, + ) + + assert result.returncode == 0, result.stderr diff --git a/tests/test_verify_python_artifacts.py b/tests/test_verify_python_artifacts.py new file mode 100644 index 00000000..e490d321 --- /dev/null +++ b/tests/test_verify_python_artifacts.py @@ -0,0 +1,153 @@ +# Copyright 2026 Cisco Systems, Inc. and its affiliates +# +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import io +import tarfile +import zipfile +from collections.abc import Mapping +from pathlib import Path + +import pytest + +from cisco_sccfm_scripts.verify_python_artifacts import ( + PythonArtifactVerificationError, + verify_python_artifacts, +) + +_VERSION = "1.2.3" +_DIST_INFO = f"cisco_sccfm_devkit-{_VERSION}.dist-info" +_ENTRY_POINTS = b"[console_scripts]\nsccfm-cli=cisco_sccfm_cli.cli:cli\n" +_PYPROJECT = b"""\ +[tool.poetry] +packages = [ + { include = "cisco_sccfm_cli" }, + { include = "cisco_sccfm_core" }, +] + +[tool.poetry.scripts] +sccfm-cli = "cisco_sccfm_cli.cli:cli" +""" + + +def _write_tar_file(archive: tarfile.TarFile, name: str, content: bytes) -> None: + member = tarfile.TarInfo(name) + member.mode = 0o644 + member.size = len(content) + archive.addfile(member, io.BytesIO(content)) + + +def _build_artifacts( + tmp_path: Path, + *, + wheel_extra: Mapping[str, bytes] | None = None, + sdist_extra: Mapping[str, bytes] | None = None, + entry_points: bytes = _ENTRY_POINTS, + pyproject: bytes = _PYPROJECT, +) -> tuple[Path, Path]: + wheel = tmp_path / f"cisco_sccfm_devkit-{_VERSION}-py3-none-any.whl" + wheel_files = { + "cisco_sccfm_cli/__init__.py": b"", + "cisco_sccfm_core/__init__.py": b"", + f"{_DIST_INFO}/METADATA": b"Name: cisco-sccfm-devkit\nVersion: 1.2.3\n", + f"{_DIST_INFO}/WHEEL": b"Wheel-Version: 1.0\n", + f"{_DIST_INFO}/entry_points.txt": entry_points, + f"{_DIST_INFO}/RECORD": b"", + **(wheel_extra or {}), + } + with zipfile.ZipFile(wheel, mode="w") as archive: + for name, content in wheel_files.items(): + archive.writestr(name, content) + + sdist = tmp_path / f"cisco_sccfm_devkit-{_VERSION}.tar.gz" + prefix = f"cisco_sccfm_devkit-{_VERSION}" + sdist_files = { + "LICENSE": b"Apache-2.0\n", + "LICENSES/Apache-2.0.txt": b"Apache-2.0\n", + "PKG-INFO": b"Name: cisco-sccfm-devkit\nVersion: 1.2.3\n", + "README.md": b"# Synthetic package\n", + "cisco_sccfm_cli/__init__.py": b"", + "cisco_sccfm_core/__init__.py": b"", + "pyproject.toml": pyproject, + **(sdist_extra or {}), + } + with tarfile.open(sdist, mode="w:gz") as archive: + for name, content in sdist_files.items(): + _write_tar_file(archive, f"{prefix}/{name}", content) + return wheel, sdist + + +def test_verifier_accepts_public_artifact_pair(tmp_path: Path) -> None: + wheel, sdist = _build_artifacts(tmp_path) + + result = verify_python_artifacts(wheel, sdist) + + assert result.wheel_files == 6 + assert result.sdist_files == 7 + + +@pytest.mark.parametrize( + ("artifact", "member"), + [ + ("wheel", "cisco_sccfm_scripts/devkit_cli.py"), + ("sdist", "cisco_sccfm_scripts/devkit_cli.py"), + ("wheel", "cisco_sccfm_scripts/bin/devkit"), + ("sdist", "cisco_sccfm_scripts/bin/devkit"), + ("wheel", "devtools/pyproject.toml"), + ("sdist", "devtools/pyproject.toml"), + ("wheel", "cisco_sccfm_cli/commands/tests/test_command.py"), + ("wheel", "cisco_sccfm_cli/e2e/live_tenant.py"), + ("wheel", "cisco_sccfm_core/__pycache__/service.pyc"), + ("sdist", "cisco_sccfm_core/.env.production"), + ("sdist", "cisco_sccfm_cli/private.pem"), + ], +) +def test_verifier_rejects_non_public_members(tmp_path: Path, artifact: str, member: str) -> None: + wheel_extra = {member: b"synthetic\n"} if artifact == "wheel" else None + sdist_extra = {member: b"synthetic\n"} if artifact == "sdist" else None + wheel, sdist = _build_artifacts( + tmp_path, + wheel_extra=wheel_extra, + sdist_extra=sdist_extra, + ) + + with pytest.raises(PythonArtifactVerificationError): + verify_python_artifacts(wheel, sdist) + + +def test_verifier_rejects_additional_wheel_entry_point(tmp_path: Path) -> None: + entry_points = _ENTRY_POINTS + b"devkit=cisco_sccfm_scripts.devkit_cli:main\n" + wheel, sdist = _build_artifacts(tmp_path, entry_points=entry_points) + + with pytest.raises(PythonArtifactVerificationError, match="exactly the sccfm-cli"): + verify_python_artifacts(wheel, sdist) + + +def test_verifier_rejects_additional_sdist_entry_point(tmp_path: Path) -> None: + pyproject = _PYPROJECT + b'devkit = "cisco_sccfm_scripts.devkit_cli:main"\n' + wheel, sdist = _build_artifacts(tmp_path, pyproject=pyproject) + + with pytest.raises(PythonArtifactVerificationError, match="exactly the sccfm-cli"): + verify_python_artifacts(wheel, sdist) + + +def test_verifier_rejects_additional_sdist_package_root(tmp_path: Path) -> None: + pyproject = _PYPROJECT.replace( + b' { include = "cisco_sccfm_core" },\n', + b' { include = "cisco_sccfm_core" },\n' b' { include = "cisco_sccfm_scripts" },\n', + ) + wheel, sdist = _build_artifacts(tmp_path, pyproject=pyproject) + + with pytest.raises(PythonArtifactVerificationError, match="unexpected package roots"): + verify_python_artifacts(wheel, sdist) + + +def test_verifier_rejects_mismatched_versions(tmp_path: Path) -> None: + wheel, sdist = _build_artifacts(tmp_path) + renamed_sdist = sdist.with_name("cisco_sccfm_devkit-1.2.4.tar.gz") + sdist.rename(renamed_sdist) + + with pytest.raises(PythonArtifactVerificationError, match="versions do not match"): + verify_python_artifacts(wheel, renamed_sdist) From d69bea68acf6b9ae4c2e9aef514720a2e40b7a07 Mon Sep 17 00:00:00 2001 From: Andrei Huides Date: Tue, 11 Aug 2026 19:26:56 +0300 Subject: [PATCH 09/19] fix(lh-102436): make the Galaxy artifact independently installable and usable. --- .github/workflows/ci.yml | 18 +- INSTALL.md | 64 +++-- .../tests/test_packaging_metadata.py | 4 - .../build_ansible_collection.py | 54 +++- .../verify_ansible_collection.py | 48 ++++ .../verify_clean_controller.py | 256 ++++++++++++++++++ .../verify_python_artifacts.py | 24 +- poetry.lock | 2 +- pyproject.toml | 2 +- sccfm-ansible/README.md | 207 ++++++++------ sccfm-ansible/examples/inventory.sccfm.yml | 6 +- sccfm-ansible/meta/execution-environment.yml | 7 + sccfm-ansible/meta/runtime.yml | 2 +- sccfm-ansible/requirements.txt | 14 +- tests/test_ansible_dependency_metadata.py | 54 ++++ tests/test_build_ansible_collection.py | 52 ++++ tests/test_verify_ansible_collection.py | 27 +- tests/test_verify_clean_controller.py | 52 ++++ tests/test_verify_python_artifacts.py | 10 + 19 files changed, 764 insertions(+), 139 deletions(-) create mode 100644 cisco_sccfm_scripts/verify_clean_controller.py create mode 100644 sccfm-ansible/meta/execution-environment.yml create mode 100644 tests/test_ansible_dependency_metadata.py create mode 100644 tests/test_build_ansible_collection.py create mode 100644 tests/test_verify_clean_controller.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index caaec79c..1ffdd180 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -79,22 +79,30 @@ jobs: poetry run mypy \ cisco_sccfm_cli \ cisco_sccfm_core \ + cisco_sccfm_scripts/build_ansible_collection.py \ + cisco_sccfm_scripts/verify_ansible_collection.py \ + cisco_sccfm_scripts/verify_clean_controller.py \ cisco_sccfm_scripts/verify_python_artifacts.py - name: Test run: poetry run pytest --color=yes - - name: Build and verify Python artifacts + - name: Build and verify release artifacts run: | set -euo pipefail poetry build PACKAGE_VERSION="$(poetry version -s)" WHEEL_PATH="dist/cisco_sccfm_devkit-${PACKAGE_VERSION}-py3-none-any.whl" SDIST_PATH="dist/cisco_sccfm_devkit-${PACKAGE_VERSION}.tar.gz" + COLLECTION_PATH="dist/cisco-sccfm-${PACKAGE_VERSION}.tar.gz" test -f "${WHEEL_PATH}" test -f "${SDIST_PATH}" poetry run python -m cisco_sccfm_scripts.verify_python_artifacts \ "${WHEEL_PATH}" "${SDIST_PATH}" + poetry run build-ansible-collection + test -f "${COLLECTION_PATH}" + poetry run python -m cisco_sccfm_scripts.verify_clean_controller \ + "${WHEEL_PATH}" "${COLLECTION_PATH}" --expected-version "${PACKAGE_VERSION}" release: needs: lint-and-test @@ -250,6 +258,14 @@ jobs: 'from importlib.metadata import version; import json, sys; payload = json.load(sys.stdin); commands = payload.get("commands"); schema_version = payload.get("version"); installed_version = version("cisco-sccfm-devkit"); assert schema_version == installed_version, f"expected schema version {installed_version}, got {schema_version}"; assert isinstance(commands, list) and len(commands) == 57, f"expected 57 commands, got {len(commands) if isinstance(commands, list) else 0}"' echo "path=${WHEEL_RELATIVE_PATH}" >> "$GITHUB_OUTPUT" + - name: Verify clean controller artifact pair + if: steps.bump.outputs.bumped == 'true' + run: | + poetry run python -m cisco_sccfm_scripts.verify_clean_controller \ + "${{ steps.wheel.outputs.path }}" \ + "${{ steps.bump.outputs.artifact_path }}" \ + --expected-version "$(poetry version -s)" + - name: Commit and tag verified release if: steps.bump.outputs.bumped == 'true' env: diff --git a/INSTALL.md b/INSTALL.md index 421b2945..37527608 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -1,8 +1,7 @@ # Installation -These are instructions to install the latest CLI and Python library from PyPI, plus the -Ansible collection from GitHub releases. Eventually, the `cisco.sccfm` collection will be -available on Ansible Galaxy. +These are instructions to install the CLI and Python library from PyPI, plus the matching +`cisco.sccfm` collection from Ansible Galaxy or a GitHub release. @@ -18,10 +17,10 @@ available on Ansible Galaxy. - [Enable shell completion](#enable-shell-completion) - [Using the Python library](#using-the-python-library) - [Installing the Ansible collection](#installing-the-ansible-collection) - - [Download the Ansible collection Bundle.](#download-the-ansible-collection-bundle) - - [Install Ansible Collection](#install-ansible-collection) + - [Install a matched release](#install-a-matched-release) + - [Install downloaded release artifacts](#install-downloaded-release-artifacts) - [Verify installation](#verify-installation) - - [Try out examples](#try-out-examples) + - [Authentication and examples](#authentication-and-examples) @@ -153,38 +152,51 @@ The generated `scc-firewall-manager-sdk` remains the low-level SDK dependency. ## Installing the Ansible collection -> ⚠️ Before you do this, make sure you've installed the sccfm-cli following the instructions in the section above. +Installing the CLI with `pipx` is not sufficient for Ansible because pipx keeps that package in an +isolated environment. The collection imports `cisco_sccfm_core` from `cisco-sccfm-devkit`, so the +Python package must be installed in the Python environment that executes the Ansible modules. -### Download the Ansible collection Bundle. +### Install a matched release -1. Navigate to the [GitHub Releases](https://github.com/CiscoDevNet/sccfm-devkit/releases) page for this project. -2. Download the latest tar.gz asset, named like `cisco-sccfm-.tar.gz`, to your local machine. +Use Python `>=3.12,<4.0` and `ansible-core>=2.20,<2.22`. Replace `X.Y.Z` with a version published +on both PyPI and Ansible Galaxy, and install both artifacts at that exact version: -### Install Ansible Collection ```bash -ansible-galaxy collection install /path/to/cisco-sccfm-{version}.tar.gz +python3.12 -m venv .venv +source .venv/bin/activate +python -m pip install --upgrade pip +python -m pip install "ansible-core>=2.20,<2.22" "cisco-sccfm-devkit==X.Y.Z" +ansible-galaxy collection install "cisco.sccfm:==X.Y.Z" ``` -### Verify installation +Upgrade or roll back the Python package and collection together. Mixing release versions is +unsupported. + +### Install downloaded release artifacts + +To install release artifacts directly, download the wheel and same-version collection tarball from +[GitHub Releases](https://github.com/CiscoDevNet/sccfm-devkit/releases), then install both into the +Ansible environment: ```bash -python -c "import cisco_sccfm_core; print('Python package installed')" -ansible-galaxy collection list | grep cisco.sccfm +python -m pip install /path/to/cisco_sccfm_devkit-X.Y.Z-py3-none-any.whl +ansible-galaxy collection install /path/to/cisco-sccfm-X.Y.Z.tar.gz --force ``` -### Try out examples - -The PyPI package exposes only the `sccfm-cli` console command; it does not install the repository's -developer, collection-build, token-bootstrap, or documentation helpers. Configure the supported -CLI with its hidden token prompt: +### Verify installation ```bash -sccfm-cli configure --region us +python -c 'from importlib.metadata import version; print(version("cisco-sccfm-devkit"))' +python -m pip check +ansible-galaxy collection list cisco.sccfm +ansible-doc -l -t module cisco.sccfm +ansible-doc -t inventory cisco.sccfm.sccfm ``` -For Ansible, provide `SCCFM_REGION` and `SCCFM_API_TOKEN` through your controller's environment or -secret manager. If you prefer Ansible Vault, follow the manual setup in -[Trying out examples](sccfm-ansible/README.md#trying-out-examples) to create `.vault_pass`, -`vars.yml`, and an encrypted `vault.yml`; do not place plaintext credentials in tracked files. +The Python and collection versions printed above must be identical. + +### Authentication and examples -See that walkthrough for the inventory and playbook commands. +Provide `SCCFM_REGION` and `SCCFM_API_TOKEN` through the controller or execution environment's +secret manager. See the collection's [packaged installation, authentication, execution environment, +and example instructions](sccfm-ansible/README.md#installation) for the complete consumer workflow. diff --git a/cisco_sccfm_core/tests/test_packaging_metadata.py b/cisco_sccfm_core/tests/test_packaging_metadata.py index 0c85571f..41119a97 100644 --- a/cisco_sccfm_core/tests/test_packaging_metadata.py +++ b/cisco_sccfm_core/tests/test_packaging_metadata.py @@ -48,12 +48,8 @@ def test_published_packages_exclude_repository_only_code() -> None: def test_generated_sdk_is_pinned_to_the_verified_compatible_version() -> None: poetry = _poetry_config() - collection_requirements = (PROJECT_ROOT / "sccfm-ansible" / "requirements.txt").read_text( - encoding="utf-8" - ) assert poetry["dependencies"]["scc-firewall-manager-sdk"] == "1.17.27" - assert "scc-firewall-manager-sdk==1.17.27" in collection_requirements.splitlines() def test_pyinstaller_spec_uses_repository_relative_entrypoint() -> None: diff --git a/cisco_sccfm_scripts/build_ansible_collection.py b/cisco_sccfm_scripts/build_ansible_collection.py index 49ad8b2c..54464e46 100644 --- a/cisco_sccfm_scripts/build_ansible_collection.py +++ b/cisco_sccfm_scripts/build_ansible_collection.py @@ -6,6 +6,7 @@ """Build script for Ansible collection.""" import os +import re import shutil import subprocess import sys @@ -19,6 +20,15 @@ verify_collection_artifact, ) +_PAIRED_REQUIREMENT_PIN = re.compile( + r"^[ \t]*cisco-sccfm-devkit[ \t]*==[ \t]*[^\s;#]+[ \t]*(?:#.*)?" r"(?P\r?\n)?$", + re.IGNORECASE, +) + + +class CollectionBuildError(RuntimeError): + """Raised when collection source cannot be prepared safely for a build.""" + def _find_collection_symlink(collection_dir: Path) -> Path | None: """Return the first symlink without following targets outside the collection.""" @@ -30,6 +40,33 @@ def _find_collection_symlink(collection_dir: Path) -> Path | None: return None +def _sync_paired_python_requirement(requirements_path: Path, version: str) -> None: + """Synchronize the sole active requirement, which must be an exact devkit pin.""" + content = requirements_path.read_text(encoding="utf-8") + lines = content.splitlines(keepends=True) + active_requirements = [ + (index, line) + for index, line in enumerate(lines) + if line.strip() and not line.lstrip().startswith("#") + ] + if len(active_requirements) != 1: + raise CollectionBuildError( + "requirements.txt must contain only one cisco-sccfm-devkit requirement" + ) + + index, requirement = active_requirements[0] + match = _PAIRED_REQUIREMENT_PIN.fullmatch(requirement) + if match is None: + raise CollectionBuildError( + "cisco-sccfm-devkit must use one exact == version pin in requirements.txt" + ) + + lines[index] = f"cisco-sccfm-devkit=={version}{match.group('newline') or ''}" + updated = "".join(lines) + if updated != content: + requirements_path.write_text(updated, encoding="utf-8") + + def main() -> int: """Build the Ansible collection tarball.""" project_root = Path(__file__).parent.parent @@ -37,6 +74,7 @@ def main() -> int: dist_dir = project_root / "dist" pyproject_path = project_root / "pyproject.toml" galaxy_path = collection_dir / "galaxy.yml" + requirements_path = collection_dir / "requirements.txt" license_src = project_root / "LICENSE" license_dst = collection_dir / "LICENSE" @@ -47,17 +85,23 @@ def main() -> int: print(f"❌ Collection source contains a symlink: {symlink}", file=sys.stderr) return 1 - # Copy the root LICENSE into the collection so galaxy.yml's `license_file` - # resolves and the license ships in the tarball (Galaxy import requires it). - shutil.copyfile(license_src, license_dst) - print(f"📄 Copied LICENSE into {collection_dir.name}/") - # Read version from pyproject.toml with open(pyproject_path, "rb") as f: pyproject = tomllib.load(f) version = pyproject["tool"]["poetry"]["version"] print(f"📦 Using version {version} from pyproject.toml") + try: + _sync_paired_python_requirement(requirements_path, version) + except (CollectionBuildError, OSError) as exc: + print(f"❌ Failed to synchronize Python requirements: {exc}", file=sys.stderr) + return 1 + print(f"✏️ Synchronized cisco-sccfm-devkit requirement to {version}") + + # Include the declared Apache license after all fail-closed source validation. + shutil.copyfile(license_src, license_dst) + print(f"📄 Copied LICENSE into {collection_dir.name}/") + # Update galaxy.yml with the version with open(galaxy_path, "r") as f: galaxy = yaml.safe_load(f) diff --git a/cisco_sccfm_scripts/verify_ansible_collection.py b/cisco_sccfm_scripts/verify_ansible_collection.py index ae2929a7..1dc43eb9 100644 --- a/cisco_sccfm_scripts/verify_ansible_collection.py +++ b/cisco_sccfm_scripts/verify_ansible_collection.py @@ -15,6 +15,8 @@ from pathlib import Path, PurePosixPath from typing import Any, Sequence, cast +import yaml + _MAX_MEMBERS = 2_000 _MAX_ARCHIVE_BYTES = 20 * 1024 * 1024 _MAX_MEMBER_BYTES = 10 * 1024 * 1024 @@ -41,6 +43,7 @@ "README.md", "examples/.vault_pass.example", "examples/group_vars/all/vault.yml.example", + "meta/execution-environment.yml", "meta/runtime.yml", "plugins/inventory", "plugins/module_utils", @@ -241,6 +244,20 @@ def _load_json_member( return cast(dict[str, Any], parsed), raw +def _load_yaml_member(archive: tarfile.TarFile, member: tarfile.TarInfo) -> dict[str, Any]: + """Load one required YAML mapping without exposing its contents in errors.""" + raw = _read_member(archive, member) + try: + parsed: object = yaml.safe_load(raw.decode("utf-8")) + except (UnicodeDecodeError, yaml.YAMLError) as exc: + raise ArtifactVerificationError(f"invalid YAML in artifact member: {member.name}") from exc + if not isinstance(parsed, dict): + raise ArtifactVerificationError( + f"expected a YAML mapping in artifact member: {member.name}" + ) + return cast(dict[str, Any], parsed) + + def _manifest_entries(files_manifest: dict[str, Any]) -> dict[str, dict[str, Any]]: """Return a validated, duplicate-free FILES.json entry map.""" raw_entries = files_manifest.get("files") @@ -328,6 +345,36 @@ def _verify_license_content(archive: tarfile.TarFile, member: tarfile.TarInfo) - raise ArtifactVerificationError("artifact LICENSE does not contain Apache-2.0 text") +def _verify_python_dependency_contract( + archive: tarfile.TarFile, + members: dict[str, tarfile.TarInfo], + expected_version: str, +) -> None: + """Require Ansible Builder metadata and the lockstep Python package pin.""" + try: + requirements = _read_member(archive, members["requirements.txt"]).decode("utf-8") + except UnicodeDecodeError as exc: + raise ArtifactVerificationError( + "invalid UTF-8 in artifact member: requirements.txt" + ) from exc + requirement_lines = [ + line.strip() + for line in requirements.splitlines() + if line.strip() and not line.lstrip().startswith("#") + ] + expected_requirement = f"cisco-sccfm-devkit=={expected_version}" + if requirement_lines != [expected_requirement]: + raise ArtifactVerificationError( + "requirements.txt does not contain only the version-matched Python package" + ) + + execution_environment = _load_yaml_member(archive, members["meta/execution-environment.yml"]) + if execution_environment != {"dependencies": {"python": "requirements.txt"}}: + raise ArtifactVerificationError( + "meta/execution-environment.yml does not reference requirements.txt" + ) + + def verify_collection_artifact(artifact: Path, expected_version: str) -> ArtifactVerification: """Verify structure, manifests, paths, content, and digest for one tarball.""" expected_name = f"cisco-sccfm-{expected_version}.tar.gz" @@ -370,6 +417,7 @@ def verify_collection_artifact(artifact: Path, expected_version: str) -> Artifac _verify_manifests(archive, members, expected_version) _verify_license_content(archive, members["LICENSE"]) + _verify_python_dependency_contract(archive, members, expected_version) for name, member in members.items(): if member.isfile(): _scan_member_content(name, _read_member(archive, member)) diff --git a/cisco_sccfm_scripts/verify_clean_controller.py b/cisco_sccfm_scripts/verify_clean_controller.py new file mode 100644 index 00000000..1fe4ad66 --- /dev/null +++ b/cisco_sccfm_scripts/verify_clean_controller.py @@ -0,0 +1,256 @@ +# Copyright 2026 Cisco Systems, Inc. and its affiliates +# +# SPDX-License-Identifier: Apache-2.0 + +"""Smoke-test the public wheel and collection on an isolated Ubuntu controller.""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +import tempfile +import venv +from dataclasses import dataclass +from pathlib import Path + +from cisco_sccfm_scripts.verify_ansible_collection import verify_collection_artifact +from cisco_sccfm_scripts.verify_python_artifacts import verify_python_wheel + +_ANSIBLE_CORE = "ansible-core>=2.20,<2.22" +_TOKEN_ERROR = "api_token is required." +_EXPECTED_MODULES = 49 +_EXPECTED_INVENTORY_PLUGINS = 1 + + +class CleanControllerVerificationError(RuntimeError): + """Raised when the clean-controller smoke test fails.""" + + +@dataclass(frozen=True) +class _Controller: + work: Path + collections: Path + binaries: Path + environment: dict[str, str] + + +def _run( + controller: _Controller, + command: list[str | Path], + *, + check: bool = True, +) -> subprocess.CompletedProcess[str]: + rendered = [str(part) for part in command] + result = subprocess.run( + rendered, + cwd=controller.work, + env=controller.environment, + capture_output=True, + text=True, + check=False, + ) + if check and result.returncode != 0: + raise CleanControllerVerificationError( + f"{Path(rendered[0]).name} failed ({result.returncode}): " + f"{result.stderr or result.stdout}" + ) + return result + + +def _create_controller(workspace: Path) -> _Controller: + work = workspace / "work" + collections = workspace / "collections" + venv_root = workspace / "venv" + work.mkdir() + venv.EnvBuilder(with_pip=True).create(venv_root) + binaries = venv_root / "bin" + + environment = dict(os.environ) + for name in tuple(environment): + if name.startswith(("ANSIBLE_", "SCCFM_")) or name in { + "POETRY_ACTIVE", + "PYTHONHOME", + "PYTHONPATH", + "PYTHONUSERBASE", + "VIRTUAL_ENV", + }: + environment.pop(name) + isolated_dirs = { + "HOME": "home", + "XDG_CACHE_HOME": "xdg-cache", + "XDG_CONFIG_HOME": "xdg-config", + "XDG_DATA_HOME": "xdg-data", + "XDG_STATE_HOME": "xdg-state", + "ANSIBLE_LOCAL_TEMP": "ansible-tmp", + } + for variable, name in isolated_dirs.items(): + directory = workspace / name + directory.mkdir() + environment[variable] = str(directory) + environment.update( + { + "ANSIBLE_COLLECTIONS_PATH": str(collections), + "PATH": f"{binaries}{os.pathsep}{environment.get('PATH', '')}", + "PYTHONNOUSERSITE": "1", + } + ) + return _Controller(work, collections, binaries, environment) + + +def _discovered_plugins(raw: str, plugin_type: str) -> dict[str, str]: + try: + payload: object = json.loads(raw) + except json.JSONDecodeError as exc: + raise CleanControllerVerificationError(f"invalid {plugin_type} discovery JSON") from exc + if not isinstance(payload, dict) or not payload: + raise CleanControllerVerificationError(f"no cisco.sccfm {plugin_type} plugins discovered") + if any( + not isinstance(name, str) + or not name.startswith("cisco.sccfm.") + or not isinstance(description, str) + for name, description in payload.items() + ): + raise CleanControllerVerificationError(f"unexpected {plugin_type} discovery result") + return {str(name): str(payload[name]) for name in sorted(payload)} + + +def _documented_probe(controller: _Controller, modules: dict[str, str]) -> str: + candidates = [ + name for name, description in modules.items() if description.casefold().startswith("list ") + ] + if not candidates: + raise CleanControllerVerificationError("no readonly list module discovered") + probe = candidates[0] + raw = _run(controller, [controller.binaries / "ansible-doc", "-j", probe]).stdout + payload: object = json.loads(raw) + if not isinstance(payload, dict) or set(payload) != {probe}: + raise CleanControllerVerificationError("selected module documentation is missing") + module = payload[probe] + doc = module.get("doc") if isinstance(module, dict) else None + options = doc.get("options") if isinstance(doc, dict) else None + if not isinstance(options, dict) or any( + isinstance(option, dict) and option.get("required") is True for option in options.values() + ): + raise CleanControllerVerificationError("offline probe has required business parameters") + return probe + + +def _install_artifacts( + controller: _Controller, + wheel: Path, + collection: Path, + expected_version: str, +) -> None: + python = controller.binaries / "python" + _run( + controller, + [python, "-I", "-m", "pip", "install", "--no-cache-dir", _ANSIBLE_CORE, wheel], + ) + _run(controller, [python, "-I", "-m", "pip", "check"]) + import_check = """\ +import importlib, importlib.metadata, importlib.util, sys +assert importlib.metadata.version("cisco-sccfm-devkit") == sys.argv[1] +for name in ("cisco_sccfm_cli", "cisco_sccfm_core", "scc_firewall_manager_sdk"): + importlib.import_module(name) +assert importlib.util.find_spec("cisco_sccfm_scripts") is None +""" + _run(controller, [python, "-I", "-c", import_check, expected_version]) + _run( + controller, + [ + controller.binaries / "ansible-galaxy", + "collection", + "install", + collection, + "-p", + controller.collections, + "-f", + ], + ) + + +def _discover(controller: _Controller) -> tuple[int, int, str]: + ansible_doc = controller.binaries / "ansible-doc" + modules = _discovered_plugins( + _run(controller, [ansible_doc, "-j", "-l", "-t", "module", "cisco.sccfm"]).stdout, + "module", + ) + inventory = _discovered_plugins( + _run(controller, [ansible_doc, "-j", "-l", "-t", "inventory", "cisco.sccfm"]).stdout, + "inventory", + ) + if len(modules) != _EXPECTED_MODULES or len(inventory) != _EXPECTED_INVENTORY_PLUGINS: + raise CleanControllerVerificationError("expected 49 modules and 1 inventory plugin") + probe = _documented_probe(controller, modules) + return len(modules), len(inventory), probe + + +def _offline_checks(controller: _Controller, probe: str) -> None: + result = _run( + controller, + [ + controller.binaries / "ansible", + "localhost", + "-i", + "localhost,", + "-c", + "local", + "-m", + probe, + "-e", + f"ansible_python_interpreter={controller.binaries / 'python'}", + ], + check=False, + ) + if result.returncode == 0 or _TOKEN_ERROR not in f"{result.stdout}\n{result.stderr}": + raise CleanControllerVerificationError("module did not reach missing-token validation") + playbook = controller.work / "syntax-check.yml" + playbook.write_text( + "---\n- hosts: localhost\n gather_facts: false\n tasks:\n" f" - {probe}: {{}}\n", + encoding="utf-8", + ) + _run(controller, [controller.binaries / "ansible-playbook", "--syntax-check", playbook]) + + +def verify_clean_controller( + wheel: Path, + collection: Path, + expected_version: str, +) -> tuple[int, int, str]: + """Verify matching artifacts using no project code inside the clean controller.""" + wheel_result = verify_python_wheel(wheel) + if wheel_result.version != expected_version: + raise CleanControllerVerificationError("wheel and collection versions do not match") + verify_collection_artifact(collection, expected_version) + wheel = wheel.resolve() + collection = collection.resolve() + with tempfile.TemporaryDirectory(prefix="sccfm-clean-controller-") as temporary: + controller = _create_controller(Path(temporary)) + _install_artifacts(controller, wheel, collection, expected_version) + module_count, inventory_count, probe = _discover(controller) + _offline_checks(controller, probe) + return module_count, inventory_count, probe + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("wheel", type=Path) + parser.add_argument("collection", type=Path) + parser.add_argument("--expected-version", required=True) + args = parser.parse_args() + try: + modules, inventory, probe = verify_clean_controller( + args.wheel, args.collection, args.expected_version + ) + except (OSError, RuntimeError, ValueError) as exc: + print(f"Clean-controller verification failed: {exc}", file=sys.stderr) + return 1 + print(f"Clean controller verified: modules={modules} inventory={inventory} probe={probe}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/cisco_sccfm_scripts/verify_python_artifacts.py b/cisco_sccfm_scripts/verify_python_artifacts.py index 3931cb76..f8fec417 100644 --- a/cisco_sccfm_scripts/verify_python_artifacts.py +++ b/cisco_sccfm_scripts/verify_python_artifacts.py @@ -113,6 +113,14 @@ class PythonArtifactVerification: sdist_files: int +@dataclass(frozen=True) +class PythonWheelVerification: + """Version and member count from a successfully verified public wheel.""" + + version: str + files: int + + def _wheel_version(path: Path) -> str: """Return the version encoded in the expected pure-Python wheel filename.""" parts = path.name.removesuffix(".whl").split("-") @@ -341,20 +349,26 @@ def _verify_sdist(path: Path, version: str) -> int: return sum(member.isfile() for member in members.values()) -def verify_python_artifacts(wheel: Path, sdist: Path) -> PythonArtifactVerification: - """Verify one matching wheel and sdist against the public artifact policy.""" +def verify_python_wheel(wheel: Path) -> PythonWheelVerification: + """Verify one public wheel without requiring its source-distribution counterpart.""" if wheel.is_symlink() or not wheel.is_file(): raise PythonArtifactVerificationError("wheel must be a regular file") + version = _wheel_version(wheel) + return PythonWheelVerification(version=version, files=_verify_wheel(wheel, version)) + + +def verify_python_artifacts(wheel: Path, sdist: Path) -> PythonArtifactVerification: + """Verify one matching wheel and sdist against the public artifact policy.""" if sdist.is_symlink() or not sdist.is_file(): raise PythonArtifactVerificationError("sdist must be a regular file") - wheel_version = _wheel_version(wheel) + wheel_verification = verify_python_wheel(wheel) sdist_version = _sdist_version(sdist) - if wheel_version != sdist_version: + if wheel_verification.version != sdist_version: raise PythonArtifactVerificationError("wheel and sdist versions do not match") return PythonArtifactVerification( - wheel_files=_verify_wheel(wheel, wheel_version), + wheel_files=wheel_verification.files, sdist_files=_verify_sdist(sdist, sdist_version), ) diff --git a/poetry.lock b/poetry.lock index ae727bee..7c05fa17 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1906,4 +1906,4 @@ files = [ [metadata] lock-version = "2.1" python-versions = "^3.12" -content-hash = "357676b89db4ee02067c9af7662de06c89561046a3a5773c7f77e64f1a4e4200" +content-hash = "f1aab5c6a46bff4152589b745913d3857b105ec358fda5999bf9e9263f054ff8" diff --git a/pyproject.toml b/pyproject.toml index ba410731..f87a3598 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,7 +58,7 @@ sccfm-cli = "cisco_sccfm_cli.cli:cli" [tool.poetry.group.dev.dependencies] cisco-sccfm-devtools = { path = "devtools", develop = true } -ansible-core = "^2.17.0" +ansible-core = ">=2.20,<2.22" black = "^25.11.0" click-man = "^0.5.1" isort = "^7.0.0" diff --git a/sccfm-ansible/README.md b/sccfm-ansible/README.md index 530b8dcb..465ea000 100644 --- a/sccfm-ansible/README.md +++ b/sccfm-ansible/README.md @@ -8,13 +8,14 @@ Ansible collection for managing Cisco Security Cloud Control Firewall Manager (S - [Features](#features) - [Installation](#installation) - - [Local Development](#local-development) + - [Requirements](#requirements) + - [Install a matched release](#install-a-matched-release) + - [Upgrade or downgrade](#upgrade-or-downgrade) + - [Automation Controller and execution environments](#automation-controller-and-execution-environments) + - [Verify the installation offline](#verify-the-installation-offline) - [Trying out examples](#trying-out-examples) - - [1. Set Up Ansible Vault](#1-set-up-ansible-vault) - - [2. Edit playbook](#2-edit-playbook) - - [2. Create Encrypted Secrets](#2-create-encrypted-secrets) - - [3. Configure Plain Variables](#3-configure-plain-variables) - - [4. Run Examples](#4-run-examples) + - [Configure authentication](#configure-authentication) + - [Run an example](#run-an-example) - [Test Inventory](#test-inventory) - [Host Variables](#host-variables) - [Modules](#modules) @@ -53,133 +54,173 @@ Ansible collection for managing Cisco Security Cloud Control Firewall Manager (S ## Installation -See instructions in the [INSTALL.md](INSTALL.md) file. +The collection and its Python package form one release. Modules in `cisco.sccfm` import +`cisco_sccfm_core` from the `cisco-sccfm-devkit` Python distribution, but Galaxy does not install +Python packages. Install both published artifacts at the **same exact version**. Mixing versions is +unsupported. -### Local Development +### Requirements -From the repository root, build the collection with the source-only helper: +- Python `>=3.12,<4.0` on the Ansible control node or inside the execution environment +- `ansible-core>=2.20,<2.22` in that same environment +- Network access from that environment to SCC Firewall Manager +- An SCCFM API token and one of these regions: `int`, `us`, `eu`, `apj`, `au`, `uae`, `in`, or + `ci` -```bash -source cisco_sccfm_scripts/activate.sh -build-ansible-collection -``` +The bundled examples run SCCFM API modules on `localhost`, so the Python package normally belongs +on the control node or in the execution environment. If you run or delegate a module to another +Ansible host, install the package in that host's module Python environment too. ASA and FTD devices +managed through the SCCFM API do not need the package installed on them. -The helper reads the repository version, creates the tarball under `dist/`, and verifies the exact -artifact. Install the built collection explicitly: +### Install a matched release + +Create and activate a Python 3.12 virtual environment. Replace `X.Y.Z` with a version that exists +on both PyPI and Ansible Galaxy, then install the Python artifact first and the Galaxy artifact +second: ```bash -ansible-galaxy collection install dist/cisco-sccfm-*.tar.gz --force +python3.12 -m venv .venv +source .venv/bin/activate +python -m pip install --upgrade pip +python -m pip install "ansible-core>=2.20,<2.22" "cisco-sccfm-devkit==X.Y.Z" +ansible-galaxy collection install "cisco.sccfm:==X.Y.Z" ``` -## Trying out examples +Do not continue with a partially published release. If either `X.Y.Z` artifact is unavailable, +install another version for which both artifacts exist. -### 1. Set Up Tokens (Recommended — interactive) +### Upgrade or downgrade -The PyPI package exposes only the `sccfm-cli` console command; it does not install repository -maintenance helpers. In an activated source checkout, run the token helper from the repository -root: +Change both artifacts together, keeping the Python package first in the operation: ```bash -change-tokens +python -m pip install --upgrade "cisco-sccfm-devkit==X.Y.Z" +ansible-galaxy collection install "cisco.sccfm:==X.Y.Z" --upgrade ``` -This will interactively: -1. Let you pick a previously saved token or create a new one -2. Ask which SCCFM region you're connecting to (for new tokens) -3. Prompt you to paste your API token -4. Save the token for future reuse -5. Create the `.env` file with `SCCFM_REGION` and `SCCFM_API_TOKEN` -6. Create the `.vault_pass` password file (if it doesn't exist) -7. Write and encrypt `group_vars/all/vault.yml` -8. Update `group_vars/all/vars.yml` with the selected region - -By default, Ansible credentials are written under `sccfm-ansible/examples`. They are Git-ignored -and explicitly excluded from collection artifacts. You can also point the standalone command at a -custom examples directory: +For a rollback, install the older Python version and force Ansible Galaxy to replace the installed +collection: ```bash -change-tokens --path /path/to/examples +python -m pip install "cisco-sccfm-devkit==X.Y.Z" +ansible-galaxy collection install "cisco.sccfm:==X.Y.Z" --force ``` -Users of installed artifacts should configure `SCCFM_REGION` and `SCCFM_API_TOKEN` through their -controller environment or secret manager, or use the manual Ansible Vault setup below. +Never upgrade or roll back only one artifact. -
-Manual setup (alternative) +### Automation Controller and execution environments -Create a vault password file (do NOT commit this!): +Automation Controller jobs must use an execution environment whose base image provides Python +`>=3.12,<4.0` and `ansible-core>=2.20,<2.22`. Pin the collection in the execution environment's +Galaxy requirements: -```bash -cd sccfm-ansible/examples -cp .vault_pass.example .vault_pass -echo "YourSecureVaultPassword" > .vault_pass -chmod 600 .vault_pass +```yaml +--- +collections: + - name: cisco.sccfm + version: "==X.Y.Z" ``` -Copy and edit the example vault file: +The packaged [`meta/execution-environment.yml`](meta/execution-environment.yml) directs Ansible +Builder to the packaged [`requirements.txt`](requirements.txt), which installs the exact matching +`cisco-sccfm-devkit` Python release. Do not override that dependency with a different version. +Provide `SCCFM_REGION` and `SCCFM_API_TOKEN` to the job through an Automation Controller +credential or another secret manager; do not bake tokens into an image. + +### Verify the installation offline + +These checks resolve the installed Python package, modules, and inventory plugin without contacting +SCCFM: ```bash -cp group_vars/all/vault.yml.example group_vars/all/vault.yml.temp -vim group_vars/all/vault.yml.temp +python -c 'from importlib.metadata import version; print(version("cisco-sccfm-devkit"))' +python -m pip check +ansible-galaxy collection list cisco.sccfm +ansible-doc -l -t module cisco.sccfm +ansible-doc -t inventory cisco.sccfm.sccfm ``` -Add your secrets: +The two reported release versions must be identical. For an offline syntax smoke test, save this as +`sccfm-smoke.yml`: + ```yaml --- -sccfm_api_token: "your-actual-api-token-here" -vault_asa_branch_office_01_password: "ActualPassword1" +- name: Validate cisco.sccfm collection resolution + hosts: localhost + gather_facts: false + tasks: + - name: Resolve a read-only SCCFM module + cisco.sccfm.list_network_objects: + limit: 1 ``` -Encrypt the vault file: +Then run: + ```bash -ansible-vault encrypt group_vars/all/vault.yml.temp \ - --vault-password-file .vault_pass \ - --output group_vars/all/vault.yml +ansible-playbook -i localhost, --syntax-check sccfm-smoke.yml +``` + +## Trying out examples + +### Configure authentication -rm group_vars/all/vault.yml.temp +For local use, provide credentials to the process environment. Enter the token through your shell, +CI secret store, or credential manager without putting it in a playbook, a tracked file, or a +command-line argument: + +```bash +export SCCFM_REGION=us +printf "SCCFM API token: " +read -r -s SCCFM_API_TOKEN +printf "\n" +export SCCFM_API_TOKEN ``` -Edit `group_vars/all/vars.yml`: +For long-lived automation, use a secret manager or an Ansible Vault variable in a playbook-local +file. For example, create `vault.yml` with `ansible-vault create vault.yml` and store: ```yaml -sccfm_region: us # Change to your region (int, us, eu, apj, au, uae, in, or ci) +--- +vault_sccfm_api_token: "replace-with-your-token" ``` -
+Reference it without exposing the token: -### 2. Edit playbook +```yaml +vars_files: + - vault.yml +module_defaults: + group/cisco.sccfm.all: + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ vault_sccfm_api_token }}" +``` -Edit the `onboard_asas.yml` playbook, and change the `asas_to_onboard` list to match your devices. +Run vault-backed playbooks with `--ask-vault-pass` or with your organization's approved vault +secret integration. Never commit a real token, decrypted vault, or vault password. -### 4. Run Examples +### Run an example + +The `examples/` directory is included in the Galaxy artifact. Copy the example you want into your +Ansible project before editing it. Use fully qualified collection names in your own playbooks. **Graph inventory:** ```bash -# Load SCCFM_REGION and SCCFM_API_TOKEN without putting the token on argv. -# `change-tokens` writes the repository .env for use with direnv. ansible-inventory -i examples/inventory.sccfm.yml \ --graph \ - --playbook-dir examples \ - --vault-password-file examples/.vault_pass + --playbook-dir examples ``` **Show all devices:** ```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 +examples/show_devices.yml ``` ### Test Inventory ```bash -ansible-inventory -i inventory.sccfm.yml --graph --vault-password-file .vault_pass +ansible-inventory -i inventory.sccfm.yml --graph ``` Do not use `--list`, `--yaml`, or `--graph --vars` while decrypted `group_vars` contain @@ -204,14 +245,14 @@ commands. ## Modules -Generated module and inventory reference docs can be previewed locally. Generate them with: +Discover the module and inventory documentation from the installed collection: ```bash -generate-ansible-docs +ansible-doc -l -t module cisco.sccfm +ansible-doc cisco.sccfm.onboard_asa +ansible-doc -t inventory cisco.sccfm.sccfm ``` -The generated Markdown is written under `docs/ansible/`. - ### cisco.sccfm.onboard_asa Onboard an ASA device to your SCCFM tenant. @@ -373,12 +414,12 @@ Three ways to provide credentials (in order of precedence): 3. **Environment variables**: ```bash export SCCFM_REGION=us - export SCCFM_API_TOKEN=your-token-here + # Inject SCCFM_API_TOKEN through your shell or secret manager as shown above. ``` ## Security Best Practices -1. **Never commit credential files**, including encrypted customer vaults, to this repository +1. **Never commit credential files**, including encrypted customer vaults, to your project 2. **Keep vault files encrypted** whenever they are at rest 3. **Store `.vault_pass` securely** and never commit it 4. **Use different vault passwords** for different environments (dev/prod) @@ -407,7 +448,7 @@ Three ways to provide credentials (in order of precedence): ### Inventory returns no hosts - Check your API token has proper permissions - Verify the region is correct -- Test API access: `curl -H "Authorization: Bearer $SCCFM_API_TOKEN" https://.cdo.cisco.com/api/rest/v1/inventory/devices` +- Run plain `ansible-inventory --graph` to test discovery without printing inventory variables ## Examples @@ -427,6 +468,8 @@ are Git-ignored and excluded from collection artifacts: ## Additional Resources +- [Installing Ansible collections](https://docs.ansible.com/projects/ansible/latest/collections_guide/collections_installing.html) +- [Ansible Core support matrix](https://docs.ansible.com/projects/ansible/latest/reference_appendices/release_and_maintenance.html#ansible-core-support-matrix) - [Ansible Vault Documentation](https://docs.ansible.com/ansible/latest/user_guide/vault.html) - [Ansible Inventory Plugins](https://docs.ansible.com/ansible/latest/plugins/inventory.html) - [SCC Firewall Manager API Documentation](https://developer.cisco.com/docs/security-cloud-control/) diff --git a/sccfm-ansible/examples/inventory.sccfm.yml b/sccfm-ansible/examples/inventory.sccfm.yml index 87d20cd8..1e84cda2 100644 --- a/sccfm-ansible/examples/inventory.sccfm.yml +++ b/sccfm-ansible/examples/inventory.sccfm.yml @@ -1,6 +1,6 @@ -# Set SCCFM_REGION and SCCFM_API_TOKEN in the controller environment. -# Create a .env file from .env.example and use direnv to load it. The -# lookups below keep plaintext credentials out of this inventory source. +# Set SCCFM_REGION and SCCFM_API_TOKEN in the controller environment through +# your shell, CI secret store, or Automation Controller credential. The lookups +# below keep plaintext credentials out of this inventory source. # Authentication values are used only to refresh inventory. The plugin never # exports them as host or group variables. plugin: cisco.sccfm.sccfm diff --git a/sccfm-ansible/meta/execution-environment.yml b/sccfm-ansible/meta/execution-environment.yml new file mode 100644 index 00000000..24be6fb7 --- /dev/null +++ b/sccfm-ansible/meta/execution-environment.yml @@ -0,0 +1,7 @@ +--- +# Copyright 2026 Cisco Systems, Inc. and its affiliates +# +# SPDX-License-Identifier: Apache-2.0 + +dependencies: + python: requirements.txt diff --git a/sccfm-ansible/meta/runtime.yml b/sccfm-ansible/meta/runtime.yml index 5d0c6e87..da7dae8f 100644 --- a/sccfm-ansible/meta/runtime.yml +++ b/sccfm-ansible/meta/runtime.yml @@ -1,5 +1,5 @@ --- -requires_ansible: ">=2.15.0" +requires_ansible: ">=2.20.0,<2.22.0" action_groups: cisco.sccfm.all: diff --git a/sccfm-ansible/requirements.txt b/sccfm-ansible/requirements.txt index babeb980..a2014f3b 100644 --- a/sccfm-ansible/requirements.txt +++ b/sccfm-ansible/requirements.txt @@ -1,9 +1,7 @@ -# Python dependencies for cisco.sccfm Ansible collection -# Install with: pip install -r requirements.txt +# Copyright 2026 Cisco Systems, Inc. and its affiliates +# +# SPDX-License-Identifier: Apache-2.0 -# Core dependencies -scc-firewall-manager-sdk==1.17.27 -paramiko>=5.0.0,<6 -cryptography>=50.0.0,<51 -# Note: cisco-sccfm-devkit includes both cisco_sccfm_cli and cisco_sccfm_core -# For local development, use: poetry install from parent directory +# Controller-side Python dependency for this cisco.sccfm release. +# The collection build keeps this exact version aligned with galaxy.yml. +cisco-sccfm-devkit==0.38.0 diff --git a/tests/test_ansible_dependency_metadata.py b/tests/test_ansible_dependency_metadata.py new file mode 100644 index 00000000..a350e1a6 --- /dev/null +++ b/tests/test_ansible_dependency_metadata.py @@ -0,0 +1,54 @@ +# Copyright 2026 Cisco Systems, Inc. and its affiliates +# +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import tomllib +from pathlib import Path +from typing import Any, cast + +import yaml + +_REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +_COLLECTION_ROOT = _REPOSITORY_ROOT / "sccfm-ansible" + + +def _yaml_mapping(path: Path) -> dict[str, Any]: + """Load a YAML mapping from a collection metadata file.""" + document = yaml.safe_load(path.read_text()) + assert isinstance(document, dict) + return cast(dict[str, Any], document) + + +def test_collection_python_requirement_matches_release_versions() -> None: + """Require the collection and its Python runtime package to ship in lockstep.""" + pyproject = tomllib.loads((_REPOSITORY_ROOT / "pyproject.toml").read_text()) + project_version = pyproject["tool"]["poetry"]["version"] + galaxy_version = _yaml_mapping(_COLLECTION_ROOT / "galaxy.yml")["version"] + requirement_lines = [ + line.strip() + for line in (_COLLECTION_ROOT / "requirements.txt").read_text().splitlines() + if line.strip() and not line.lstrip().startswith("#") + ] + + assert galaxy_version == project_version + assert requirement_lines == [f"cisco-sccfm-devkit=={project_version}"] + + +def test_execution_environment_uses_collection_requirements() -> None: + """Point Ansible Builder at the version-matched controller requirement.""" + metadata = _yaml_mapping(_COLLECTION_ROOT / "meta" / "execution-environment.yml") + + assert metadata == {"dependencies": {"python": "requirements.txt"}} + + +def test_supported_ansible_range_matches_development_and_collection_metadata() -> None: + """Keep the tested controller range consistent with the published collection.""" + pyproject = tomllib.loads((_REPOSITORY_ROOT / "pyproject.toml").read_text()) + runtime = _yaml_mapping(_COLLECTION_ROOT / "meta" / "runtime.yml") + + assert pyproject["tool"]["poetry"]["group"]["dev"]["dependencies"]["ansible-core"] == ( + ">=2.20,<2.22" + ) + assert runtime["requires_ansible"] == ">=2.20.0,<2.22.0" diff --git a/tests/test_build_ansible_collection.py b/tests/test_build_ansible_collection.py new file mode 100644 index 00000000..37768dbc --- /dev/null +++ b/tests/test_build_ansible_collection.py @@ -0,0 +1,52 @@ +# Copyright 2026 Cisco Systems, Inc. and its affiliates +# +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from cisco_sccfm_scripts.build_ansible_collection import ( + CollectionBuildError, + _sync_paired_python_requirement, +) + + +def test_sync_paired_python_requirement_writes_canonical_pair_pin(tmp_path: Path) -> None: + requirements = tmp_path / "requirements.txt" + requirements.write_text( + "# Runtime installed from the matching public wheel\n" + "cisco-sccfm-devkit == 0.37.0 # paired release\n", + encoding="utf-8", + ) + + _sync_paired_python_requirement(requirements, "0.38.0") + + assert requirements.read_text(encoding="utf-8") == ( + "# Runtime installed from the matching public wheel\n" "cisco-sccfm-devkit==0.38.0\n" + ) + + +@pytest.mark.parametrize( + "content", + [ + "example-package==1.0.0\n", + "cisco-sccfm-devkit==0.37.0\ncisco-sccfm-devkit==0.38.0\n", + "cisco-sccfm-devkit>=0.37.0\n", + "cisco-sccfm-devkit==0.37.0; python_version >= '3.12'\n", + "cisco-sccfm-devkit==0.37.0\nexample-package==1.0.0\n", + ], +) +def test_sync_paired_python_requirement_rejects_ambiguous_contract( + tmp_path: Path, + content: str, +) -> None: + requirements = tmp_path / "requirements.txt" + requirements.write_text(content, encoding="utf-8") + + with pytest.raises(CollectionBuildError): + _sync_paired_python_requirement(requirements, "0.38.0") + + assert requirements.read_text(encoding="utf-8") == content diff --git a/tests/test_verify_ansible_collection.py b/tests/test_verify_ansible_collection.py index ffdec9d5..dcc9c9de 100644 --- a/tests/test_verify_ansible_collection.py +++ b/tests/test_verify_ansible_collection.py @@ -46,8 +46,9 @@ "examples/.vault_pass.example": b"replace-me\n", "examples/group_vars/all/vault.yml.example": b"---\nsccfm_api_token: placeholder\n", "examples/show_devices.yml": b"---\n- name: Synthetic example\n hosts: localhost\n", - "meta/runtime.yml": b"requires_ansible: '>=2.15.0'\n", - "requirements.txt": b"example-package\n", + "meta/execution-environment.yml": b"---\ndependencies:\n python: requirements.txt\n", + "meta/runtime.yml": b"requires_ansible: '>=2.20.0,<2.22.0'\n", + "requirements.txt": f"cisco-sccfm-devkit=={_VERSION}\n".encode(), } @@ -201,6 +202,28 @@ def test_verifier_rejects_wrong_license_content(tmp_path: Path) -> None: verify_collection_artifact(artifact, expected_version=_VERSION) +def test_verifier_rejects_mismatched_python_package_version(tmp_path: Path) -> None: + artifact = _build_synthetic_artifact( + tmp_path, + extra_files={"requirements.txt": b"cisco-sccfm-devkit==9.9.9\n"}, + ) + + with pytest.raises(ArtifactVerificationError, match="version-matched Python package"): + verify_collection_artifact(artifact, expected_version=_VERSION) + + +def test_verifier_rejects_wrong_execution_environment_requirement_path(tmp_path: Path) -> None: + artifact = _build_synthetic_artifact( + tmp_path, + extra_files={ + "meta/execution-environment.yml": b"---\ndependencies:\n python: other.txt\n" + }, + ) + + with pytest.raises(ArtifactVerificationError, match="does not reference requirements.txt"): + verify_collection_artifact(artifact, expected_version=_VERSION) + + def test_builder_detects_collection_source_symlink(tmp_path: Path) -> None: collection = tmp_path / "collection" examples = collection / "examples" diff --git a/tests/test_verify_clean_controller.py b/tests/test_verify_clean_controller.py new file mode 100644 index 00000000..6d5a00ed --- /dev/null +++ b/tests/test_verify_clean_controller.py @@ -0,0 +1,52 @@ +# Copyright 2026 Cisco Systems, Inc. and its affiliates +# +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from cisco_sccfm_scripts import verify_clean_controller as verifier + + +def test_discovery_parser_accepts_only_cisco_sccfm_plugins() -> None: + raw = json.dumps( + { + "cisco.sccfm.second_plugin": "Second", + "cisco.sccfm.first_plugin": "First", + } + ) + + assert verifier._discovered_plugins(raw, "module") == { + "cisco.sccfm.first_plugin": "First", + "cisco.sccfm.second_plugin": "Second", + } + + +@pytest.mark.parametrize("raw", ["not-json", "{}", '{"other.collection.plugin": "Bad"}']) +def test_discovery_parser_rejects_invalid_results(raw: str) -> None: + with pytest.raises(verifier.CleanControllerVerificationError): + verifier._discovered_plugins(raw, "module") + + +def test_controller_isolates_user_state_and_sccfm_credentials( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("SCCFM_API_TOKEN", "synthetic-secret") + monkeypatch.setenv("SCCFM_REGION", "us") + monkeypatch.setenv("SCCFM_CONFIG", "/not/used") + monkeypatch.setenv("ANSIBLE_VAULT_PASSWORD_FILE", "/not/used") + monkeypatch.setenv("PYTHONUSERBASE", "/not/used") + controller = verifier._create_controller(tmp_path) + + assert not any(name.startswith("SCCFM_") for name in controller.environment) + assert "ANSIBLE_VAULT_PASSWORD_FILE" not in controller.environment + assert "PYTHONUSERBASE" not in controller.environment + assert controller.environment["PYTHONNOUSERSITE"] == "1" + assert Path(controller.environment["HOME"]).parent == tmp_path + assert Path(controller.environment["XDG_CONFIG_HOME"]).parent == tmp_path + assert controller.environment["ANSIBLE_COLLECTIONS_PATH"] == str(controller.collections) diff --git a/tests/test_verify_python_artifacts.py b/tests/test_verify_python_artifacts.py index e490d321..0cd7dc4c 100644 --- a/tests/test_verify_python_artifacts.py +++ b/tests/test_verify_python_artifacts.py @@ -15,6 +15,7 @@ from cisco_sccfm_scripts.verify_python_artifacts import ( PythonArtifactVerificationError, verify_python_artifacts, + verify_python_wheel, ) _VERSION = "1.2.3" @@ -88,6 +89,15 @@ def test_verifier_accepts_public_artifact_pair(tmp_path: Path) -> None: assert result.sdist_files == 7 +def test_wheel_verifier_accepts_public_wheel_without_sdist(tmp_path: Path) -> None: + wheel, _ = _build_artifacts(tmp_path) + + result = verify_python_wheel(wheel) + + assert result.version == _VERSION + assert result.files == 6 + + @pytest.mark.parametrize( ("artifact", "member"), [ From a04e3cd8b73f3c24263094d9eed76a2d1ce42c0a Mon Sep 17 00:00:00 2001 From: Andrei Huides Date: Tue, 11 Aug 2026 22:46:48 +0300 Subject: [PATCH 10/19] fix(lh-102436): Galaxy metadata/runtime issues --- .github/workflows/ci.yml | 18 +++++ REUSE.toml | 10 +++ .../verify_ansible_collection.py | 18 +++++ docs/ansible/inventory/sccfm.md | 8 +-- docs/ansible/modules/add_asa_shun.md | 9 +-- .../modules/add_network_group_members.md | 9 +-- docs/ansible/modules/add_object_override.md | 9 +-- .../apply_object_override_as_default.md | 9 +-- docs/ansible/modules/asa_ha_check.md | 9 +-- docs/ansible/modules/change_asa_boot_image.md | 9 +-- .../modules/change_asa_local_password.md | 10 +-- docs/ansible/modules/clear_asa_shun.md | 9 +-- docs/ansible/modules/configure_manager.md | 8 +-- docs/ansible/modules/create_access_rule.md | 9 +-- docs/ansible/modules/create_network_group.md | 9 +-- docs/ansible/modules/create_network_object.md | 9 +-- docs/ansible/modules/delete_access_rule.md | 9 +-- docs/ansible/modules/delete_network_group.md | 9 +-- docs/ansible/modules/delete_network_object.md | 9 +-- .../ansible/modules/delete_object_override.md | 9 +-- docs/ansible/modules/deploy_cdfmc_ftd.md | 9 +-- docs/ansible/modules/edit_object_override.md | 9 +-- docs/ansible/modules/execute_asa_cli.md | 9 +-- docs/ansible/modules/execute_ftd_cli.md | 9 +-- docs/ansible/modules/get_access_group.md | 9 +-- docs/ansible/modules/get_access_rule.md | 9 +-- docs/ansible/modules/get_object.md | 9 +-- docs/ansible/modules/list_access_groups.md | 9 +-- docs/ansible/modules/list_access_rules.md | 9 +-- .../ansible/modules/list_asa_boot_registry.md | 9 +-- .../modules/list_asa_compatible_versions.md | 9 +-- docs/ansible/modules/list_asa_disk_files.md | 9 +-- docs/ansible/modules/list_asa_local_users.md | 9 +-- .../modules/list_asa_not_on_version.md | 9 +-- .../modules/list_cdfmc_access_policies.md | 9 +-- .../modules/list_ftd_compatible_versions.md | 9 +-- .../modules/list_ftd_not_on_version.md | 9 +-- docs/ansible/modules/list_managers.md | 9 +-- docs/ansible/modules/list_network_groups.md | 9 +-- docs/ansible/modules/list_network_objects.md | 9 +-- docs/ansible/modules/onboard_asa.md | 10 +-- docs/ansible/modules/onboard_cdfmc_ftd.md | 9 +-- docs/ansible/modules/onboard_cdfmc_ftd_ztp.md | 10 +-- docs/ansible/modules/register_cdfmc_ftd.md | 9 +-- docs/ansible/modules/remove_asa_shun.md | 9 +-- .../modules/remove_network_group_members.md | 9 +-- docs/ansible/modules/show_asa_shun.md | 9 +-- docs/ansible/modules/trigger_asa_upgrade.md | 9 +-- docs/ansible/modules/trigger_ftd_upgrade.md | 9 +-- docs/ansible/modules/update_access_rule.md | 9 +-- docs/ansible/modules/update_network_group.md | 9 +-- docs/ansible/modules/update_network_object.md | 9 +-- docs/ansible/modules/update_object_default.md | 9 +-- sccfm-ansible/CHANGELOG.rst | 13 ++++ sccfm-ansible/changelogs/changelog.yaml | 11 +++ sccfm-ansible/changelogs/config.yaml | 42 +++++++++++ sccfm-ansible/galaxy.yml | 6 +- sccfm-ansible/meta/runtime.yml | 5 -- sccfm-ansible/plugins/inventory/sccfm.py | 33 ++++----- sccfm-ansible/plugins/module_utils/config.py | 20 ++++-- .../plugins/module_utils/dependencies.py | 35 +++++++++ .../plugins/module_utils/loaders/__init__.py | 9 --- .../module_utils/loaders/inventory_loader.py | 51 ------------- .../plugins/module_utils/operations.py | 9 ++- sccfm-ansible/plugins/modules/__init__.py | 3 - sccfm-ansible/plugins/modules/add_asa_shun.py | 42 ++++++----- .../modules/add_network_group_members.py | 53 +++++++------- .../plugins/modules/add_object_override.py | 37 +++++----- .../apply_object_override_as_default.py | 37 +++++----- sccfm-ansible/plugins/modules/asa_ha_check.py | 47 ++++++------ .../plugins/modules/change_asa_boot_image.py | 55 +++++++------- .../modules/change_asa_local_password.py | 42 ++++++----- .../plugins/modules/clear_asa_shun.py | 40 +++++++---- .../plugins/modules/configure_manager.py | 37 ++++++---- .../plugins/modules/create_access_rule.py | 37 +++++----- .../plugins/modules/create_network_group.py | 37 +++++----- .../plugins/modules/create_network_object.py | 37 +++++----- .../plugins/modules/delete_access_rule.py | 37 +++++----- .../plugins/modules/delete_network_group.py | 46 ++++++------ .../plugins/modules/delete_network_object.py | 46 ++++++------ .../plugins/modules/delete_object_override.py | 37 +++++----- .../plugins/modules/deploy_cdfmc_ftd.py | 60 +++++++++------- .../plugins/modules/edit_object_override.py | 37 +++++----- .../plugins/modules/execute_asa_cli.py | 47 ++++++------ .../plugins/modules/execute_ftd_cli.py | 47 ++++++------ .../plugins/modules/get_access_group.py | 37 +++++----- .../plugins/modules/get_access_rule.py | 37 +++++----- sccfm-ansible/plugins/modules/get_object.py | 37 +++++----- .../plugins/modules/list_access_groups.py | 37 +++++----- .../plugins/modules/list_access_rules.py | 37 +++++----- .../plugins/modules/list_asa_boot_registry.py | 49 +++++++------ .../modules/list_asa_compatible_versions.py | 49 +++++++------ .../plugins/modules/list_asa_disk_files.py | 49 +++++++------ .../plugins/modules/list_asa_local_users.py | 51 +++++++------ .../modules/list_asa_not_on_version.py | 38 +++++----- .../modules/list_cdfmc_access_policies.py | 39 +++++----- .../modules/list_ftd_compatible_versions.py | 50 +++++++------ .../modules/list_ftd_not_on_version.py | 41 ++++++----- .../plugins/modules/list_managers.py | 37 +++++----- .../plugins/modules/list_network_groups.py | 43 ++++++----- .../plugins/modules/list_network_objects.py | 43 ++++++----- sccfm-ansible/plugins/modules/onboard_asa.py | 56 ++++++++------- .../plugins/modules/onboard_cdfmc_ftd.py | 60 +++++++++------- .../plugins/modules/onboard_cdfmc_ftd_ztp.py | 60 +++++++++------- .../plugins/modules/register_cdfmc_ftd.py | 37 +++++----- .../plugins/modules/remove_asa_shun.py | 40 +++++++---- .../modules/remove_network_group_members.py | 53 +++++++------- .../plugins/modules/show_asa_shun.py | 42 ++++++----- .../plugins/modules/tests/conftest.py | 46 ++++++------ .../modules/tests/test_module_utils_config.py | 33 +++++++++ .../plugins/modules/trigger_asa_upgrade.py | 68 ++++++++++-------- .../plugins/modules/trigger_ftd_upgrade.py | 69 +++++++++--------- .../plugins/modules/update_access_rule.py | 38 +++++----- .../plugins/modules/update_network_group.py | 52 ++++++++------ .../plugins/modules/update_network_object.py | 52 ++++++++------ .../plugins/modules/update_object_default.py | 37 +++++----- .../builders => plugin_utils}/__init__.py | 4 -- .../inventory_host_builder.py | 27 +++++-- .../plugins/plugin_utils/inventory_loader.py | 72 +++++++++++++++++++ sccfm-ansible/tests/sanity/ignore-2.20.txt | 50 +++++++++++++ sccfm-ansible/tests/sanity/ignore-2.21.txt | 50 +++++++++++++ tests/test_ansible_dependency_metadata.py | 32 +++++++++ tests/test_verify_ansible_collection.py | 22 ++++++ 123 files changed, 1784 insertions(+), 1482 deletions(-) create mode 100644 REUSE.toml create mode 100644 sccfm-ansible/CHANGELOG.rst create mode 100644 sccfm-ansible/changelogs/changelog.yaml create mode 100644 sccfm-ansible/changelogs/config.yaml create mode 100644 sccfm-ansible/plugins/module_utils/dependencies.py delete mode 100644 sccfm-ansible/plugins/module_utils/loaders/__init__.py delete mode 100644 sccfm-ansible/plugins/module_utils/loaders/inventory_loader.py rename sccfm-ansible/plugins/{module_utils/builders => plugin_utils}/__init__.py (50%) rename sccfm-ansible/plugins/{module_utils/builders => plugin_utils}/inventory_host_builder.py (78%) create mode 100644 sccfm-ansible/plugins/plugin_utils/inventory_loader.py create mode 100644 sccfm-ansible/tests/sanity/ignore-2.20.txt create mode 100644 sccfm-ansible/tests/sanity/ignore-2.21.txt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1ffdd180..f4c472df 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -87,6 +87,24 @@ jobs: - name: Test run: poetry run pytest --color=yes + - name: Ansible sanity + run: | + set -euo pipefail + VENV_PATH="$(poetry env info --path)" + SANITY_ROOT="$(mktemp -d "${RUNNER_TEMP}/sccfm-sanity.XXXXXX")" + COLLECTION_ROOT="${SANITY_ROOT}/ansible_collections/cisco/sccfm" + mkdir -p "${COLLECTION_ROOT}" "${SANITY_ROOT}/home" "${SANITY_ROOT}/local" + git archive HEAD:sccfm-ansible | tar -x -C "${COLLECTION_ROOT}" + rm -rf \ + "${COLLECTION_ROOT}/ci" \ + "${COLLECTION_ROOT}/e2e" \ + "${COLLECTION_ROOT}/plugins/modules/tests" + cd "${COLLECTION_ROOT}" + HOME="${SANITY_ROOT}/home" \ + XDG_CACHE_HOME="${SANITY_ROOT}/home/.cache" \ + ANSIBLE_LOCAL_TEMP="${SANITY_ROOT}/local" \ + "${VENV_PATH}/bin/ansible-test" sanity --local --truncate 0 + - name: Build and verify release artifacts run: | set -euo pipefail diff --git a/REUSE.toml b/REUSE.toml new file mode 100644 index 00000000..76b3faea --- /dev/null +++ b/REUSE.toml @@ -0,0 +1,10 @@ +version = 1 + +[[annotations]] +path = [ + "sccfm-ansible/CHANGELOG.rst", + "sccfm-ansible/changelogs/changelog.yaml", +] +precedence = "override" +SPDX-FileCopyrightText = "2026 Cisco Systems, Inc. and its affiliates" +SPDX-License-Identifier = "Apache-2.0" diff --git a/cisco_sccfm_scripts/verify_ansible_collection.py b/cisco_sccfm_scripts/verify_ansible_collection.py index 1dc43eb9..e3660f3a 100644 --- a/cisco_sccfm_scripts/verify_ansible_collection.py +++ b/cisco_sccfm_scripts/verify_ansible_collection.py @@ -25,22 +25,28 @@ _ALLOWED_TOP_LEVEL = frozenset( { "FILES.json", + "CHANGELOG.rst", "LICENSE", "MANIFEST.json", "README.md", "__init__.py", + "changelogs", "examples", "meta", "plugins", "requirements.txt", + "tests", } ) _REQUIRED_MEMBERS = frozenset( { "FILES.json", + "CHANGELOG.rst", "LICENSE", "MANIFEST.json", "README.md", + "changelogs/changelog.yaml", + "changelogs/config.yaml", "examples/.vault_pass.example", "examples/group_vars/all/vault.yml.example", "meta/execution-environment.yml", @@ -49,6 +55,8 @@ "plugins/module_utils", "plugins/modules", "requirements.txt", + "tests/sanity/ignore-2.20.txt", + "tests/sanity/ignore-2.21.txt", } ) _SAFE_CREDENTIAL_TEMPLATES = frozenset( @@ -101,6 +109,14 @@ "examples/update_network_objects.yml", } ) +_ALLOWED_TEST_PATHS = frozenset( + { + "tests", + "tests/sanity", + "tests/sanity/ignore-2.20.txt", + "tests/sanity/ignore-2.21.txt", + } +) _FORBIDDEN_DIRECTORY_NAMES = frozenset( { ".git", @@ -203,6 +219,8 @@ def _check_member_path(name: str) -> None: raise ArtifactVerificationError(f"forbidden runtime directory in artifact: {name}") if path.parts[0] == "examples" and name not in _ALLOWED_EXAMPLE_PATHS: raise ArtifactVerificationError(f"unreviewed examples path in artifact: {name}") + if path.parts[0] == "tests" and name not in _ALLOWED_TEST_PATHS: + raise ArtifactVerificationError(f"unreviewed test policy path in artifact: {name}") if name in _SAFE_CREDENTIAL_TEMPLATES: return diff --git a/docs/ansible/inventory/sccfm.md b/docs/ansible/inventory/sccfm.md index 1835296b..f027e1a6 100644 --- a/docs/ansible/inventory/sccfm.md +++ b/docs/ansible/inventory/sccfm.md @@ -26,7 +26,6 @@ OPTIONS (= indicates it is required): set_via: env: - name: SCCFM_API_TOKEN - no_log: true type: str - group Group to place all discovered SCCFM devices into. @@ -42,7 +41,8 @@ OPTIONS (= indicates it is required): default: 100 type: int -= plugin Ensure this plugin gets loaded. += plugin Token that ensures this is a source file for the + `cisco.sccfm.sccfm' plugin. choices: [cisco.sccfm.sccfm] - query Optional text filter applied to device names. @@ -56,9 +56,7 @@ OPTIONS (= indicates it is required): - name: SCCFM_REGION type: str -NAME: cisco.sccfm.sccfm - -PLUGIN_TYPE: inventory +NAME: sccfm EXAMPLES: plugin: cisco.sccfm.sccfm diff --git a/docs/ansible/modules/add_asa_shun.md b/docs/ansible/modules/add_asa_shun.md index 5ea3f4de..e5b34173 100644 --- a/docs/ansible/modules/add_asa_shun.md +++ b/docs/ansible/modules/add_asa_shun.md @@ -32,11 +32,7 @@ $ ansible-doc -t module cisco.sccfm.add_asa_shun OPTIONS (= indicates it is required): - api_token API token for SCCFM. - set_via: - env: - - name: SCCFM_API_TOKEN default: null - no_log: true type: str - dest_ip Destination IP of a specific connection to drop @@ -111,9 +107,6 @@ OPTIONS (= indicates it is required): type: str - region SCCFM region (int, us, eu, apj, au, uae, in, or ci). - set_via: - env: - - name: SCCFM_REGION default: null type: str @@ -134,7 +127,7 @@ OPTIONS (= indicates it is required): elements: str type: list -AUTHOR: Cisco SCCFM Team +AUTHOR: huides00 (@huides00), Scoombe (@Scoombe), afercal (@afercal) EXAMPLES: # Example 1: Shun a single source IP on devices matching a query diff --git a/docs/ansible/modules/add_network_group_members.md b/docs/ansible/modules/add_network_group_members.md index f12da3ad..efaa18b3 100644 --- a/docs/ansible/modules/add_network_group_members.md +++ b/docs/ansible/modules/add_network_group_members.md @@ -25,11 +25,7 @@ $ ansible-doc -t module cisco.sccfm.add_network_group_members OPTIONS (= indicates it is required): - api_token API token for SCCFM. - set_via: - env: - - name: SCCFM_API_TOKEN default: null - no_log: true type: str - name Name of the network group to update. @@ -43,9 +39,6 @@ OPTIONS (= indicates it is required): type: list - region SCCFM region (int, us, eu, apj, au, uae, in, or ci). - set_via: - env: - - name: SCCFM_REGION default: null type: str @@ -53,7 +46,7 @@ OPTIONS (= indicates it is required): default: null type: str -AUTHOR: Cisco SCCFM Team +AUTHOR: huides00 (@huides00), Scoombe (@Scoombe), afercal (@afercal) EXAMPLES: # Example 1: Add members by name diff --git a/docs/ansible/modules/add_object_override.md b/docs/ansible/modules/add_object_override.md index 14849bda..c6846440 100644 --- a/docs/ansible/modules/add_object_override.md +++ b/docs/ansible/modules/add_object_override.md @@ -26,11 +26,7 @@ $ ansible-doc -t module cisco.sccfm.add_object_override OPTIONS (= indicates it is required): - api_token API token for SCCFM. - set_via: - env: - - name: SCCFM_API_TOKEN default: null - no_log: true type: str = override_value The literal value for the override. For network @@ -41,9 +37,6 @@ OPTIONS (= indicates it is required): type: str - region SCCFM region (int, us, eu, apj, au, uae, in, or ci). - set_via: - env: - - name: SCCFM_REGION default: null type: str @@ -54,7 +47,7 @@ OPTIONS (= indicates it is required): to. type: str -AUTHOR: Cisco SCCFM Team +AUTHOR: huides00 (@huides00), Scoombe (@Scoombe), afercal (@afercal) EXAMPLES: # Example 1: Add an override for a specific device diff --git a/docs/ansible/modules/apply_object_override_as_default.md b/docs/ansible/modules/apply_object_override_as_default.md index fa8f4e3f..1b3d0dc6 100644 --- a/docs/ansible/modules/apply_object_override_as_default.md +++ b/docs/ansible/modules/apply_object_override_as_default.md @@ -22,17 +22,10 @@ $ ansible-doc -t module cisco.sccfm.apply_object_override_as_default OPTIONS (= indicates it is required): - api_token API token for SCCFM. - set_via: - env: - - name: SCCFM_API_TOKEN default: null - no_log: true type: str - region SCCFM region (int, us, eu, apj, au, uae, in, or ci). - set_via: - env: - - name: SCCFM_REGION default: null type: str @@ -43,7 +36,7 @@ OPTIONS (= indicates it is required): = uid Unique identifier (UID) of the object. type: str -AUTHOR: Cisco SCCFM Team +AUTHOR: huides00 (@huides00), Scoombe (@Scoombe), afercal (@afercal) EXAMPLES: # Example 1: Promote a device override to become the new default diff --git a/docs/ansible/modules/asa_ha_check.md b/docs/ansible/modules/asa_ha_check.md index fa3051cc..d3f1edc4 100644 --- a/docs/ansible/modules/asa_ha_check.md +++ b/docs/ansible/modules/asa_ha_check.md @@ -27,11 +27,7 @@ $ ansible-doc -t module cisco.sccfm.asa_ha_check OPTIONS (= indicates it is required): - api_token API token for SCCFM. - set_via: - env: - - name: SCCFM_API_TOKEN default: null - no_log: true type: str - limit Maximum number of devices to return when using `query'. @@ -49,9 +45,6 @@ OPTIONS (= indicates it is required): type: str - region SCCFM region (int, us, eu, apj, au, uae, in, or ci). - set_via: - env: - - name: SCCFM_REGION default: null type: str @@ -61,7 +54,7 @@ OPTIONS (= indicates it is required): elements: str type: list -AUTHOR: Cisco SCCFM Team +AUTHOR: huides00 (@huides00), Scoombe (@Scoombe), afercal (@afercal) EXAMPLES: # Example 1: Check HA status on devices matching a query diff --git a/docs/ansible/modules/change_asa_boot_image.md b/docs/ansible/modules/change_asa_boot_image.md index 9dbde793..40bda3de 100644 --- a/docs/ansible/modules/change_asa_boot_image.md +++ b/docs/ansible/modules/change_asa_boot_image.md @@ -26,11 +26,7 @@ $ ansible-doc -t module cisco.sccfm.change_asa_boot_image OPTIONS (= indicates it is required): - api_token API token for SCCFM. - set_via: - env: - - name: SCCFM_API_TOKEN default: null - no_log: true type: str = image_path Full ASA image path already present on the device, such @@ -54,9 +50,6 @@ OPTIONS (= indicates it is required): type: str - region SCCFM region (int, us, eu, apj, au, uae, in, or ci). - set_via: - env: - - name: SCCFM_REGION default: null type: str @@ -66,7 +59,7 @@ OPTIONS (= indicates it is required): elements: str type: list -AUTHOR: Cisco SCCFM Team +AUTHOR: huides00 (@huides00), Scoombe (@Scoombe), afercal (@afercal) EXAMPLES: # Example 1: Change boot image using a query diff --git a/docs/ansible/modules/change_asa_local_password.md b/docs/ansible/modules/change_asa_local_password.md index b67693e9..890b480f 100644 --- a/docs/ansible/modules/change_asa_local_password.md +++ b/docs/ansible/modules/change_asa_local_password.md @@ -28,11 +28,7 @@ $ ansible-doc -t module cisco.sccfm.change_asa_local_password OPTIONS (= indicates it is required): - api_token API token for SCCFM. - set_via: - env: - - name: SCCFM_API_TOKEN default: null - no_log: true type: str - limit Maximum number of devices to return when using `query'. @@ -41,7 +37,6 @@ OPTIONS (= indicates it is required): type: int = new_password The new password to set for the user. - no_log: true type: str - offset Pagination offset when using `query'. @@ -56,9 +51,6 @@ OPTIONS (= indicates it is required): type: str - region SCCFM region (int, us, eu, apj, au, uae, in, or ci). - set_via: - env: - - name: SCCFM_REGION default: null type: str @@ -71,7 +63,7 @@ OPTIONS (= indicates it is required): = username The local ASA username whose password will be changed. type: str -AUTHOR: Cisco SCCFM Team +AUTHOR: huides00 (@huides00), Scoombe (@Scoombe), afercal (@afercal) EXAMPLES: # Example 1: Change password on ASAs matching a query diff --git a/docs/ansible/modules/clear_asa_shun.md b/docs/ansible/modules/clear_asa_shun.md index 5a63825c..23cbb68b 100644 --- a/docs/ansible/modules/clear_asa_shun.md +++ b/docs/ansible/modules/clear_asa_shun.md @@ -25,11 +25,7 @@ $ ansible-doc -t module cisco.sccfm.clear_asa_shun OPTIONS (= indicates it is required): - api_token API token for SCCFM. - set_via: - env: - - name: SCCFM_API_TOKEN default: null - no_log: true type: str - limit Maximum number of devices to return when using `query'. @@ -49,9 +45,6 @@ OPTIONS (= indicates it is required): type: str - region SCCFM region (int, us, eu, apj, au, uae, in, or ci). - set_via: - env: - - name: SCCFM_REGION default: null type: str @@ -61,7 +54,7 @@ OPTIONS (= indicates it is required): elements: str type: list -AUTHOR: Cisco SCCFM Team +AUTHOR: huides00 (@huides00), Scoombe (@Scoombe), afercal (@afercal) EXAMPLES: # Example 1: Clear all shuns on devices matching a query diff --git a/docs/ansible/modules/configure_manager.md b/docs/ansible/modules/configure_manager.md index 8fc2c5d7..5c53ed7c 100644 --- a/docs/ansible/modules/configure_manager.md +++ b/docs/ansible/modules/configure_manager.md @@ -41,9 +41,6 @@ OPTIONS (= indicates it is required): - ftd_password SSH password for the FTD VM. Can also be supplied via the `SCCFM_FTD_PASSWORD' environment variable. - set_via: - env: - - name: SCCFM_FTD_PASSWORD default: null type: str @@ -67,9 +64,6 @@ OPTIONS (= indicates it is required): environment variable. Leave unset to use SSH key/agent authentication for the jump host. - set_via: - env: - - name: SCCFM_JUMP_PASSWORD default: null type: str @@ -77,7 +71,7 @@ OPTIONS (= indicates it is required): default: 30 type: int -AUTHOR: Cisco SCCFM Team +AUTHOR: huides00 (@huides00), Scoombe (@Scoombe), afercal (@afercal) EXAMPLES: # Example 1: Direct SSH to the FTD diff --git a/docs/ansible/modules/create_access_rule.md b/docs/ansible/modules/create_access_rule.md index 20c852b9..03cb2319 100644 --- a/docs/ansible/modules/create_access_rule.md +++ b/docs/ansible/modules/create_access_rule.md @@ -29,11 +29,7 @@ OPTIONS (= indicates it is required): type: bool - api_token API token for SCCFM. - set_via: - env: - - name: SCCFM_API_TOKEN default: null - no_log: true type: str - destination_network Destination network object name. @@ -63,9 +59,6 @@ OPTIONS (= indicates it is required): type: str - region SCCFM region (int, us, eu, apj, au, uae, in, or ci). - set_via: - env: - - name: SCCFM_REGION default: null type: str @@ -86,7 +79,7 @@ OPTIONS (= indicates it is required): default: null type: str -AUTHOR: Cisco SCCFM Team +AUTHOR: huides00 (@huides00), Scoombe (@Scoombe), afercal (@afercal) EXAMPLES: # Example 1: Create a permit rule with explicit credentials diff --git a/docs/ansible/modules/create_network_group.md b/docs/ansible/modules/create_network_group.md index 80de113d..9583ab34 100644 --- a/docs/ansible/modules/create_network_group.md +++ b/docs/ansible/modules/create_network_group.md @@ -23,11 +23,7 @@ $ ansible-doc -t module cisco.sccfm.create_network_group OPTIONS (= indicates it is required): - api_token API token for SCCFM. - set_via: - env: - - name: SCCFM_API_TOKEN default: null - no_log: true type: str - description Optional description for the network group. @@ -57,9 +53,6 @@ OPTIONS (= indicates it is required): type: list - region SCCFM region (int, us, eu, apj, au, uae, in, or ci). - set_via: - env: - - name: SCCFM_REGION default: null type: str @@ -74,7 +67,7 @@ OPTIONS (= indicates it is required): elements: str type: list -AUTHOR: Cisco SCCFM Team +AUTHOR: huides00 (@huides00), Scoombe (@Scoombe), afercal (@afercal) EXAMPLES: # Example 1: Create a group with network literals diff --git a/docs/ansible/modules/create_network_object.md b/docs/ansible/modules/create_network_object.md index 8ddac83f..197dc550 100644 --- a/docs/ansible/modules/create_network_object.md +++ b/docs/ansible/modules/create_network_object.md @@ -21,11 +21,7 @@ $ ansible-doc -t module cisco.sccfm.create_network_object OPTIONS (= indicates it is required): - api_token API token for SCCFM. - set_via: - env: - - name: SCCFM_API_TOKEN default: null - no_log: true type: str - description Optional description for the network object. @@ -41,9 +37,6 @@ OPTIONS (= indicates it is required): type: str - region SCCFM region (int, us, eu, apj, au, uae, in, or ci). - set_via: - env: - - name: SCCFM_REGION default: null type: str @@ -57,7 +50,7 @@ OPTIONS (= indicates it is required): an IP range (e.g., `10.0.0.1-10.0.0.10'). type: str -AUTHOR: Cisco SCCFM Team +AUTHOR: huides00 (@huides00), Scoombe (@Scoombe), afercal (@afercal) EXAMPLES: # Example 1: Create a host network object diff --git a/docs/ansible/modules/delete_access_rule.md b/docs/ansible/modules/delete_access_rule.md index 3e79e390..19c0d257 100644 --- a/docs/ansible/modules/delete_access_rule.md +++ b/docs/ansible/modules/delete_access_rule.md @@ -20,24 +20,17 @@ $ ansible-doc -t module cisco.sccfm.delete_access_rule OPTIONS (= indicates it is required): - api_token API token for SCCFM. - set_via: - env: - - name: SCCFM_API_TOKEN default: null - no_log: true type: str - region SCCFM region (int, us, eu, apj, au, uae, in, or ci). - set_via: - env: - - name: SCCFM_REGION default: null type: str = uid Unique identifier (UID) of the access rule to delete. type: str -AUTHOR: Cisco SCCFM Team +AUTHOR: huides00 (@huides00), Scoombe (@Scoombe), afercal (@afercal) EXAMPLES: # Example 1: Delete an access rule by UID diff --git a/docs/ansible/modules/delete_network_group.md b/docs/ansible/modules/delete_network_group.md index 9e4029d3..6cf859e0 100644 --- a/docs/ansible/modules/delete_network_group.md +++ b/docs/ansible/modules/delete_network_group.md @@ -21,11 +21,7 @@ $ ansible-doc -t module cisco.sccfm.delete_network_group OPTIONS (= indicates it is required): - api_token API token for SCCFM. - set_via: - env: - - name: SCCFM_API_TOKEN default: null - no_log: true type: str - name Name of the network group to delete. @@ -33,9 +29,6 @@ OPTIONS (= indicates it is required): type: str - region SCCFM region (int, us, eu, apj, au, uae, in, or ci). - set_via: - env: - - name: SCCFM_REGION default: null type: str @@ -51,7 +44,7 @@ NOTES: accidentally matching network objects with the same name. -AUTHOR: Cisco SCCFM Team +AUTHOR: huides00 (@huides00), Scoombe (@Scoombe), afercal (@afercal) EXAMPLES: # Example 1: Delete a network group by UID diff --git a/docs/ansible/modules/delete_network_object.md b/docs/ansible/modules/delete_network_object.md index 01ce9f6c..1ccb3f1f 100644 --- a/docs/ansible/modules/delete_network_object.md +++ b/docs/ansible/modules/delete_network_object.md @@ -19,11 +19,7 @@ $ ansible-doc -t module cisco.sccfm.delete_network_object OPTIONS (= indicates it is required): - api_token API token for SCCFM. - set_via: - env: - - name: SCCFM_API_TOKEN default: null - no_log: true type: str - name Name of the network object to delete. @@ -31,9 +27,6 @@ OPTIONS (= indicates it is required): type: str - region SCCFM region (int, us, eu, apj, au, uae, in, or ci). - set_via: - env: - - name: SCCFM_REGION default: null type: str @@ -46,7 +39,7 @@ NOTES: * When using `name', the module will search for the object and resolve it to a UID before deletion. -AUTHOR: Cisco SCCFM Team +AUTHOR: huides00 (@huides00), Scoombe (@Scoombe), afercal (@afercal) EXAMPLES: # Example 1: Delete a network object by UID diff --git a/docs/ansible/modules/delete_object_override.md b/docs/ansible/modules/delete_object_override.md index e5550dc6..6e7e817a 100644 --- a/docs/ansible/modules/delete_object_override.md +++ b/docs/ansible/modules/delete_object_override.md @@ -22,17 +22,10 @@ $ ansible-doc -t module cisco.sccfm.delete_object_override OPTIONS (= indicates it is required): - api_token API token for SCCFM. - set_via: - env: - - name: SCCFM_API_TOKEN default: null - no_log: true type: str - region SCCFM region (int, us, eu, apj, au, uae, in, or ci). - set_via: - env: - - name: SCCFM_REGION default: null type: str @@ -42,7 +35,7 @@ OPTIONS (= indicates it is required): = uid Unique identifier (UID) of the object. type: str -AUTHOR: Cisco SCCFM Team +AUTHOR: huides00 (@huides00), Scoombe (@Scoombe), afercal (@afercal) EXAMPLES: # Example 1: Delete an override for a specific device diff --git a/docs/ansible/modules/deploy_cdfmc_ftd.md b/docs/ansible/modules/deploy_cdfmc_ftd.md index 2489d85f..af910dae 100644 --- a/docs/ansible/modules/deploy_cdfmc_ftd.md +++ b/docs/ansible/modules/deploy_cdfmc_ftd.md @@ -21,11 +21,7 @@ $ ansible-doc -t module cisco.sccfm.deploy_cdfmc_ftd OPTIONS (= indicates it is required): - api_token API token for SCCFM. - set_via: - env: - - name: SCCFM_API_TOKEN default: null - no_log: true type: str - deployment_notes Notes for the deployment. @@ -59,9 +55,6 @@ OPTIONS (= indicates it is required): type: str - region SCCFM region (int, us, eu, apj, au, uae, in, or ci). - set_via: - env: - - name: SCCFM_REGION default: null type: str @@ -81,7 +74,7 @@ OPTIONS (= indicates it is required): default: false type: bool -AUTHOR: Cisco SCCFM Team +AUTHOR: huides00 (@huides00), Scoombe (@Scoombe), afercal (@afercal) EXAMPLES: # Example 1: Deploy changes to specific FTD devices diff --git a/docs/ansible/modules/edit_object_override.md b/docs/ansible/modules/edit_object_override.md index c8094ea0..76034a88 100644 --- a/docs/ansible/modules/edit_object_override.md +++ b/docs/ansible/modules/edit_object_override.md @@ -22,11 +22,7 @@ $ ansible-doc -t module cisco.sccfm.edit_object_override OPTIONS (= indicates it is required): - api_token API token for SCCFM. - set_via: - env: - - name: SCCFM_API_TOKEN default: null - no_log: true type: str = override_value The new value for the override. For network objects @@ -36,9 +32,6 @@ OPTIONS (= indicates it is required): type: str - region SCCFM region (int, us, eu, apj, au, uae, in, or ci). - set_via: - env: - - name: SCCFM_REGION default: null type: str @@ -48,7 +41,7 @@ OPTIONS (= indicates it is required): = uid Unique identifier (UID) of the object to edit. type: str -AUTHOR: Cisco SCCFM Team +AUTHOR: huides00 (@huides00), Scoombe (@Scoombe), afercal (@afercal) EXAMPLES: # Example 1: Edit an existing override for a specific device diff --git a/docs/ansible/modules/execute_asa_cli.md b/docs/ansible/modules/execute_asa_cli.md index 62c756c4..7b2e8580 100644 --- a/docs/ansible/modules/execute_asa_cli.md +++ b/docs/ansible/modules/execute_asa_cli.md @@ -25,11 +25,7 @@ $ ansible-doc -t module cisco.sccfm.execute_asa_cli OPTIONS (= indicates it is required): - api_token API token for SCCFM. - set_via: - env: - - name: SCCFM_API_TOKEN default: null - no_log: true type: str - command Single ASA CLI command to execute. @@ -62,9 +58,6 @@ OPTIONS (= indicates it is required): type: str - region SCCFM region (int, us, eu, apj, au, uae, in, or ci). - set_via: - env: - - name: SCCFM_REGION default: null type: str @@ -74,7 +67,7 @@ OPTIONS (= indicates it is required): elements: str type: list -AUTHOR: Cisco SCCFM Team +AUTHOR: huides00 (@huides00), Scoombe (@Scoombe), afercal (@afercal) EXAMPLES: # Example 1: Execute commands on devices matching a query diff --git a/docs/ansible/modules/execute_ftd_cli.md b/docs/ansible/modules/execute_ftd_cli.md index 43a24b3f..86ca49af 100644 --- a/docs/ansible/modules/execute_ftd_cli.md +++ b/docs/ansible/modules/execute_ftd_cli.md @@ -26,11 +26,7 @@ $ ansible-doc -t module cisco.sccfm.execute_ftd_cli OPTIONS (= indicates it is required): - api_token API token for SCCFM. - set_via: - env: - - name: SCCFM_API_TOKEN default: null - no_log: true type: str - command The show command to execute on the FTD devices. @@ -66,9 +62,6 @@ OPTIONS (= indicates it is required): type: str - region SCCFM region (int, us, eu, apj, au, uae, in, or ci). - set_via: - env: - - name: SCCFM_REGION default: null type: str @@ -78,7 +71,7 @@ OPTIONS (= indicates it is required): elements: str type: list -AUTHOR: Cisco SCCFM Team +AUTHOR: huides00 (@huides00), Scoombe (@Scoombe), afercal (@afercal) EXAMPLES: # Example 1: Execute a show command on devices matching a query diff --git a/docs/ansible/modules/get_access_group.md b/docs/ansible/modules/get_access_group.md index 7ae6cca1..90b739aa 100644 --- a/docs/ansible/modules/get_access_group.md +++ b/docs/ansible/modules/get_access_group.md @@ -18,24 +18,17 @@ $ ansible-doc -t module cisco.sccfm.get_access_group OPTIONS (= indicates it is required): - api_token API token for SCCFM. - set_via: - env: - - name: SCCFM_API_TOKEN default: null - no_log: true type: str - region SCCFM region (int, us, eu, apj, au, uae, in, or ci). - set_via: - env: - - name: SCCFM_REGION default: null type: str = uid Unique identifier (UID) of the access group to retrieve. type: str -AUTHOR: Cisco SCCFM Team +AUTHOR: huides00 (@huides00), Scoombe (@Scoombe), afercal (@afercal) EXAMPLES: # Get access group details by UID diff --git a/docs/ansible/modules/get_access_rule.md b/docs/ansible/modules/get_access_rule.md index 26909497..f673b9ab 100644 --- a/docs/ansible/modules/get_access_rule.md +++ b/docs/ansible/modules/get_access_rule.md @@ -18,24 +18,17 @@ $ ansible-doc -t module cisco.sccfm.get_access_rule OPTIONS (= indicates it is required): - api_token API token for SCCFM. - set_via: - env: - - name: SCCFM_API_TOKEN default: null - no_log: true type: str - region SCCFM region (int, us, eu, apj, au, uae, in, or ci). - set_via: - env: - - name: SCCFM_REGION default: null type: str = uid Unique identifier (UID) of the access rule to retrieve. type: str -AUTHOR: Cisco SCCFM Team +AUTHOR: huides00 (@huides00), Scoombe (@Scoombe), afercal (@afercal) EXAMPLES: # Example 1: Get access rule details by UID diff --git a/docs/ansible/modules/get_object.md b/docs/ansible/modules/get_object.md index 96ae6035..1ed6e010 100644 --- a/docs/ansible/modules/get_object.md +++ b/docs/ansible/modules/get_object.md @@ -20,24 +20,17 @@ $ ansible-doc -t module cisco.sccfm.get_object OPTIONS (= indicates it is required): - api_token API token for SCCFM. - set_via: - env: - - name: SCCFM_API_TOKEN default: null - no_log: true type: str - region SCCFM region (int, us, eu, apj, au, uae, in, or ci). - set_via: - env: - - name: SCCFM_REGION default: null type: str = uid Unique identifier (UID) of the object to retrieve. type: str -AUTHOR: Cisco SCCFM Team +AUTHOR: huides00 (@huides00), Scoombe (@Scoombe), afercal (@afercal) EXAMPLES: # Example 1: Get object details by UID diff --git a/docs/ansible/modules/list_access_groups.md b/docs/ansible/modules/list_access_groups.md index f2122920..ec48ddb4 100644 --- a/docs/ansible/modules/list_access_groups.md +++ b/docs/ansible/modules/list_access_groups.md @@ -20,11 +20,7 @@ $ ansible-doc -t module cisco.sccfm.list_access_groups OPTIONS (= indicates it is required): - api_token API token for SCCFM. - set_via: - env: - - name: SCCFM_API_TOKEN default: null - no_log: true type: str - limit Maximum number of results to return. @@ -40,13 +36,10 @@ OPTIONS (= indicates it is required): type: str - region SCCFM region (int, us, eu, apj, au, uae, in, or ci). - set_via: - env: - - name: SCCFM_REGION default: null type: str -AUTHOR: Cisco SCCFM Team +AUTHOR: huides00 (@huides00), Scoombe (@Scoombe), afercal (@afercal) EXAMPLES: # List all access groups diff --git a/docs/ansible/modules/list_access_rules.md b/docs/ansible/modules/list_access_rules.md index 090e2357..4be78591 100644 --- a/docs/ansible/modules/list_access_rules.md +++ b/docs/ansible/modules/list_access_rules.md @@ -20,11 +20,7 @@ $ ansible-doc -t module cisco.sccfm.list_access_rules OPTIONS (= indicates it is required): - api_token API token for SCCFM. - set_via: - env: - - name: SCCFM_API_TOKEN default: null - no_log: true type: str - limit Maximum number of results to return. @@ -40,13 +36,10 @@ OPTIONS (= indicates it is required): type: str - region SCCFM region (int, us, eu, apj, au, uae, in, or ci). - set_via: - env: - - name: SCCFM_REGION default: null type: str -AUTHOR: Cisco SCCFM Team +AUTHOR: huides00 (@huides00), Scoombe (@Scoombe), afercal (@afercal) EXAMPLES: # Example 1: List all access rules diff --git a/docs/ansible/modules/list_asa_boot_registry.md b/docs/ansible/modules/list_asa_boot_registry.md index 287a4e84..f2d9cea6 100644 --- a/docs/ansible/modules/list_asa_boot_registry.md +++ b/docs/ansible/modules/list_asa_boot_registry.md @@ -27,11 +27,7 @@ $ ansible-doc -t module cisco.sccfm.list_asa_boot_registry OPTIONS (= indicates it is required): - api_token API token for SCCFM. - set_via: - env: - - name: SCCFM_API_TOKEN default: null - no_log: true type: str - limit Maximum number of devices to return when using `query'. @@ -51,9 +47,6 @@ OPTIONS (= indicates it is required): type: str - region SCCFM region (int, us, eu, apj, au, uae, in, or ci). - set_via: - env: - - name: SCCFM_REGION default: null type: str @@ -63,7 +56,7 @@ OPTIONS (= indicates it is required): elements: str type: list -AUTHOR: Cisco SCCFM Team +AUTHOR: huides00 (@huides00), Scoombe (@Scoombe), afercal (@afercal) EXAMPLES: # Example 1: Get boot registry info for ASAs matching a query diff --git a/docs/ansible/modules/list_asa_compatible_versions.md b/docs/ansible/modules/list_asa_compatible_versions.md index 3d01fb00..9aaacaa0 100644 --- a/docs/ansible/modules/list_asa_compatible_versions.md +++ b/docs/ansible/modules/list_asa_compatible_versions.md @@ -27,11 +27,7 @@ $ ansible-doc -t module cisco.sccfm.list_asa_compatible_versions OPTIONS (= indicates it is required): - api_token API token for SCCFM. - set_via: - env: - - name: SCCFM_API_TOKEN default: null - no_log: true type: str - limit Maximum number of devices to return when using `query'. @@ -61,9 +57,6 @@ OPTIONS (= indicates it is required): type: str - region SCCFM region (int, us, eu, apj, au, uae, in, or ci). - set_via: - env: - - name: SCCFM_REGION default: null type: str @@ -73,7 +66,7 @@ OPTIONS (= indicates it is required): elements: str type: list -AUTHOR: Cisco SCCFM Team +AUTHOR: huides00 (@huides00), Scoombe (@Scoombe), afercal (@afercal) EXAMPLES: # Example 1: Get compatible versions for a single ASA (flat list output) diff --git a/docs/ansible/modules/list_asa_disk_files.md b/docs/ansible/modules/list_asa_disk_files.md index eb3050d7..d6265df4 100644 --- a/docs/ansible/modules/list_asa_disk_files.md +++ b/docs/ansible/modules/list_asa_disk_files.md @@ -27,11 +27,7 @@ $ ansible-doc -t module cisco.sccfm.list_asa_disk_files OPTIONS (= indicates it is required): - api_token API token for SCCFM. - set_via: - env: - - name: SCCFM_API_TOKEN default: null - no_log: true type: str - limit Maximum number of devices to return when using `query'. @@ -51,9 +47,6 @@ OPTIONS (= indicates it is required): type: str - region SCCFM region (int, us, eu, apj, au, uae, in, or ci). - set_via: - env: - - name: SCCFM_REGION default: null type: str @@ -63,7 +56,7 @@ OPTIONS (= indicates it is required): elements: str type: list -AUTHOR: Cisco SCCFM Team +AUTHOR: huides00 (@huides00), Scoombe (@Scoombe), afercal (@afercal) EXAMPLES: # Example 1: List files on all production ASAs matching a query diff --git a/docs/ansible/modules/list_asa_local_users.md b/docs/ansible/modules/list_asa_local_users.md index 8bf5c9b6..14948bf8 100644 --- a/docs/ansible/modules/list_asa_local_users.md +++ b/docs/ansible/modules/list_asa_local_users.md @@ -21,11 +21,7 @@ $ ansible-doc -t module cisco.sccfm.list_asa_local_users OPTIONS (= indicates it is required): - api_token API token for SCCFM - set_via: - env: - - name: SCCFM_API_TOKEN default: null - no_log: true type: str - limit Maximum number of devices to return when using `query'. @@ -42,9 +38,6 @@ OPTIONS (= indicates it is required): type: str - region SCCFM region (int, us, eu, apj, au, uae, in, or ci). - set_via: - env: - - name: SCCFM_REGION default: null type: str @@ -54,7 +47,7 @@ OPTIONS (= indicates it is required): elements: str type: list -AUTHOR: Cisco SCCFM Team +AUTHOR: huides00 (@huides00), Scoombe (@Scoombe), afercal (@afercal) EXAMPLES: - name: List local users on one or more devices by UID diff --git a/docs/ansible/modules/list_asa_not_on_version.md b/docs/ansible/modules/list_asa_not_on_version.md index 0d68cc3f..9cdcdc66 100644 --- a/docs/ansible/modules/list_asa_not_on_version.md +++ b/docs/ansible/modules/list_asa_not_on_version.md @@ -25,11 +25,7 @@ $ ansible-doc -t module cisco.sccfm.list_asa_not_on_version OPTIONS (= indicates it is required): - api_token API token for SCCFM. - set_via: - env: - - name: SCCFM_API_TOKEN default: null - no_log: true type: str - limit Maximum number of devices to fetch when using `query' or no @@ -51,9 +47,6 @@ OPTIONS (= indicates it is required): type: str - region SCCFM region (int, us, eu, apj, au, uae, in, or ci). - set_via: - env: - - name: SCCFM_REGION default: null type: str @@ -70,7 +63,7 @@ OPTIONS (= indicates it is required): Devices NOT running this exact version will be returned. type: str -AUTHOR: Cisco SCCFM Team +AUTHOR: huides00 (@huides00), Scoombe (@Scoombe), afercal (@afercal) EXAMPLES: # Example 1: List all ASAs not on a specific version diff --git a/docs/ansible/modules/list_cdfmc_access_policies.md b/docs/ansible/modules/list_cdfmc_access_policies.md index f602df7b..d216bb75 100644 --- a/docs/ansible/modules/list_cdfmc_access_policies.md +++ b/docs/ansible/modules/list_cdfmc_access_policies.md @@ -21,11 +21,7 @@ $ ansible-doc -t module cisco.sccfm.list_cdfmc_access_policies OPTIONS (= indicates it is required): - api_token API token for SCCFM. - set_via: - env: - - name: SCCFM_API_TOKEN default: null - no_log: true type: str = domain_uid The FMC domain UID to query. Obtain this from the @@ -42,13 +38,10 @@ OPTIONS (= indicates it is required): type: int - region SCCFM region (int, us, eu, apj, au, uae, in, or ci). - set_via: - env: - - name: SCCFM_REGION default: null type: str -AUTHOR: Cisco SCCFM Team +AUTHOR: huides00 (@huides00), Scoombe (@Scoombe), afercal (@afercal) EXAMPLES: # Example 1: List access policies for a domain diff --git a/docs/ansible/modules/list_ftd_compatible_versions.md b/docs/ansible/modules/list_ftd_compatible_versions.md index 9466fd73..dcbd6c9d 100644 --- a/docs/ansible/modules/list_ftd_compatible_versions.md +++ b/docs/ansible/modules/list_ftd_compatible_versions.md @@ -23,11 +23,7 @@ $ ansible-doc -t module cisco.sccfm.list_ftd_compatible_versions OPTIONS (= indicates it is required): - api_token API token for SCCFM. - set_via: - env: - - name: SCCFM_API_TOKEN default: null - no_log: true type: str - limit Maximum number of devices to return when using `query'. @@ -58,9 +54,6 @@ OPTIONS (= indicates it is required): type: str - region SCCFM region (int, us, eu, apj, au, uae, in, or ci). - set_via: - env: - - name: SCCFM_REGION default: null type: str @@ -70,7 +63,7 @@ OPTIONS (= indicates it is required): elements: str type: list -AUTHOR: Cisco SCCFM Team +AUTHOR: huides00 (@huides00), Scoombe (@Scoombe), afercal (@afercal) EXAMPLES: # Example 1: Get compatible versions for a single FTD (flat list output) diff --git a/docs/ansible/modules/list_ftd_not_on_version.md b/docs/ansible/modules/list_ftd_not_on_version.md index 69834adb..743568c5 100644 --- a/docs/ansible/modules/list_ftd_not_on_version.md +++ b/docs/ansible/modules/list_ftd_not_on_version.md @@ -29,11 +29,7 @@ $ ansible-doc -t module cisco.sccfm.list_ftd_not_on_version OPTIONS (= indicates it is required): - api_token API token for SCCFM. - set_via: - env: - - name: SCCFM_API_TOKEN default: null - no_log: true type: str - limit Maximum number of devices to fetch when using `query' or no @@ -63,9 +59,6 @@ OPTIONS (= indicates it is required): type: bool - region SCCFM region (int, us, eu, apj, au, uae, in, or ci). - set_via: - env: - - name: SCCFM_REGION default: null type: str @@ -84,7 +77,7 @@ OPTIONS (= indicates it is required): default: null type: str -AUTHOR: Cisco SCCFM Team +AUTHOR: huides00 (@huides00), Scoombe (@Scoombe), afercal (@afercal) EXAMPLES: # Example 1: List all FTDs not on a specific version diff --git a/docs/ansible/modules/list_managers.md b/docs/ansible/modules/list_managers.md index 5ecf2a00..e61147b8 100644 --- a/docs/ansible/modules/list_managers.md +++ b/docs/ansible/modules/list_managers.md @@ -21,11 +21,7 @@ $ ansible-doc -t module cisco.sccfm.list_managers OPTIONS (= indicates it is required): - api_token API token for SCCFM. - set_via: - env: - - name: SCCFM_API_TOKEN default: null - no_log: true type: str - limit Maximum number of results to return. @@ -42,13 +38,10 @@ OPTIONS (= indicates it is required): type: str - region SCCFM region (int, us, eu, apj, au, uae, in, or ci). - set_via: - env: - - name: SCCFM_REGION default: null type: str -AUTHOR: Cisco SCCFM Team +AUTHOR: huides00 (@huides00), Scoombe (@Scoombe), afercal (@afercal) EXAMPLES: # Example 1: List all managers diff --git a/docs/ansible/modules/list_network_groups.md b/docs/ansible/modules/list_network_groups.md index e7b041f9..8be047c7 100644 --- a/docs/ansible/modules/list_network_groups.md +++ b/docs/ansible/modules/list_network_groups.md @@ -22,11 +22,7 @@ $ ansible-doc -t module cisco.sccfm.list_network_groups OPTIONS (= indicates it is required): - api_token API token for SCCFM. - set_via: - env: - - name: SCCFM_API_TOKEN default: null - no_log: true type: str - limit Maximum number of results to return. @@ -44,13 +40,10 @@ OPTIONS (= indicates it is required): type: str - region SCCFM region (int, us, eu, apj, au, uae, in, or ci). - set_via: - env: - - name: SCCFM_REGION default: null type: str -AUTHOR: Cisco SCCFM Team +AUTHOR: huides00 (@huides00), Scoombe (@Scoombe), afercal (@afercal) EXAMPLES: # Example 1: List all network groups diff --git a/docs/ansible/modules/list_network_objects.md b/docs/ansible/modules/list_network_objects.md index ec7f05ba..d81dede9 100644 --- a/docs/ansible/modules/list_network_objects.md +++ b/docs/ansible/modules/list_network_objects.md @@ -22,11 +22,7 @@ $ ansible-doc -t module cisco.sccfm.list_network_objects OPTIONS (= indicates it is required): - api_token API token for SCCFM. - set_via: - env: - - name: SCCFM_API_TOKEN default: null - no_log: true type: str - limit Maximum number of results to return. @@ -44,13 +40,10 @@ OPTIONS (= indicates it is required): type: str - region SCCFM region (int, us, eu, apj, au, uae, in, or ci). - set_via: - env: - - name: SCCFM_REGION default: null type: str -AUTHOR: Cisco SCCFM Team +AUTHOR: huides00 (@huides00), Scoombe (@Scoombe), afercal (@afercal) EXAMPLES: # Example 1: List all network objects diff --git a/docs/ansible/modules/onboard_asa.md b/docs/ansible/modules/onboard_asa.md index a4fdb88c..455c70f0 100644 --- a/docs/ansible/modules/onboard_asa.md +++ b/docs/ansible/modules/onboard_asa.md @@ -18,11 +18,7 @@ $ ansible-doc -t module cisco.sccfm.onboard_asa OPTIONS (= indicates it is required): - api_token API token for SCCFM. - set_via: - env: - - name: SCCFM_API_TOKEN default: null - no_log: true type: str - connector_name Name of the Secure Device Connector (SDC) to use @@ -50,13 +46,9 @@ OPTIONS (= indicates it is required): type: str = password Password used to authenticate with the device. - no_log: true type: str - region SCCFM region (int, us, eu, apj, au, uae, in, or ci). - set_via: - env: - - name: SCCFM_REGION default: null type: str @@ -68,7 +60,7 @@ OPTIONS (= indicates it is required): = username Username used to authenticate with the device. type: str -AUTHOR: Cisco SCCFM Team +AUTHOR: huides00 (@huides00), Scoombe (@Scoombe), afercal (@afercal) EXAMPLES: # Example 1: Using module_defaults (recommended) diff --git a/docs/ansible/modules/onboard_cdfmc_ftd.md b/docs/ansible/modules/onboard_cdfmc_ftd.md index 7ee84b04..80424191 100644 --- a/docs/ansible/modules/onboard_cdfmc_ftd.md +++ b/docs/ansible/modules/onboard_cdfmc_ftd.md @@ -22,11 +22,7 @@ $ ansible-doc -t module cisco.sccfm.onboard_cdfmc_ftd OPTIONS (= indicates it is required): - api_token API token for SCCFM. - set_via: - env: - - name: SCCFM_API_TOKEN default: null - no_log: true type: str = fmc_access_policy_uid UUID of the FMC access policy to apply to @@ -53,9 +49,6 @@ OPTIONS (= indicates it is required): type: str - region SCCFM region (int, us, eu, apj, au, uae, in, or ci). - set_via: - env: - - name: SCCFM_REGION default: null type: str @@ -69,7 +62,7 @@ OPTIONS (= indicates it is required): default: false type: bool -AUTHOR: Cisco SCCFM Team +AUTHOR: huides00 (@huides00), Scoombe (@Scoombe), afercal (@afercal) EXAMPLES: # Example 1: Onboard a physical cdFMC-managed FTD diff --git a/docs/ansible/modules/onboard_cdfmc_ftd_ztp.md b/docs/ansible/modules/onboard_cdfmc_ftd_ztp.md index 3987b77f..982b4830 100644 --- a/docs/ansible/modules/onboard_cdfmc_ftd_ztp.md +++ b/docs/ansible/modules/onboard_cdfmc_ftd_ztp.md @@ -34,15 +34,10 @@ OPTIONS (= indicates it is required): Required if a password has not already been set on the device. default: null - no_log: true type: str - api_token API token for SCCFM. - set_via: - env: - - name: SCCFM_API_TOKEN default: null - no_log: true type: str - device_group_uid UUID of the device group the device will join @@ -64,16 +59,13 @@ OPTIONS (= indicates it is required): type: str - region SCCFM region (int, us, eu, apj, au, uae, in, or ci). - set_via: - env: - - name: SCCFM_REGION default: null type: str = serial_number Serial number of the physical FTD device. type: str -AUTHOR: Cisco SCCFM Team +AUTHOR: huides00 (@huides00), Scoombe (@Scoombe), afercal (@afercal) EXAMPLES: # Example 1: Onboard a physical FTD via ZTP diff --git a/docs/ansible/modules/register_cdfmc_ftd.md b/docs/ansible/modules/register_cdfmc_ftd.md index ade06e3a..b81e447b 100644 --- a/docs/ansible/modules/register_cdfmc_ftd.md +++ b/docs/ansible/modules/register_cdfmc_ftd.md @@ -21,11 +21,7 @@ $ ansible-doc -t module cisco.sccfm.register_cdfmc_ftd OPTIONS (= indicates it is required): - api_token The SCC Firewall Manager API token. - set_via: - env: - - name: SCCFM_API_TOKEN default: null - no_log: true type: str = ftd_uid The UID of the FTD device in SCC Firewall Manager to @@ -33,9 +29,6 @@ OPTIONS (= indicates it is required): type: str - region The SCC Firewall Manager region. - set_via: - env: - - name: SCCFM_REGION default: null type: str @@ -45,7 +38,7 @@ OPTIONS (= indicates it is required): default: false type: bool -AUTHOR: Cisco SCCFM Team +AUTHOR: huides00 (@huides00), Scoombe (@Scoombe), afercal (@afercal) EXAMPLES: - name: Register vFTD with cdFMC using shared authentication defaults diff --git a/docs/ansible/modules/remove_asa_shun.md b/docs/ansible/modules/remove_asa_shun.md index 3d795bfd..d286c91a 100644 --- a/docs/ansible/modules/remove_asa_shun.md +++ b/docs/ansible/modules/remove_asa_shun.md @@ -28,11 +28,7 @@ $ ansible-doc -t module cisco.sccfm.remove_asa_shun OPTIONS (= indicates it is required): - api_token API token for SCCFM. - set_via: - env: - - name: SCCFM_API_TOKEN default: null - no_log: true type: str - limit Maximum number of devices to return when using `query'. @@ -52,9 +48,6 @@ OPTIONS (= indicates it is required): type: str - region SCCFM region (int, us, eu, apj, au, uae, in, or ci). - set_via: - env: - - name: SCCFM_REGION default: null type: str @@ -77,7 +70,7 @@ OPTIONS (= indicates it is required): elements: str type: list -AUTHOR: Cisco SCCFM Team +AUTHOR: huides00 (@huides00), Scoombe (@Scoombe), afercal (@afercal) EXAMPLES: # Example 1: Remove a single shun on devices matching a query diff --git a/docs/ansible/modules/remove_network_group_members.md b/docs/ansible/modules/remove_network_group_members.md index 6df409ea..84ec0e70 100644 --- a/docs/ansible/modules/remove_network_group_members.md +++ b/docs/ansible/modules/remove_network_group_members.md @@ -25,11 +25,7 @@ $ ansible-doc -t module cisco.sccfm.remove_network_group_members OPTIONS (= indicates it is required): - api_token API token for SCCFM. - set_via: - env: - - name: SCCFM_API_TOKEN default: null - no_log: true type: str - name Name of the network group to update. @@ -43,9 +39,6 @@ OPTIONS (= indicates it is required): type: list - region SCCFM region (int, us, eu, apj, au, uae, in, or ci). - set_via: - env: - - name: SCCFM_REGION default: null type: str @@ -53,7 +46,7 @@ OPTIONS (= indicates it is required): default: null type: str -AUTHOR: Cisco SCCFM Team +AUTHOR: huides00 (@huides00), Scoombe (@Scoombe), afercal (@afercal) EXAMPLES: # Example 1: Remove members by name diff --git a/docs/ansible/modules/show_asa_shun.md b/docs/ansible/modules/show_asa_shun.md index 23eef5da..68a5202a 100644 --- a/docs/ansible/modules/show_asa_shun.md +++ b/docs/ansible/modules/show_asa_shun.md @@ -28,11 +28,7 @@ $ ansible-doc -t module cisco.sccfm.show_asa_shun OPTIONS (= indicates it is required): - api_token API token for SCCFM. - set_via: - env: - - name: SCCFM_API_TOKEN default: null - no_log: true type: str - limit Maximum number of devices to return when using `query'. @@ -52,9 +48,6 @@ OPTIONS (= indicates it is required): type: str - region SCCFM region (int, us, eu, apj, au, uae, in, or ci). - set_via: - env: - - name: SCCFM_REGION default: null type: str @@ -69,7 +62,7 @@ OPTIONS (= indicates it is required): elements: str type: list -AUTHOR: Cisco SCCFM Team +AUTHOR: huides00 (@huides00), Scoombe (@Scoombe), afercal (@afercal) EXAMPLES: # Example 1: Show shun entries on devices matching a query diff --git a/docs/ansible/modules/trigger_asa_upgrade.md b/docs/ansible/modules/trigger_asa_upgrade.md index bef1ee58..9342366f 100644 --- a/docs/ansible/modules/trigger_asa_upgrade.md +++ b/docs/ansible/modules/trigger_asa_upgrade.md @@ -26,11 +26,7 @@ $ ansible-doc -t module cisco.sccfm.trigger_asa_upgrade OPTIONS (= indicates it is required): - api_token API token for SCCFM. - set_via: - env: - - name: SCCFM_API_TOKEN default: null - no_log: true type: str - asdm_version Target ASDM version (e.g. `7.18(1.152')). @@ -66,9 +62,6 @@ OPTIONS (= indicates it is required): type: str - region SCCFM region (int, us, eu, apj, au, uae, in, or ci). - set_via: - env: - - name: SCCFM_REGION default: null type: str @@ -106,7 +99,7 @@ OPTIONS (= indicates it is required): default: false type: bool -AUTHOR: Cisco SCCFM Team +AUTHOR: huides00 (@huides00), Scoombe (@Scoombe), afercal (@afercal) EXAMPLES: # Example 1: Upgrade software and ASDM on specific devices diff --git a/docs/ansible/modules/trigger_ftd_upgrade.md b/docs/ansible/modules/trigger_ftd_upgrade.md index ccc76e88..40ad1aa4 100644 --- a/docs/ansible/modules/trigger_ftd_upgrade.md +++ b/docs/ansible/modules/trigger_ftd_upgrade.md @@ -26,11 +26,7 @@ $ ansible-doc -t module cisco.sccfm.trigger_ftd_upgrade OPTIONS (= indicates it is required): - api_token API token for SCCFM. - set_via: - env: - - name: SCCFM_API_TOKEN default: null - no_log: true type: str - ignore_maintenance_window Allow upgrade outside the device @@ -56,9 +52,6 @@ OPTIONS (= indicates it is required): type: str - region SCCFM region (int, us, eu, apj, au, uae, in, or ci). - set_via: - env: - - name: SCCFM_REGION default: null type: str @@ -96,7 +89,7 @@ OPTIONS (= indicates it is required): default: false type: bool -AUTHOR: Cisco SCCFM Team +AUTHOR: huides00 (@huides00), Scoombe (@Scoombe), afercal (@afercal) EXAMPLES: # Example 1: Upgrade specific FTD devices diff --git a/docs/ansible/modules/update_access_rule.md b/docs/ansible/modules/update_access_rule.md index 21cec0be..5e17aa57 100644 --- a/docs/ansible/modules/update_access_rule.md +++ b/docs/ansible/modules/update_access_rule.md @@ -25,11 +25,7 @@ OPTIONS (= indicates it is required): type: bool - api_token API token for SCCFM. - set_via: - env: - - name: SCCFM_API_TOKEN default: null - no_log: true type: str - destination_network Destination network object name. @@ -57,9 +53,6 @@ OPTIONS (= indicates it is required): type: str - region SCCFM region (int, us, eu, apj, au, uae, in, or ci). - set_via: - env: - - name: SCCFM_REGION default: null type: str @@ -83,7 +76,7 @@ OPTIONS (= indicates it is required): = uid Unique identifier (UID) of the access rule to update. type: str -AUTHOR: Cisco SCCFM Team +AUTHOR: huides00 (@huides00), Scoombe (@Scoombe), afercal (@afercal) EXAMPLES: # Example 1: Update a rule's action diff --git a/docs/ansible/modules/update_network_group.md b/docs/ansible/modules/update_network_group.md index 7c7e9288..8af937b6 100644 --- a/docs/ansible/modules/update_network_group.md +++ b/docs/ansible/modules/update_network_group.md @@ -26,11 +26,7 @@ $ ansible-doc -t module cisco.sccfm.update_network_group OPTIONS (= indicates it is required): - api_token API token for SCCFM. - set_via: - env: - - name: SCCFM_API_TOKEN default: null - no_log: true type: str - description New description for the network group. @@ -60,9 +56,6 @@ OPTIONS (= indicates it is required): type: list - region SCCFM region (int, us, eu, apj, au, uae, in, or ci). - set_via: - env: - - name: SCCFM_REGION default: null type: str @@ -75,7 +68,7 @@ OPTIONS (= indicates it is required): default: null type: str -AUTHOR: Cisco SCCFM Team +AUTHOR: huides00 (@huides00), Scoombe (@Scoombe), afercal (@afercal) EXAMPLES: # Example 1: Update referenced objects by name diff --git a/docs/ansible/modules/update_network_object.md b/docs/ansible/modules/update_network_object.md index 05a07d5a..fcd49b09 100644 --- a/docs/ansible/modules/update_network_object.md +++ b/docs/ansible/modules/update_network_object.md @@ -24,11 +24,7 @@ $ ansible-doc -t module cisco.sccfm.update_network_object OPTIONS (= indicates it is required): - api_token API token for SCCFM. - set_via: - env: - - name: SCCFM_API_TOKEN default: null - no_log: true type: str - description New description for the network object. @@ -50,9 +46,6 @@ OPTIONS (= indicates it is required): type: str - region SCCFM region (int, us, eu, apj, au, uae, in, or ci). - set_via: - env: - - name: SCCFM_REGION default: null type: str @@ -72,7 +65,7 @@ OPTIONS (= indicates it is required): default: null type: str -AUTHOR: Cisco SCCFM Team +AUTHOR: huides00 (@huides00), Scoombe (@Scoombe), afercal (@afercal) EXAMPLES: # Example 1: Update a network object's value by UID diff --git a/docs/ansible/modules/update_object_default.md b/docs/ansible/modules/update_object_default.md index 55b4e69f..6282c0df 100644 --- a/docs/ansible/modules/update_object_default.md +++ b/docs/ansible/modules/update_object_default.md @@ -22,17 +22,10 @@ $ ansible-doc -t module cisco.sccfm.update_object_default OPTIONS (= indicates it is required): - api_token API token for SCCFM. - set_via: - env: - - name: SCCFM_API_TOKEN default: null - no_log: true type: str - region SCCFM region (int, us, eu, apj, au, uae, in, or ci). - set_via: - env: - - name: SCCFM_REGION default: null type: str @@ -45,7 +38,7 @@ OPTIONS (= indicates it is required): should be the URL string. type: str -AUTHOR: Cisco SCCFM Team +AUTHOR: huides00 (@huides00), Scoombe (@Scoombe), afercal (@afercal) EXAMPLES: # Example 1: Update the default value of a network object diff --git a/sccfm-ansible/CHANGELOG.rst b/sccfm-ansible/CHANGELOG.rst new file mode 100644 index 00000000..428df9d2 --- /dev/null +++ b/sccfm-ansible/CHANGELOG.rst @@ -0,0 +1,13 @@ +==================================== +Cisco SCCFM Collection Release Notes +==================================== + +.. contents:: Topics + +v0.38.0 +======= + +Release Summary +--------------- + +Initial development release of the cisco.sccfm collection, with dynamic inventory and modules for automating Cisco Security Cloud Control Firewall Manager. diff --git a/sccfm-ansible/changelogs/changelog.yaml b/sccfm-ansible/changelogs/changelog.yaml new file mode 100644 index 00000000..0b4e2ebd --- /dev/null +++ b/sccfm-ansible/changelogs/changelog.yaml @@ -0,0 +1,11 @@ +--- +ancestor: null +releases: + 0.38.0: + changes: + release_summary: Initial development release of the cisco.sccfm collection, + with dynamic inventory and modules for automating Cisco Security Cloud Control + Firewall Manager. + fragments: + - 0.38.0.yml + release_date: '2026-07-27' diff --git a/sccfm-ansible/changelogs/config.yaml b/sccfm-ansible/changelogs/config.yaml new file mode 100644 index 00000000..37d6dd8c --- /dev/null +++ b/sccfm-ansible/changelogs/config.yaml @@ -0,0 +1,42 @@ +--- +# Copyright 2026 Cisco Systems, Inc. and its affiliates +# +# SPDX-License-Identifier: Apache-2.0 + +add_plugin_period: true +changelog_nice_yaml: true +changelog_sort: version +changes_file: changelog.yaml +changes_format: combined +ignore_other_fragment_extensions: true +keep_fragments: false +mention_ancestor: true +new_plugins_after_name: removed_features +notesdir: fragments +output: + - file: CHANGELOG.rst + format: rst +prelude_section_name: release_summary +prelude_section_title: Release Summary +sanitize_changelog: true +sections: + - - major_changes + - Major Changes + - - minor_changes + - Minor Changes + - - breaking_changes + - Breaking Changes / Porting Guide + - - deprecated_features + - Deprecated Features + - - removed_features + - Removed Features (previously deprecated) + - - security_fixes + - Security Fixes + - - bugfixes + - Bugfixes + - - known_issues + - Known Issues +title: Cisco SCCFM Collection +trivial_section_name: trivial +use_fqcn: true +vcs: auto diff --git a/sccfm-ansible/galaxy.yml b/sccfm-ansible/galaxy.yml index eb19c760..490767ed 100644 --- a/sccfm-ansible/galaxy.yml +++ b/sccfm-ansible/galaxy.yml @@ -4,9 +4,8 @@ version: 0.38.0 readme: README.md authors: - Cisco Security Cloud Control Firewall Manager Team -description: Ansible inventory plugin for Cisco SCC Firewall Manager (SCCFM). -license: -- Apache-2.0 +description: Ansible modules and dynamic inventory for Cisco Security Cloud Control + Firewall Manager. license_file: LICENSE tags: - cisco @@ -29,6 +28,7 @@ build_ignore: - ansible_collections - plugins/modules/tests - plugins/modules/tests/** +- changelogs/.plugin-cache.yaml - .DS_Store - '**/.DS_Store' - .gitignore diff --git a/sccfm-ansible/meta/runtime.yml b/sccfm-ansible/meta/runtime.yml index da7dae8f..e194c82c 100644 --- a/sccfm-ansible/meta/runtime.yml +++ b/sccfm-ansible/meta/runtime.yml @@ -51,8 +51,3 @@ action_groups: - update_network_group - update_network_object - update_object_default - -module_defaults: - group/cisco.sccfm.all: - region: null - api_token: null diff --git a/sccfm-ansible/plugins/inventory/sccfm.py b/sccfm-ansible/plugins/inventory/sccfm.py index 3d1a2bf1..f7ec15fa 100644 --- a/sccfm-ansible/plugins/inventory/sccfm.py +++ b/sccfm-ansible/plugins/inventory/sccfm.py @@ -1,24 +1,12 @@ # Copyright 2026 Cisco Systems, Inc. and its affiliates # # SPDX-License-Identifier: Apache-2.0 +# flake8: noqa: E402 from __future__ import annotations -import os -from typing import Any, Dict, List, Optional, cast - -from ansible.errors import AnsibleParserError -from ansible.plugins.inventory import BaseInventoryPlugin -from ansible.utils.display import Display -from scc_firewall_manager_sdk import Device - -from ..module_utils.builders import InventoryHostBuilder -from ..module_utils.config import Config -from ..module_utils.loaders import InventoryLoader - DOCUMENTATION = r""" -name: cisco.sccfm.sccfm -plugin_type: inventory +name: sccfm short_description: Load devices in SCC Firewall Manager as inventory hosts. description: - Uses Cisco Security Cloud Control Firewall Manager (SCCFM) to enumerate @@ -28,7 +16,7 @@ to groups or hosts. options: plugin: - description: Ensure this plugin gets loaded. + description: Token that ensures this is a source file for the C(cisco.sccfm.sccfm) plugin. required: true choices: ["cisco.sccfm.sccfm"] region: @@ -43,7 +31,6 @@ - name: SCCFM_API_TOKEN required: true type: str - no_log: true limit: description: Page size to use when fetching devices. required: false @@ -75,6 +62,20 @@ group_by_device_type: true """ +import os +from typing import TYPE_CHECKING, Any, Dict, List, Optional, cast + +from ansible.errors import AnsibleParserError +from ansible.plugins.inventory import BaseInventoryPlugin +from ansible.utils.display import Display + +from ..module_utils.config import Config +from ..plugin_utils.inventory_host_builder import InventoryHostBuilder +from ..plugin_utils.inventory_loader import InventoryLoader + +if TYPE_CHECKING: + from scc_firewall_manager_sdk import Device + class InventoryModule(BaseInventoryPlugin): NAME = "cisco.sccfm.sccfm" diff --git a/sccfm-ansible/plugins/module_utils/config.py b/sccfm-ansible/plugins/module_utils/config.py index 69ea3a16..e412cfb7 100644 --- a/sccfm-ansible/plugins/module_utils/config.py +++ b/sccfm-ansible/plugins/module_utils/config.py @@ -8,15 +8,25 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, Any -from cisco_sccfm_core.constants import SCCFM_REGIONS, normalize_sccfm_region - if TYPE_CHECKING: from ansible.module_utils.basic import AnsibleModule -ALLOWED_REGIONS = SCCFM_REGIONS +from .dependencies import ensure_required_dependencies + +ALLOWED_REGIONS = ("int", "us", "eu", "apj", "au", "uae", "in", "ci") +REGION_ALIASES = {"aus": "au"} ALLOWED_REGIONS_TEXT = ", ".join(ALLOWED_REGIONS) +def _normalize_region(region: str | None) -> str | None: + """Normalize a region without importing the separately installed core package.""" + if region is None: + return None + + normalized = region.strip().lower() + return REGION_ALIASES.get(normalized, normalized) + + @dataclass(frozen=True) class Config: """SCCFM API configuration. @@ -30,7 +40,7 @@ class Config: def __post_init__(self) -> None: # Resolve from environment if not provided - resolved_region = normalize_sccfm_region(self.region or os.getenv("SCCFM_REGION")) + resolved_region = _normalize_region(self.region or os.getenv("SCCFM_REGION")) resolved_token = self.api_token or os.getenv("SCCFM_API_TOKEN") # Use object.__setattr__ since dataclass is frozen @@ -91,6 +101,8 @@ def create_config(module: "AnsibleModule") -> Config: Note: On validation error, calls module.fail_json() and does not return. """ + ensure_required_dependencies(module) + try: return Config( region=module.params.get("region") or "", diff --git a/sccfm-ansible/plugins/module_utils/dependencies.py b/sccfm-ansible/plugins/module_utils/dependencies.py new file mode 100644 index 00000000..4951c180 --- /dev/null +++ b/sccfm-ansible/plugins/module_utils/dependencies.py @@ -0,0 +1,35 @@ +# Copyright 2026 Cisco Systems, Inc. and its affiliates +# +# SPDX-License-Identifier: Apache-2.0 + +"""Report optional runtime dependency imports after module argument parsing.""" + +from __future__ import annotations + +import traceback +from typing import TYPE_CHECKING + +from ansible.module_utils.basic import missing_required_lib + +if TYPE_CHECKING: + from ansible.module_utils.basic import AnsibleModule + +_IMPORT_ERRORS: list[tuple[str, str]] = [] + + +def record_import_error(error: ImportError) -> None: + """Record an import failure without preventing Ansible from inspecting a module.""" + library = (error.name or "cisco-sccfm-devkit").split(".", maxsplit=1)[0] + _IMPORT_ERRORS.append((library, traceback.format_exc())) + + +def ensure_required_dependencies(module: "AnsibleModule") -> None: + """Fail with Ansible's actionable dependency message when an import failed.""" + if not _IMPORT_ERRORS: + return + + library, import_traceback = _IMPORT_ERRORS[0] + module.fail_json( + msg=missing_required_lib(library), + exception=import_traceback, + ) diff --git a/sccfm-ansible/plugins/module_utils/loaders/__init__.py b/sccfm-ansible/plugins/module_utils/loaders/__init__.py deleted file mode 100644 index 4e4f22fb..00000000 --- a/sccfm-ansible/plugins/module_utils/loaders/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -# Copyright 2026 Cisco Systems, Inc. and its affiliates -# -# SPDX-License-Identifier: Apache-2.0 - -"""Services used by the cisco.sccfm collection.""" - -from .inventory_loader import InventoryLoader - -__all__ = ["InventoryLoader"] diff --git a/sccfm-ansible/plugins/module_utils/loaders/inventory_loader.py b/sccfm-ansible/plugins/module_utils/loaders/inventory_loader.py deleted file mode 100644 index cb86bfe5..00000000 --- a/sccfm-ansible/plugins/module_utils/loaders/inventory_loader.py +++ /dev/null @@ -1,51 +0,0 @@ -# Copyright 2026 Cisco Systems, Inc. and its affiliates -# -# SPDX-License-Identifier: Apache-2.0 - -from __future__ import annotations - -from typing import List, Optional - -from ansible.errors import AnsibleParserError -from scc_firewall_manager_sdk import ApiException, Device, DevicePage - -from cisco_sccfm_core import SccApiError -from cisco_sccfm_core.services import InventoryService -from cisco_sccfm_core.types import ConfigLike - - -class InventoryLoader: - def __init__(self, *, config: ConfigLike, limit: int, query: Optional[str]) -> None: - self._config = config - self._limit = limit - self._query = query - self._inventory_service = InventoryService(config) - - def load_devices(self) -> List[Device]: - try: - return self._fetch_all_pages() - except ApiException as exc: - error = SccApiError.from_exception(exc) - raise AnsibleParserError(f"Failed to load SCCFM devices: {error}") from exc - except Exception as exc: # noqa: BLE001 - raise AnsibleParserError(f"Failed to load SCCFM devices: {exc}") from exc - - def _fetch_all_pages(self) -> List[Device]: - devices: List[Device] = [] - offset = 0 - - while True: - page: DevicePage = self._inventory_service.get_devices( - limit=self._limit, - offset=offset, - query=self._query, - ) - page_items = list(page.items or []) - devices.extend(page_items) - - offset += len(page_items) - total_count = page.count or 0 - if not page_items or offset >= total_count: - break - - return devices diff --git a/sccfm-ansible/plugins/module_utils/operations.py b/sccfm-ansible/plugins/module_utils/operations.py index a4e44ed1..2cc5641a 100644 --- a/sccfm-ansible/plugins/module_utils/operations.py +++ b/sccfm-ansible/plugins/module_utils/operations.py @@ -8,9 +8,14 @@ from typing import TYPE_CHECKING, Any, Callable, Protocol, TypeVar -from scc_firewall_manager_sdk import ApiException +from .dependencies import record_import_error -from cisco_sccfm_core.errors import NotFoundError, SccApiError +try: + from scc_firewall_manager_sdk import ApiException + + from cisco_sccfm_core.errors import NotFoundError, SccApiError +except ImportError as exc: + record_import_error(exc) if TYPE_CHECKING: from ansible.module_utils.basic import AnsibleModule diff --git a/sccfm-ansible/plugins/modules/__init__.py b/sccfm-ansible/plugins/modules/__init__.py index 6ed0f466..e69de29b 100644 --- a/sccfm-ansible/plugins/modules/__init__.py +++ b/sccfm-ansible/plugins/modules/__init__.py @@ -1,3 +0,0 @@ -# Copyright 2026 Cisco Systems, Inc. and its affiliates -# -# SPDX-License-Identifier: Apache-2.0 diff --git a/sccfm-ansible/plugins/modules/add_asa_shun.py b/sccfm-ansible/plugins/modules/add_asa_shun.py index c9ce85af..77dd0ee8 100644 --- a/sccfm-ansible/plugins/modules/add_asa_shun.py +++ b/sccfm-ansible/plugins/modules/add_asa_shun.py @@ -1,19 +1,11 @@ # Copyright 2026 Cisco Systems, Inc. and its affiliates # # SPDX-License-Identifier: Apache-2.0 +# flake8: noqa: E402 +# isort: skip_file from __future__ import annotations -from typing import Any, cast - -from ansible.module_utils.basic import AnsibleModule -from scc_firewall_manager_sdk import ApiException, CdoCliResult, CdoTransaction, DevicePage - -from cisco_sccfm_core import ASA_DEVICE_TYPE_FILTER, AsaShunService, InventoryService, SccApiError -from cisco_sccfm_core.services.inventory.asa_shun_service import ShunEntrySpec - -from ..module_utils.config import base_argument_spec, create_config - DOCUMENTATION = r""" --- module: add_asa_shun @@ -134,17 +126,14 @@ description: SCCFM region (int, us, eu, apj, au, uae, in, or ci). required: false type: str - env: - - name: SCCFM_REGION api_token: description: API token for SCCFM. required: false type: str - no_log: true - env: - - name: SCCFM_API_TOKEN author: - - Cisco SCCFM Team + - huides00 (@huides00) + - Scoombe (@Scoombe) + - afercal (@afercal) """ EXAMPLES = r""" @@ -219,6 +208,27 @@ """ +from typing import Any, cast + +from ansible.module_utils.basic import AnsibleModule + +from ..module_utils.config import base_argument_spec, create_config +from ..module_utils.dependencies import record_import_error + +try: + from scc_firewall_manager_sdk import ApiException, CdoCliResult, CdoTransaction, DevicePage + + from cisco_sccfm_core import ( + ASA_DEVICE_TYPE_FILTER, + AsaShunService, + InventoryService, + SccApiError, + ) + from cisco_sccfm_core.services.inventory.asa_shun_service import ShunEntrySpec +except ImportError as exc: + record_import_error(exc) + + def build_argument_spec() -> dict[str, dict[str, Any]]: return { **base_argument_spec(), diff --git a/sccfm-ansible/plugins/modules/add_network_group_members.py b/sccfm-ansible/plugins/modules/add_network_group_members.py index 36d7979a..e203ef44 100644 --- a/sccfm-ansible/plugins/modules/add_network_group_members.py +++ b/sccfm-ansible/plugins/modules/add_network_group_members.py @@ -1,27 +1,11 @@ # Copyright 2026 Cisco Systems, Inc. and its affiliates # # SPDX-License-Identifier: Apache-2.0 +# flake8: noqa: E402 +# isort: skip_file from __future__ import annotations -from typing import Any - -from ansible.module_utils.basic import AnsibleModule -from scc_firewall_manager_sdk import ApiException - -from cisco_sccfm_core.errors import NotFoundError, SccApiError -from cisco_sccfm_core.services.object_management import ( - NetworkGroupMemberMutationResult, - NetworkGroupService, -) - -from ..module_utils.config import ( - Config, - base_argument_spec, - create_config, - identifier_argument_spec, -) - DOCUMENTATION = r""" --- module: add_network_group_members @@ -53,17 +37,14 @@ description: SCCFM region (int, us, eu, apj, au, uae, in, or ci). required: false type: str - env: - - name: SCCFM_REGION api_token: description: API token for SCCFM. required: false type: str - no_log: true - env: - - name: SCCFM_API_TOKEN author: - - Cisco SCCFM Team + - huides00 (@huides00) + - Scoombe (@Scoombe) + - afercal (@afercal) """ EXAMPLES = r""" @@ -135,6 +116,30 @@ """ +from typing import Any + +from ansible.module_utils.basic import AnsibleModule + +from ..module_utils.config import ( + Config, + base_argument_spec, + create_config, + identifier_argument_spec, +) +from ..module_utils.dependencies import record_import_error + +try: + from scc_firewall_manager_sdk import ApiException + + from cisco_sccfm_core.errors import NotFoundError, SccApiError + from cisco_sccfm_core.services.object_management import ( + NetworkGroupMemberMutationResult, + NetworkGroupService, + ) +except ImportError as exc: + record_import_error(exc) + + def build_argument_spec() -> dict[str, dict[str, Any]]: return { **identifier_argument_spec(), diff --git a/sccfm-ansible/plugins/modules/add_object_override.py b/sccfm-ansible/plugins/modules/add_object_override.py index 5e7b007f..9f52c7f5 100644 --- a/sccfm-ansible/plugins/modules/add_object_override.py +++ b/sccfm-ansible/plugins/modules/add_object_override.py @@ -1,19 +1,11 @@ # Copyright 2026 Cisco Systems, Inc. and its affiliates # # SPDX-License-Identifier: Apache-2.0 +# flake8: noqa: E402 +# isort: skip_file from __future__ import annotations -from typing import Any - -from ansible.module_utils.basic import AnsibleModule -from scc_firewall_manager_sdk import ApiException - -from cisco_sccfm_core.errors import SccApiError -from cisco_sccfm_core.services.object_management import ObjectOverrideService - -from ..module_utils.config import Config, base_argument_spec, create_config - DOCUMENTATION = r""" --- module: add_object_override @@ -45,17 +37,14 @@ description: SCCFM region (int, us, eu, apj, au, uae, in, or ci). required: false type: str - env: - - name: SCCFM_REGION api_token: description: API token for SCCFM. required: false type: str - no_log: true - env: - - name: SCCFM_API_TOKEN author: - - Cisco SCCFM Team + - huides00 (@huides00) + - Scoombe (@Scoombe) + - afercal (@afercal) """ EXAMPLES = r""" @@ -120,6 +109,22 @@ """ +from typing import Any + +from ansible.module_utils.basic import AnsibleModule + +from ..module_utils.config import Config, base_argument_spec, create_config +from ..module_utils.dependencies import record_import_error + +try: + from scc_firewall_manager_sdk import ApiException + + from cisco_sccfm_core.errors import SccApiError + from cisco_sccfm_core.services.object_management import ObjectOverrideService +except ImportError as exc: + record_import_error(exc) + + def build_argument_spec() -> dict[str, dict[str, Any]]: return { "uid": {"type": "str", "required": True}, diff --git a/sccfm-ansible/plugins/modules/apply_object_override_as_default.py b/sccfm-ansible/plugins/modules/apply_object_override_as_default.py index 47a13f6e..016304e2 100644 --- a/sccfm-ansible/plugins/modules/apply_object_override_as_default.py +++ b/sccfm-ansible/plugins/modules/apply_object_override_as_default.py @@ -1,19 +1,11 @@ # Copyright 2026 Cisco Systems, Inc. and its affiliates # # SPDX-License-Identifier: Apache-2.0 +# flake8: noqa: E402 +# isort: skip_file from __future__ import annotations -from typing import Any - -from ansible.module_utils.basic import AnsibleModule -from scc_firewall_manager_sdk import ApiException - -from cisco_sccfm_core.errors import SccApiError -from cisco_sccfm_core.services.object_management import ObjectOverrideService - -from ..module_utils.config import Config, base_argument_spec, create_config - DOCUMENTATION = r""" --- module: apply_object_override_as_default @@ -37,17 +29,14 @@ description: SCCFM region (int, us, eu, apj, au, uae, in, or ci). required: false type: str - env: - - name: SCCFM_REGION api_token: description: API token for SCCFM. required: false type: str - no_log: true - env: - - name: SCCFM_API_TOKEN author: - - Cisco SCCFM Team + - huides00 (@huides00) + - Scoombe (@Scoombe) + - afercal (@afercal) """ EXAMPLES = r""" @@ -100,6 +89,22 @@ """ +from typing import Any + +from ansible.module_utils.basic import AnsibleModule + +from ..module_utils.config import Config, base_argument_spec, create_config +from ..module_utils.dependencies import record_import_error + +try: + from scc_firewall_manager_sdk import ApiException + + from cisco_sccfm_core.errors import SccApiError + from cisco_sccfm_core.services.object_management import ObjectOverrideService +except ImportError as exc: + record_import_error(exc) + + def build_argument_spec() -> dict[str, dict[str, Any]]: return { "uid": {"type": "str", "required": True}, diff --git a/sccfm-ansible/plugins/modules/asa_ha_check.py b/sccfm-ansible/plugins/modules/asa_ha_check.py index 311b46ac..2171901d 100644 --- a/sccfm-ansible/plugins/modules/asa_ha_check.py +++ b/sccfm-ansible/plugins/modules/asa_ha_check.py @@ -1,24 +1,11 @@ # Copyright 2026 Cisco Systems, Inc. and its affiliates # # SPDX-License-Identifier: Apache-2.0 +# flake8: noqa: E402 +# isort: skip_file from __future__ import annotations -from typing import Any, cast - -from ansible.module_utils.basic import AnsibleModule -from scc_firewall_manager_sdk import ApiException, CdoTransaction, DevicePage - -from cisco_sccfm_core import ( - ASA_DEVICE_TYPE_FILTER, - AsaHaCheckReport, - AsaHaCheckService, - InventoryService, - SccApiError, -) - -from ..module_utils.config import base_argument_spec, create_config - DOCUMENTATION = r""" --- module: asa_ha_check @@ -65,17 +52,14 @@ description: SCCFM region (int, us, eu, apj, au, uae, in, or ci). required: false type: str - env: - - name: SCCFM_REGION api_token: description: API token for SCCFM. required: false type: str - no_log: true - env: - - name: SCCFM_API_TOKEN author: - - Cisco SCCFM Team + - huides00 (@huides00) + - Scoombe (@Scoombe) + - afercal (@afercal) """ EXAMPLES = r""" @@ -175,6 +159,27 @@ """ +from typing import Any, cast + +from ansible.module_utils.basic import AnsibleModule + +from ..module_utils.config import base_argument_spec, create_config +from ..module_utils.dependencies import record_import_error + +try: + from scc_firewall_manager_sdk import ApiException, CdoTransaction, DevicePage + + from cisco_sccfm_core import ( + ASA_DEVICE_TYPE_FILTER, + AsaHaCheckReport, + AsaHaCheckService, + InventoryService, + SccApiError, + ) +except ImportError as exc: + record_import_error(exc) + + def build_argument_spec() -> dict[str, dict[str, Any]]: return { **base_argument_spec(), diff --git a/sccfm-ansible/plugins/modules/change_asa_boot_image.py b/sccfm-ansible/plugins/modules/change_asa_boot_image.py index 712e4ff2..55e33835 100644 --- a/sccfm-ansible/plugins/modules/change_asa_boot_image.py +++ b/sccfm-ansible/plugins/modules/change_asa_boot_image.py @@ -1,28 +1,11 @@ # Copyright 2026 Cisco Systems, Inc. and its affiliates # # SPDX-License-Identifier: Apache-2.0 +# flake8: noqa: E402 +# isort: skip_file from __future__ import annotations -from typing import Any, cast - -from ansible.module_utils.basic import AnsibleModule -from scc_firewall_manager_sdk import ( - ApiException, - CdoTransaction, - ConfigState, - ConnectivityState, - Device, - DevicePage, -) - -from cisco_sccfm_core import ASA_DEVICE_TYPE_FILTER, InventoryService, SccApiError -from cisco_sccfm_core.models.asa_boot_image_change_result import AsaBootImageChangeResult -from cisco_sccfm_core.services.inventory import AsaBootImageService -from cisco_sccfm_core.utils import validate_asa_image_path - -from ..module_utils.config import Config, base_argument_spec, create_config - DOCUMENTATION = r""" --- module: change_asa_boot_image @@ -75,17 +58,14 @@ description: SCCFM region (int, us, eu, apj, au, uae, in, or ci). required: false type: str - env: - - name: SCCFM_REGION api_token: description: API token for SCCFM. required: false type: str - no_log: true - env: - - name: SCCFM_API_TOKEN author: - - Cisco SCCFM Team + - huides00 (@huides00) + - Scoombe (@Scoombe) + - afercal (@afercal) """ EXAMPLES = r""" @@ -162,6 +142,31 @@ """ +from typing import Any, cast + +from ansible.module_utils.basic import AnsibleModule + +from ..module_utils.config import Config, base_argument_spec, create_config +from ..module_utils.dependencies import record_import_error + +try: + from scc_firewall_manager_sdk import ( + ApiException, + CdoTransaction, + ConfigState, + ConnectivityState, + Device, + DevicePage, + ) + + from cisco_sccfm_core import ASA_DEVICE_TYPE_FILTER, InventoryService, SccApiError + from cisco_sccfm_core.models.asa_boot_image_change_result import AsaBootImageChangeResult + from cisco_sccfm_core.services.inventory import AsaBootImageService + from cisco_sccfm_core.utils import validate_asa_image_path +except ImportError as exc: + record_import_error(exc) + + def build_argument_spec() -> dict[str, dict[str, Any]]: return { **base_argument_spec(), diff --git a/sccfm-ansible/plugins/modules/change_asa_local_password.py b/sccfm-ansible/plugins/modules/change_asa_local_password.py index 777bcf15..6831fa93 100644 --- a/sccfm-ansible/plugins/modules/change_asa_local_password.py +++ b/sccfm-ansible/plugins/modules/change_asa_local_password.py @@ -1,21 +1,11 @@ # Copyright 2026 Cisco Systems, Inc. and its affiliates # # SPDX-License-Identifier: Apache-2.0 +# flake8: noqa: E402 +# isort: skip_file from __future__ import annotations -from typing import Any, cast - -from ansible.module_utils.basic import AnsibleModule -from scc_firewall_manager_sdk import ApiException, CdoTransaction, DevicePage - -from cisco_sccfm_core import ASA_DEVICE_TYPE_FILTER, InventoryService, SccApiError -from cisco_sccfm_core.models.asa_password_change_result import AsaPasswordChangeResult -from cisco_sccfm_core.services.inventory.asa_user_password_service import AsaUserPasswordService -from cisco_sccfm_core.types import ConfigLike - -from ..module_utils.config import Config, base_argument_spec, create_config - DOCUMENTATION = r""" --- module: change_asa_local_password @@ -54,7 +44,6 @@ - The new password to set for the user. required: true type: str - no_log: true limit: description: - Maximum number of devices to return when using C(query). @@ -73,17 +62,14 @@ description: SCCFM region (int, us, eu, apj, au, uae, in, or ci). required: false type: str - env: - - name: SCCFM_REGION api_token: description: API token for SCCFM. required: false type: str - no_log: true - env: - - name: SCCFM_API_TOKEN author: - - Cisco SCCFM Team + - huides00 (@huides00) + - Scoombe (@Scoombe) + - afercal (@afercal) """ EXAMPLES = r""" @@ -152,6 +138,24 @@ """ +from typing import Any, cast + +from ansible.module_utils.basic import AnsibleModule + +from ..module_utils.config import Config, base_argument_spec, create_config +from ..module_utils.dependencies import record_import_error + +try: + from scc_firewall_manager_sdk import ApiException, CdoTransaction, DevicePage + + from cisco_sccfm_core import ASA_DEVICE_TYPE_FILTER, InventoryService, SccApiError + from cisco_sccfm_core.models.asa_password_change_result import AsaPasswordChangeResult + from cisco_sccfm_core.services.inventory.asa_user_password_service import AsaUserPasswordService + from cisco_sccfm_core.types import ConfigLike +except ImportError as exc: + record_import_error(exc) + + def build_argument_spec() -> dict[str, dict[str, Any]]: return { "query": {"type": "str", "required": False}, diff --git a/sccfm-ansible/plugins/modules/clear_asa_shun.py b/sccfm-ansible/plugins/modules/clear_asa_shun.py index ea7b01bd..42241b03 100644 --- a/sccfm-ansible/plugins/modules/clear_asa_shun.py +++ b/sccfm-ansible/plugins/modules/clear_asa_shun.py @@ -1,18 +1,11 @@ # Copyright 2026 Cisco Systems, Inc. and its affiliates # # SPDX-License-Identifier: Apache-2.0 +# flake8: noqa: E402 +# isort: skip_file from __future__ import annotations -from typing import Any, cast - -from ansible.module_utils.basic import AnsibleModule -from scc_firewall_manager_sdk import ApiException, CdoCliResult, CdoTransaction, DevicePage - -from cisco_sccfm_core import ASA_DEVICE_TYPE_FILTER, AsaShunService, InventoryService, SccApiError - -from ..module_utils.config import base_argument_spec, create_config - DOCUMENTATION = r""" --- module: clear_asa_shun @@ -57,17 +50,14 @@ description: SCCFM region (int, us, eu, apj, au, uae, in, or ci). required: false type: str - env: - - name: SCCFM_REGION api_token: description: API token for SCCFM. required: false type: str - no_log: true - env: - - name: SCCFM_API_TOKEN author: - - Cisco SCCFM Team + - huides00 (@huides00) + - Scoombe (@Scoombe) + - afercal (@afercal) """ EXAMPLES = r""" @@ -120,6 +110,26 @@ """ +from typing import Any, cast + +from ansible.module_utils.basic import AnsibleModule + +from ..module_utils.config import base_argument_spec, create_config +from ..module_utils.dependencies import record_import_error + +try: + from scc_firewall_manager_sdk import ApiException, CdoCliResult, CdoTransaction, DevicePage + + from cisco_sccfm_core import ( + ASA_DEVICE_TYPE_FILTER, + AsaShunService, + InventoryService, + SccApiError, + ) +except ImportError as exc: + record_import_error(exc) + + def build_argument_spec() -> dict[str, dict[str, Any]]: return { **base_argument_spec(), diff --git a/sccfm-ansible/plugins/modules/configure_manager.py b/sccfm-ansible/plugins/modules/configure_manager.py index c3b1a5b5..17c362f3 100644 --- a/sccfm-ansible/plugins/modules/configure_manager.py +++ b/sccfm-ansible/plugins/modules/configure_manager.py @@ -1,19 +1,11 @@ # Copyright 2026 Cisco Systems, Inc. and its affiliates # # SPDX-License-Identifier: Apache-2.0 +# flake8: noqa: E402 +# isort: skip_file from __future__ import annotations -from typing import Any - -from ansible.module_utils.basic import AnsibleModule, env_fallback - -from cisco_sccfm_core.services.inventory import ( - FtdConfigureManagerError, - FtdConfigureManagerService, - parse_jump_host, -) - DOCUMENTATION = r""" --- module: configure_manager @@ -49,8 +41,6 @@ - Can also be supplied via the C(SCCFM_FTD_PASSWORD) environment variable. required: false type: str - env: - - name: SCCFM_FTD_PASSWORD cli_key: description: - The full C(configure manager add ...) string returned by C(onboard_cdfmc_ftd). @@ -71,15 +61,15 @@ - Leave unset to use SSH key/agent authentication for the jump host. required: false type: str - env: - - name: SCCFM_JUMP_PASSWORD ssh_timeout: description: SSH connect and read timeout in seconds. required: false type: int default: 30 author: - - Cisco SCCFM Team + - huides00 (@huides00) + - Scoombe (@Scoombe) + - afercal (@afercal) """ EXAMPLES = r""" @@ -149,6 +139,22 @@ """ +from typing import Any + +from ansible.module_utils.basic import AnsibleModule, env_fallback + +from ..module_utils.dependencies import ensure_required_dependencies, record_import_error + +try: + from cisco_sccfm_core.services.inventory import ( + FtdConfigureManagerError, + FtdConfigureManagerService, + parse_jump_host, + ) +except ImportError as exc: + record_import_error(exc) + + def build_argument_spec() -> dict[str, dict[str, Any]]: return { "ftd_host": {"type": "str", "required": True}, @@ -177,6 +183,7 @@ def run_module() -> None: argument_spec=build_argument_spec(), supports_check_mode=True, ) + ensure_required_dependencies(module) host: str = module.params["ftd_host"] port: int = module.params["ftd_port"] diff --git a/sccfm-ansible/plugins/modules/create_access_rule.py b/sccfm-ansible/plugins/modules/create_access_rule.py index f5355c05..59ac4b1f 100644 --- a/sccfm-ansible/plugins/modules/create_access_rule.py +++ b/sccfm-ansible/plugins/modules/create_access_rule.py @@ -1,19 +1,11 @@ # Copyright 2026 Cisco Systems, Inc. and its affiliates # # SPDX-License-Identifier: Apache-2.0 +# flake8: noqa: E402 +# isort: skip_file from __future__ import annotations -from typing import Any - -from ansible.module_utils.basic import AnsibleModule -from scc_firewall_manager_sdk import ApiException - -from cisco_sccfm_core.errors import SccApiError -from cisco_sccfm_core.services.policy import AccessRuleService - -from ..module_utils.config import Config, base_argument_spec, create_config - DOCUMENTATION = r""" --- module: create_access_rule @@ -82,17 +74,14 @@ description: SCCFM region (int, us, eu, apj, au, uae, in, or ci). required: false type: str - env: - - name: SCCFM_REGION api_token: description: API token for SCCFM. required: false type: str - no_log: true - env: - - name: SCCFM_API_TOKEN author: - - Cisco SCCFM Team + - huides00 (@huides00) + - Scoombe (@Scoombe) + - afercal (@afercal) """ EXAMPLES = r""" @@ -179,6 +168,22 @@ """ +from typing import Any + +from ansible.module_utils.basic import AnsibleModule + +from ..module_utils.config import Config, base_argument_spec, create_config +from ..module_utils.dependencies import record_import_error + +try: + from scc_firewall_manager_sdk import ApiException + + from cisco_sccfm_core.errors import SccApiError + from cisco_sccfm_core.services.policy import AccessRuleService +except ImportError as exc: + record_import_error(exc) + + def build_argument_spec() -> dict[str, dict[str, Any]]: return { "access_group_uid": {"type": "str", "required": True}, diff --git a/sccfm-ansible/plugins/modules/create_network_group.py b/sccfm-ansible/plugins/modules/create_network_group.py index 50e04e1f..e369cf55 100644 --- a/sccfm-ansible/plugins/modules/create_network_group.py +++ b/sccfm-ansible/plugins/modules/create_network_group.py @@ -1,19 +1,11 @@ # Copyright 2026 Cisco Systems, Inc. and its affiliates # # SPDX-License-Identifier: Apache-2.0 +# flake8: noqa: E402 +# isort: skip_file from __future__ import annotations -from typing import Any - -from ansible.module_utils.basic import AnsibleModule -from scc_firewall_manager_sdk import ApiException - -from cisco_sccfm_core.errors import SccApiError -from cisco_sccfm_core.services.object_management import NetworkGroupService - -from ..module_utils.config import Config, base_argument_spec, create_config - DOCUMENTATION = r""" --- module: create_network_group @@ -69,17 +61,14 @@ description: SCCFM region (int, us, eu, apj, au, uae, in, or ci). required: false type: str - env: - - name: SCCFM_REGION api_token: description: API token for SCCFM. required: false type: str - no_log: true - env: - - name: SCCFM_API_TOKEN author: - - Cisco SCCFM Team + - huides00 (@huides00) + - Scoombe (@Scoombe) + - afercal (@afercal) """ EXAMPLES = r""" @@ -160,6 +149,22 @@ """ +from typing import Any + +from ansible.module_utils.basic import AnsibleModule + +from ..module_utils.config import Config, base_argument_spec, create_config +from ..module_utils.dependencies import record_import_error + +try: + from scc_firewall_manager_sdk import ApiException + + from cisco_sccfm_core.errors import SccApiError + from cisco_sccfm_core.services.object_management import NetworkGroupService +except ImportError as exc: + record_import_error(exc) + + def build_argument_spec() -> dict[str, dict[str, Any]]: return { "name": {"type": "str", "required": True}, diff --git a/sccfm-ansible/plugins/modules/create_network_object.py b/sccfm-ansible/plugins/modules/create_network_object.py index aab3c531..0a743d1c 100644 --- a/sccfm-ansible/plugins/modules/create_network_object.py +++ b/sccfm-ansible/plugins/modules/create_network_object.py @@ -1,19 +1,11 @@ # Copyright 2026 Cisco Systems, Inc. and its affiliates # # SPDX-License-Identifier: Apache-2.0 +# flake8: noqa: E402 +# isort: skip_file from __future__ import annotations -from typing import Any - -from ansible.module_utils.basic import AnsibleModule -from scc_firewall_manager_sdk import ApiException - -from cisco_sccfm_core.errors import SccApiError -from cisco_sccfm_core.services.object_management import NetworkObjectService - -from ..module_utils.config import Config, base_argument_spec, create_config - DOCUMENTATION = r""" --- module: create_network_object @@ -55,17 +47,14 @@ description: SCCFM region (int, us, eu, apj, au, uae, in, or ci). required: false type: str - env: - - name: SCCFM_REGION api_token: description: API token for SCCFM. required: false type: str - no_log: true - env: - - name: SCCFM_API_TOKEN author: - - Cisco SCCFM Team + - huides00 (@huides00) + - Scoombe (@Scoombe) + - afercal (@afercal) """ EXAMPLES = r""" @@ -140,6 +129,22 @@ """ +from typing import Any + +from ansible.module_utils.basic import AnsibleModule + +from ..module_utils.config import Config, base_argument_spec, create_config +from ..module_utils.dependencies import record_import_error + +try: + from scc_firewall_manager_sdk import ApiException + + from cisco_sccfm_core.errors import SccApiError + from cisco_sccfm_core.services.object_management import NetworkObjectService +except ImportError as exc: + record_import_error(exc) + + def build_argument_spec() -> dict[str, dict[str, Any]]: return { "name": {"type": "str", "required": True}, diff --git a/sccfm-ansible/plugins/modules/delete_access_rule.py b/sccfm-ansible/plugins/modules/delete_access_rule.py index 65d0e3c6..31fe3578 100644 --- a/sccfm-ansible/plugins/modules/delete_access_rule.py +++ b/sccfm-ansible/plugins/modules/delete_access_rule.py @@ -1,19 +1,11 @@ # Copyright 2026 Cisco Systems, Inc. and its affiliates # # SPDX-License-Identifier: Apache-2.0 +# flake8: noqa: E402 +# isort: skip_file from __future__ import annotations -from typing import Any - -from ansible.module_utils.basic import AnsibleModule -from scc_firewall_manager_sdk import ApiException - -from cisco_sccfm_core.errors import NotFoundError, SccApiError -from cisco_sccfm_core.services.policy import AccessRuleService - -from ..module_utils.config import Config, base_argument_spec, create_config - DOCUMENTATION = r""" --- module: delete_access_rule @@ -31,17 +23,14 @@ description: SCCFM region (int, us, eu, apj, au, uae, in, or ci). required: false type: str - env: - - name: SCCFM_REGION api_token: description: API token for SCCFM. required: false type: str - no_log: true - env: - - name: SCCFM_API_TOKEN author: - - Cisco SCCFM Team + - huides00 (@huides00) + - Scoombe (@Scoombe) + - afercal (@afercal) """ EXAMPLES = r""" @@ -75,6 +64,22 @@ """ +from typing import Any + +from ansible.module_utils.basic import AnsibleModule + +from ..module_utils.config import Config, base_argument_spec, create_config +from ..module_utils.dependencies import record_import_error + +try: + from scc_firewall_manager_sdk import ApiException + + from cisco_sccfm_core.errors import NotFoundError, SccApiError + from cisco_sccfm_core.services.policy import AccessRuleService +except ImportError as exc: + record_import_error(exc) + + def build_argument_spec() -> dict[str, dict[str, Any]]: return { "uid": {"type": "str", "required": True}, diff --git a/sccfm-ansible/plugins/modules/delete_network_group.py b/sccfm-ansible/plugins/modules/delete_network_group.py index 1eb436e0..a9ec2e4d 100644 --- a/sccfm-ansible/plugins/modules/delete_network_group.py +++ b/sccfm-ansible/plugins/modules/delete_network_group.py @@ -1,23 +1,11 @@ # Copyright 2026 Cisco Systems, Inc. and its affiliates # # SPDX-License-Identifier: Apache-2.0 +# flake8: noqa: E402 +# isort: skip_file from __future__ import annotations -from typing import Any - -from ansible.module_utils.basic import AnsibleModule - -from cisco_sccfm_core.services.object_management import NetworkGroupService - -from ..module_utils.config import ( - Config, - base_argument_spec, - create_config, - identifier_argument_spec, -) -from ..module_utils.operations import run_delete_with_idempotency - DOCUMENTATION = r""" --- module: delete_network_group @@ -39,21 +27,18 @@ description: SCCFM region (int, us, eu, apj, au, uae, in, or ci). required: false type: str - env: - - name: SCCFM_REGION api_token: description: API token for SCCFM. required: false type: str - no_log: true - env: - - name: SCCFM_API_TOKEN notes: - Either C(uid) or C(name) must be provided, but not both. - When using C(name), the module will search for the group and resolve it to a UID before deletion. - Network groups are filtered by objectType to avoid accidentally matching network objects with the same name. author: - - Cisco SCCFM Team + - huides00 (@huides00) + - Scoombe (@Scoombe) + - afercal (@afercal) """ EXAMPLES = r""" @@ -102,6 +87,27 @@ """ +from typing import Any + +from ansible.module_utils.basic import AnsibleModule + +from ..module_utils.config import ( + Config as _Config, + base_argument_spec, + create_config, + identifier_argument_spec, +) +from ..module_utils.dependencies import record_import_error +from ..module_utils.operations import run_delete_with_idempotency + +Config = _Config + +try: + from cisco_sccfm_core.services.object_management import NetworkGroupService +except ImportError as exc: + record_import_error(exc) + + def build_argument_spec() -> dict[str, dict[str, Any]]: return { **identifier_argument_spec(), diff --git a/sccfm-ansible/plugins/modules/delete_network_object.py b/sccfm-ansible/plugins/modules/delete_network_object.py index efd88bc6..fa696489 100644 --- a/sccfm-ansible/plugins/modules/delete_network_object.py +++ b/sccfm-ansible/plugins/modules/delete_network_object.py @@ -1,23 +1,11 @@ # Copyright 2026 Cisco Systems, Inc. and its affiliates # # SPDX-License-Identifier: Apache-2.0 +# flake8: noqa: E402 +# isort: skip_file from __future__ import annotations -from typing import Any - -from ansible.module_utils.basic import AnsibleModule - -from cisco_sccfm_core.services.object_management import NetworkObjectService - -from ..module_utils.config import ( - Config, - base_argument_spec, - create_config, - identifier_argument_spec, -) -from ..module_utils.operations import run_delete_with_idempotency - DOCUMENTATION = r""" --- module: delete_network_object @@ -38,20 +26,17 @@ description: SCCFM region (int, us, eu, apj, au, uae, in, or ci). required: false type: str - env: - - name: SCCFM_REGION api_token: description: API token for SCCFM. required: false type: str - no_log: true - env: - - name: SCCFM_API_TOKEN notes: - Either C(uid) or C(name) must be provided, but not both. - When using C(name), the module will search for the object and resolve it to a UID before deletion. author: - - Cisco SCCFM Team + - huides00 (@huides00) + - Scoombe (@Scoombe) + - afercal (@afercal) """ EXAMPLES = r""" @@ -101,6 +86,27 @@ """ +from typing import Any + +from ansible.module_utils.basic import AnsibleModule + +from ..module_utils.config import ( + Config as _Config, + base_argument_spec, + create_config, + identifier_argument_spec, +) +from ..module_utils.dependencies import record_import_error +from ..module_utils.operations import run_delete_with_idempotency + +Config = _Config + +try: + from cisco_sccfm_core.services.object_management import NetworkObjectService +except ImportError as exc: + record_import_error(exc) + + def build_argument_spec() -> dict[str, dict[str, Any]]: return { **identifier_argument_spec(), diff --git a/sccfm-ansible/plugins/modules/delete_object_override.py b/sccfm-ansible/plugins/modules/delete_object_override.py index b6c29fc2..6a3e47d6 100644 --- a/sccfm-ansible/plugins/modules/delete_object_override.py +++ b/sccfm-ansible/plugins/modules/delete_object_override.py @@ -1,19 +1,11 @@ # Copyright 2026 Cisco Systems, Inc. and its affiliates # # SPDX-License-Identifier: Apache-2.0 +# flake8: noqa: E402 +# isort: skip_file from __future__ import annotations -from typing import Any - -from ansible.module_utils.basic import AnsibleModule -from scc_firewall_manager_sdk import ApiException - -from cisco_sccfm_core.errors import SccApiError -from cisco_sccfm_core.services.object_management import ObjectOverrideService - -from ..module_utils.config import Config, base_argument_spec, create_config - DOCUMENTATION = r""" --- module: delete_object_override @@ -37,17 +29,14 @@ description: SCCFM region (int, us, eu, apj, au, uae, in, or ci). required: false type: str - env: - - name: SCCFM_REGION api_token: description: API token for SCCFM. required: false type: str - no_log: true - env: - - name: SCCFM_API_TOKEN author: - - Cisco SCCFM Team + - huides00 (@huides00) + - Scoombe (@Scoombe) + - afercal (@afercal) """ EXAMPLES = r""" @@ -100,6 +89,22 @@ """ +from typing import Any + +from ansible.module_utils.basic import AnsibleModule + +from ..module_utils.config import Config, base_argument_spec, create_config +from ..module_utils.dependencies import record_import_error + +try: + from scc_firewall_manager_sdk import ApiException + + from cisco_sccfm_core.errors import SccApiError + from cisco_sccfm_core.services.object_management import ObjectOverrideService +except ImportError as exc: + record_import_error(exc) + + def build_argument_spec() -> dict[str, dict[str, Any]]: return { "uid": {"type": "str", "required": True}, diff --git a/sccfm-ansible/plugins/modules/deploy_cdfmc_ftd.py b/sccfm-ansible/plugins/modules/deploy_cdfmc_ftd.py index f69ae527..a6ee7fb8 100644 --- a/sccfm-ansible/plugins/modules/deploy_cdfmc_ftd.py +++ b/sccfm-ansible/plugins/modules/deploy_cdfmc_ftd.py @@ -1,28 +1,11 @@ # Copyright 2026 Cisco Systems, Inc. and its affiliates # # SPDX-License-Identifier: Apache-2.0 +# flake8: noqa: E402 +# isort: skip_file from __future__ import annotations -from typing import Any, cast - -from ansible.module_utils.basic import AnsibleModule -from scc_firewall_manager_sdk import ( - ApiException, - CdoTransaction, - DevicePage, - EntityType, -) - -from cisco_sccfm_core import InventoryService, SccApiError -from cisco_sccfm_core.constants import DEFAULT_TRANSACTION_TIMEOUT_SEC -from cisco_sccfm_core.models.cdo_transaction_status import CdoTransactionStatus -from cisco_sccfm_core.services.inventory import FtdDeployService -from cisco_sccfm_core.services.transaction_service import TransactionService -from cisco_sccfm_core.types import ConfigLike - -from ..module_utils.config import Config, base_argument_spec, create_config - DOCUMENTATION = r""" --- module: deploy_cdfmc_ftd @@ -92,17 +75,14 @@ description: SCCFM region (int, us, eu, apj, au, uae, in, or ci). required: false type: str - env: - - name: SCCFM_REGION api_token: description: API token for SCCFM. required: false type: str - no_log: true - env: - - name: SCCFM_API_TOKEN author: - - Cisco SCCFM Team + - huides00 (@huides00) + - Scoombe (@Scoombe) + - afercal (@afercal) """ EXAMPLES = r""" @@ -158,6 +138,32 @@ """ +from typing import Any, cast + +from ansible.module_utils.basic import AnsibleModule + +from ..module_utils.config import Config, base_argument_spec, create_config +from ..module_utils.dependencies import record_import_error + +_DEFAULT_TRANSACTION_TIMEOUT_SEC = 3600 + +try: + from scc_firewall_manager_sdk import ( + ApiException, + CdoTransaction, + DevicePage, + EntityType, + ) + + from cisco_sccfm_core import InventoryService, SccApiError + from cisco_sccfm_core.models.cdo_transaction_status import CdoTransactionStatus + from cisco_sccfm_core.services.inventory import FtdDeployService + from cisco_sccfm_core.services.transaction_service import TransactionService + from cisco_sccfm_core.types import ConfigLike +except ImportError as exc: + record_import_error(exc) + + def build_argument_spec() -> dict[str, dict[str, Any]]: return { "query": {"type": "str", "required": False}, @@ -171,7 +177,7 @@ def build_argument_spec() -> dict[str, dict[str, Any]]: "timeout": { "type": "int", "required": False, - "default": DEFAULT_TRANSACTION_TIMEOUT_SEC, + "default": _DEFAULT_TRANSACTION_TIMEOUT_SEC, }, **base_argument_spec(), } @@ -261,7 +267,7 @@ def run_module() -> None: description: str | None = module.params.get("description") ignore_warnings: bool = module.params.get("ignore_warnings", False) wait_for_completion: bool = module.params.get("wait", False) - timeout: int = module.params.get("timeout", DEFAULT_TRANSACTION_TIMEOUT_SEC) + timeout: int = module.params.get("timeout", _DEFAULT_TRANSACTION_TIMEOUT_SEC) transaction = _trigger_deploy( config=config, diff --git a/sccfm-ansible/plugins/modules/edit_object_override.py b/sccfm-ansible/plugins/modules/edit_object_override.py index 944d440a..79584cf2 100644 --- a/sccfm-ansible/plugins/modules/edit_object_override.py +++ b/sccfm-ansible/plugins/modules/edit_object_override.py @@ -1,19 +1,11 @@ # Copyright 2026 Cisco Systems, Inc. and its affiliates # # SPDX-License-Identifier: Apache-2.0 +# flake8: noqa: E402 +# isort: skip_file from __future__ import annotations -from typing import Any - -from ansible.module_utils.basic import AnsibleModule -from scc_firewall_manager_sdk import ApiException - -from cisco_sccfm_core.errors import SccApiError -from cisco_sccfm_core.services.object_management import ObjectOverrideService - -from ..module_utils.config import Config, base_argument_spec, create_config - DOCUMENTATION = r""" --- module: edit_object_override @@ -45,17 +37,14 @@ description: SCCFM region (int, us, eu, apj, au, uae, in, or ci). required: false type: str - env: - - name: SCCFM_REGION api_token: description: API token for SCCFM. required: false type: str - no_log: true - env: - - name: SCCFM_API_TOKEN author: - - Cisco SCCFM Team + - huides00 (@huides00) + - Scoombe (@Scoombe) + - afercal (@afercal) """ EXAMPLES = r""" @@ -110,6 +99,22 @@ """ +from typing import Any + +from ansible.module_utils.basic import AnsibleModule + +from ..module_utils.config import Config, base_argument_spec, create_config +from ..module_utils.dependencies import record_import_error + +try: + from scc_firewall_manager_sdk import ApiException + + from cisco_sccfm_core.errors import SccApiError + from cisco_sccfm_core.services.object_management import ObjectOverrideService +except ImportError as exc: + record_import_error(exc) + + def build_argument_spec() -> dict[str, dict[str, Any]]: return { "uid": {"type": "str", "required": True}, diff --git a/sccfm-ansible/plugins/modules/execute_asa_cli.py b/sccfm-ansible/plugins/modules/execute_asa_cli.py index fa6cd572..e145579e 100644 --- a/sccfm-ansible/plugins/modules/execute_asa_cli.py +++ b/sccfm-ansible/plugins/modules/execute_asa_cli.py @@ -1,24 +1,11 @@ # Copyright 2026 Cisco Systems, Inc. and its affiliates # # SPDX-License-Identifier: Apache-2.0 +# flake8: noqa: E402 +# isort: skip_file from __future__ import annotations -from typing import Any, cast - -from ansible.module_utils.basic import AnsibleModule -from scc_firewall_manager_sdk import ApiException, CdoCliResult, CdoTransaction, DevicePage - -from cisco_sccfm_core import ( - ASA_DEVICE_TYPE_FILTER, - AsaCommandLineService, - InventoryService, - SccApiError, -) -from cisco_sccfm_core.types import ConfigLike - -from ..module_utils.config import Config, base_argument_spec, create_config - DOCUMENTATION = r""" --- module: execute_asa_cli @@ -75,17 +62,14 @@ description: SCCFM region (int, us, eu, apj, au, uae, in, or ci). required: false type: str - env: - - name: SCCFM_REGION api_token: description: API token for SCCFM. required: false type: str - no_log: true - env: - - name: SCCFM_API_TOKEN author: - - Cisco SCCFM Team + - huides00 (@huides00) + - Scoombe (@Scoombe) + - afercal (@afercal) """ EXAMPLES = r""" @@ -175,6 +159,27 @@ """ +from typing import Any, cast + +from ansible.module_utils.basic import AnsibleModule + +from ..module_utils.config import Config, base_argument_spec, create_config +from ..module_utils.dependencies import record_import_error + +try: + from scc_firewall_manager_sdk import ApiException, CdoCliResult, CdoTransaction, DevicePage + + from cisco_sccfm_core import ( + ASA_DEVICE_TYPE_FILTER, + AsaCommandLineService, + InventoryService, + SccApiError, + ) + from cisco_sccfm_core.types import ConfigLike +except ImportError as exc: + record_import_error(exc) + + def build_argument_spec() -> dict[str, dict[str, Any]]: return { "query": {"type": "str", "required": False}, diff --git a/sccfm-ansible/plugins/modules/execute_ftd_cli.py b/sccfm-ansible/plugins/modules/execute_ftd_cli.py index b39f8afa..9cdc47a2 100644 --- a/sccfm-ansible/plugins/modules/execute_ftd_cli.py +++ b/sccfm-ansible/plugins/modules/execute_ftd_cli.py @@ -1,24 +1,11 @@ # Copyright 2026 Cisco Systems, Inc. and its affiliates # # SPDX-License-Identifier: Apache-2.0 +# flake8: noqa: E402 +# isort: skip_file from __future__ import annotations -from typing import Any, cast - -from ansible.module_utils.basic import AnsibleModule -from scc_firewall_manager_sdk import ApiException, Device, DevicePage - -from cisco_sccfm_core import CDFMC_MANAGED_FTD_DEVICE_TYPE_FILTER, InventoryService, SccApiError -from cisco_sccfm_core.models.ftd_cli_result import FtdBulkCliResult -from cisco_sccfm_core.services.inventory.ftd_cli_service import ( - FtdCommandLineService, - _validate_show_command, -) -from cisco_sccfm_core.types import ConfigLike - -from ..module_utils.config import Config, base_argument_spec, create_config - DOCUMENTATION = r""" --- module: execute_ftd_cli @@ -76,17 +63,14 @@ description: SCCFM region (int, us, eu, apj, au, uae, in, or ci). required: false type: str - env: - - name: SCCFM_REGION api_token: description: API token for SCCFM. required: false type: str - no_log: true - env: - - name: SCCFM_API_TOKEN author: - - Cisco SCCFM Team + - huides00 (@huides00) + - Scoombe (@Scoombe) + - afercal (@afercal) """ EXAMPLES = r""" @@ -161,6 +145,27 @@ """ +from typing import Any + +from ansible.module_utils.basic import AnsibleModule + +from ..module_utils.config import Config, base_argument_spec, create_config +from ..module_utils.dependencies import record_import_error + +try: + from scc_firewall_manager_sdk import ApiException, Device, DevicePage + + from cisco_sccfm_core import CDFMC_MANAGED_FTD_DEVICE_TYPE_FILTER, InventoryService, SccApiError + from cisco_sccfm_core.models.ftd_cli_result import FtdBulkCliResult + from cisco_sccfm_core.services.inventory.ftd_cli_service import ( + FtdCommandLineService, + _validate_show_command, + ) + from cisco_sccfm_core.types import ConfigLike +except ImportError as exc: + record_import_error(exc) + + def build_argument_spec() -> dict[str, dict[str, Any]]: return { "query": {"type": "str", "required": False}, diff --git a/sccfm-ansible/plugins/modules/get_access_group.py b/sccfm-ansible/plugins/modules/get_access_group.py index dd1ea38e..e7f8415a 100644 --- a/sccfm-ansible/plugins/modules/get_access_group.py +++ b/sccfm-ansible/plugins/modules/get_access_group.py @@ -1,19 +1,11 @@ # Copyright 2026 Cisco Systems, Inc. and its affiliates # # SPDX-License-Identifier: Apache-2.0 +# flake8: noqa: E402 +# isort: skip_file from __future__ import annotations -from typing import Any - -from ansible.module_utils.basic import AnsibleModule -from scc_firewall_manager_sdk import ApiException - -from cisco_sccfm_core.errors import SccApiError -from cisco_sccfm_core.services.policy import AccessGroupService - -from ..module_utils.config import Config, base_argument_spec, create_config - DOCUMENTATION = r""" --- module: get_access_group @@ -29,17 +21,14 @@ description: SCCFM region (int, us, eu, apj, au, uae, in, or ci). required: false type: str - env: - - name: SCCFM_REGION api_token: description: API token for SCCFM. required: false type: str - no_log: true - env: - - name: SCCFM_API_TOKEN author: - - Cisco SCCFM Team + - huides00 (@huides00) + - Scoombe (@Scoombe) + - afercal (@afercal) """ EXAMPLES = r""" @@ -90,6 +79,22 @@ """ +from typing import Any + +from ansible.module_utils.basic import AnsibleModule + +from ..module_utils.config import Config, base_argument_spec, create_config +from ..module_utils.dependencies import record_import_error + +try: + from scc_firewall_manager_sdk import ApiException + + from cisco_sccfm_core.errors import SccApiError + from cisco_sccfm_core.services.policy import AccessGroupService +except ImportError as exc: + record_import_error(exc) + + def build_argument_spec() -> dict[str, dict[str, Any]]: return { "uid": {"type": "str", "required": True}, diff --git a/sccfm-ansible/plugins/modules/get_access_rule.py b/sccfm-ansible/plugins/modules/get_access_rule.py index 15a92e3a..dbee6929 100644 --- a/sccfm-ansible/plugins/modules/get_access_rule.py +++ b/sccfm-ansible/plugins/modules/get_access_rule.py @@ -1,19 +1,11 @@ # Copyright 2026 Cisco Systems, Inc. and its affiliates # # SPDX-License-Identifier: Apache-2.0 +# flake8: noqa: E402 +# isort: skip_file from __future__ import annotations -from typing import Any - -from ansible.module_utils.basic import AnsibleModule -from scc_firewall_manager_sdk import ApiException - -from cisco_sccfm_core.errors import SccApiError -from cisco_sccfm_core.services.policy import AccessRuleService - -from ..module_utils.config import Config, base_argument_spec, create_config - DOCUMENTATION = r""" --- module: get_access_rule @@ -29,17 +21,14 @@ description: SCCFM region (int, us, eu, apj, au, uae, in, or ci). required: false type: str - env: - - name: SCCFM_REGION api_token: description: API token for SCCFM. required: false type: str - no_log: true - env: - - name: SCCFM_API_TOKEN author: - - Cisco SCCFM Team + - huides00 (@huides00) + - Scoombe (@Scoombe) + - afercal (@afercal) """ EXAMPLES = r""" @@ -107,6 +96,22 @@ """ +from typing import Any + +from ansible.module_utils.basic import AnsibleModule + +from ..module_utils.config import Config, base_argument_spec, create_config +from ..module_utils.dependencies import record_import_error + +try: + from scc_firewall_manager_sdk import ApiException + + from cisco_sccfm_core.errors import SccApiError + from cisco_sccfm_core.services.policy import AccessRuleService +except ImportError as exc: + record_import_error(exc) + + def build_argument_spec() -> dict[str, dict[str, Any]]: return { "uid": {"type": "str", "required": True}, diff --git a/sccfm-ansible/plugins/modules/get_object.py b/sccfm-ansible/plugins/modules/get_object.py index ef3e8654..e883b2f1 100644 --- a/sccfm-ansible/plugins/modules/get_object.py +++ b/sccfm-ansible/plugins/modules/get_object.py @@ -1,19 +1,11 @@ # Copyright 2026 Cisco Systems, Inc. and its affiliates # # SPDX-License-Identifier: Apache-2.0 +# flake8: noqa: E402 +# isort: skip_file from __future__ import annotations -from typing import Any - -from ansible.module_utils.basic import AnsibleModule -from scc_firewall_manager_sdk import ApiException - -from cisco_sccfm_core.errors import SccApiError -from cisco_sccfm_core.services.object_management import ObjectOverrideService - -from ..module_utils.config import Config, base_argument_spec, create_config - DOCUMENTATION = r""" --- module: get_object @@ -31,17 +23,14 @@ description: SCCFM region (int, us, eu, apj, au, uae, in, or ci). required: false type: str - env: - - name: SCCFM_REGION api_token: description: API token for SCCFM. required: false type: str - no_log: true - env: - - name: SCCFM_API_TOKEN author: - - Cisco SCCFM Team + - huides00 (@huides00) + - Scoombe (@Scoombe) + - afercal (@afercal) """ EXAMPLES = r""" @@ -124,6 +113,22 @@ """ +from typing import Any + +from ansible.module_utils.basic import AnsibleModule + +from ..module_utils.config import Config, base_argument_spec, create_config +from ..module_utils.dependencies import record_import_error + +try: + from scc_firewall_manager_sdk import ApiException + + from cisco_sccfm_core.errors import SccApiError + from cisco_sccfm_core.services.object_management import ObjectOverrideService +except ImportError as exc: + record_import_error(exc) + + def build_argument_spec() -> dict[str, dict[str, Any]]: return { "uid": {"type": "str", "required": True}, diff --git a/sccfm-ansible/plugins/modules/list_access_groups.py b/sccfm-ansible/plugins/modules/list_access_groups.py index ff5557e2..265916ec 100644 --- a/sccfm-ansible/plugins/modules/list_access_groups.py +++ b/sccfm-ansible/plugins/modules/list_access_groups.py @@ -1,19 +1,11 @@ # Copyright 2026 Cisco Systems, Inc. and its affiliates # # SPDX-License-Identifier: Apache-2.0 +# flake8: noqa: E402 +# isort: skip_file from __future__ import annotations -from typing import Any - -from ansible.module_utils.basic import AnsibleModule -from scc_firewall_manager_sdk import ApiException - -from cisco_sccfm_core.errors import SccApiError -from cisco_sccfm_core.services.policy import AccessGroupService - -from ..module_utils.config import Config, base_argument_spec, create_config - DOCUMENTATION = r""" --- module: list_access_groups @@ -42,17 +34,14 @@ description: SCCFM region (int, us, eu, apj, au, uae, in, or ci). required: false type: str - env: - - name: SCCFM_REGION api_token: description: API token for SCCFM. required: false type: str - no_log: true - env: - - name: SCCFM_API_TOKEN author: - - Cisco SCCFM Team + - huides00 (@huides00) + - Scoombe (@Scoombe) + - afercal (@afercal) """ EXAMPLES = r""" @@ -112,6 +101,22 @@ """ +from typing import Any + +from ansible.module_utils.basic import AnsibleModule + +from ..module_utils.config import Config, base_argument_spec, create_config +from ..module_utils.dependencies import record_import_error + +try: + from scc_firewall_manager_sdk import ApiException + + from cisco_sccfm_core.errors import SccApiError + from cisco_sccfm_core.services.policy import AccessGroupService +except ImportError as exc: + record_import_error(exc) + + def build_argument_spec() -> dict[str, dict[str, Any]]: return { "query": {"type": "str", "required": False, "default": None}, diff --git a/sccfm-ansible/plugins/modules/list_access_rules.py b/sccfm-ansible/plugins/modules/list_access_rules.py index be767f23..c4adbc08 100644 --- a/sccfm-ansible/plugins/modules/list_access_rules.py +++ b/sccfm-ansible/plugins/modules/list_access_rules.py @@ -1,19 +1,11 @@ # Copyright 2026 Cisco Systems, Inc. and its affiliates # # SPDX-License-Identifier: Apache-2.0 +# flake8: noqa: E402 +# isort: skip_file from __future__ import annotations -from typing import Any - -from ansible.module_utils.basic import AnsibleModule -from scc_firewall_manager_sdk import ApiException - -from cisco_sccfm_core.errors import SccApiError -from cisco_sccfm_core.services.policy import AccessRuleService - -from ..module_utils.config import Config, base_argument_spec, create_config - DOCUMENTATION = r""" --- module: list_access_rules @@ -42,17 +34,14 @@ description: SCCFM region (int, us, eu, apj, au, uae, in, or ci). required: false type: str - env: - - name: SCCFM_REGION api_token: description: API token for SCCFM. required: false type: str - no_log: true - env: - - name: SCCFM_API_TOKEN author: - - Cisco SCCFM Team + - huides00 (@huides00) + - Scoombe (@Scoombe) + - afercal (@afercal) """ EXAMPLES = r""" @@ -124,6 +113,22 @@ """ +from typing import Any + +from ansible.module_utils.basic import AnsibleModule + +from ..module_utils.config import Config, base_argument_spec, create_config +from ..module_utils.dependencies import record_import_error + +try: + from scc_firewall_manager_sdk import ApiException + + from cisco_sccfm_core.errors import SccApiError + from cisco_sccfm_core.services.policy import AccessRuleService +except ImportError as exc: + record_import_error(exc) + + def build_argument_spec() -> dict[str, dict[str, Any]]: return { "query": {"type": "str", "required": False, "default": None}, diff --git a/sccfm-ansible/plugins/modules/list_asa_boot_registry.py b/sccfm-ansible/plugins/modules/list_asa_boot_registry.py index f12ab630..a3c7146c 100644 --- a/sccfm-ansible/plugins/modules/list_asa_boot_registry.py +++ b/sccfm-ansible/plugins/modules/list_asa_boot_registry.py @@ -1,25 +1,11 @@ # Copyright 2026 Cisco Systems, Inc. and its affiliates # # SPDX-License-Identifier: Apache-2.0 +# flake8: noqa: E402 +# isort: skip_file from __future__ import annotations -from typing import Any, cast - -from ansible.module_utils.basic import AnsibleModule -from scc_firewall_manager_sdk import ApiException, CdoTransaction, DevicePage - -from cisco_sccfm_core import ( - ASA_DEVICE_TYPE_FILTER, - AsaBootRegistryService, - InventoryService, - SccApiError, -) -from cisco_sccfm_core.models.asa_boot_registry import AsaBootRegistry -from cisco_sccfm_core.types import ConfigLike - -from ..module_utils.config import Config, base_argument_spec, create_config - DOCUMENTATION = r""" --- module: list_asa_boot_registry @@ -66,17 +52,14 @@ description: SCCFM region (int, us, eu, apj, au, uae, in, or ci). required: false type: str - env: - - name: SCCFM_REGION api_token: description: API token for SCCFM. required: false type: str - no_log: true - env: - - name: SCCFM_API_TOKEN author: - - Cisco SCCFM Team + - huides00 (@huides00) + - Scoombe (@Scoombe) + - afercal (@afercal) """ EXAMPLES = r""" @@ -144,6 +127,28 @@ """ +from typing import Any, cast + +from ansible.module_utils.basic import AnsibleModule + +from ..module_utils.config import Config, base_argument_spec, create_config +from ..module_utils.dependencies import record_import_error + +try: + from scc_firewall_manager_sdk import ApiException, CdoTransaction, DevicePage + + from cisco_sccfm_core import ( + ASA_DEVICE_TYPE_FILTER, + AsaBootRegistryService, + InventoryService, + SccApiError, + ) + from cisco_sccfm_core.models.asa_boot_registry import AsaBootRegistry + from cisco_sccfm_core.types import ConfigLike +except ImportError as exc: + record_import_error(exc) + + def build_argument_spec() -> dict[str, dict[str, Any]]: return { "query": {"type": "str", "required": False}, diff --git a/sccfm-ansible/plugins/modules/list_asa_compatible_versions.py b/sccfm-ansible/plugins/modules/list_asa_compatible_versions.py index 4ed90268..92b9da2c 100644 --- a/sccfm-ansible/plugins/modules/list_asa_compatible_versions.py +++ b/sccfm-ansible/plugins/modules/list_asa_compatible_versions.py @@ -1,25 +1,11 @@ # Copyright 2026 Cisco Systems, Inc. and its affiliates # # SPDX-License-Identifier: Apache-2.0 +# flake8: noqa: E402 +# isort: skip_file from __future__ import annotations -from typing import Any, cast - -from ansible.module_utils.basic import AnsibleModule -from scc_firewall_manager_sdk import ( - ApiException, - AsaCompatibleVersion, - DevicePage, -) - -from cisco_sccfm_core import ASA_DEVICE_TYPE_FILTER, InventoryService, SccApiError -from cisco_sccfm_core.models.asa_upgrade_version import AsaGroupCompatibleVersions -from cisco_sccfm_core.services.inventory import AsaUpgradeVersionService -from cisco_sccfm_core.types import ConfigLike - -from ..module_utils.config import Config, base_argument_spec, create_config - DOCUMENTATION = r""" --- module: list_asa_compatible_versions @@ -76,17 +62,14 @@ description: SCCFM region (int, us, eu, apj, au, uae, in, or ci). required: false type: str - env: - - name: SCCFM_REGION api_token: description: API token for SCCFM. required: false type: str - no_log: true - env: - - name: SCCFM_API_TOKEN author: - - Cisco SCCFM Team + - huides00 (@huides00) + - Scoombe (@Scoombe) + - afercal (@afercal) """ EXAMPLES = r""" @@ -170,6 +153,28 @@ """ +from typing import Any, cast + +from ansible.module_utils.basic import AnsibleModule + +from ..module_utils.config import Config, base_argument_spec, create_config +from ..module_utils.dependencies import record_import_error + +try: + from scc_firewall_manager_sdk import ( + ApiException, + AsaCompatibleVersion, + DevicePage, + ) + + from cisco_sccfm_core import ASA_DEVICE_TYPE_FILTER, InventoryService, SccApiError + from cisco_sccfm_core.models.asa_upgrade_version import AsaGroupCompatibleVersions + from cisco_sccfm_core.services.inventory import AsaUpgradeVersionService + from cisco_sccfm_core.types import ConfigLike +except ImportError as exc: + record_import_error(exc) + + def build_argument_spec() -> dict[str, dict[str, Any]]: return { "query": {"type": "str", "required": False}, diff --git a/sccfm-ansible/plugins/modules/list_asa_disk_files.py b/sccfm-ansible/plugins/modules/list_asa_disk_files.py index f8e9540a..a04b59b8 100644 --- a/sccfm-ansible/plugins/modules/list_asa_disk_files.py +++ b/sccfm-ansible/plugins/modules/list_asa_disk_files.py @@ -1,25 +1,11 @@ # Copyright 2026 Cisco Systems, Inc. and its affiliates # # SPDX-License-Identifier: Apache-2.0 +# flake8: noqa: E402 +# isort: skip_file from __future__ import annotations -from typing import Any, cast - -from ansible.module_utils.basic import AnsibleModule -from scc_firewall_manager_sdk import ApiException, CdoTransaction, DevicePage - -from cisco_sccfm_core import ( - ASA_DEVICE_TYPE_FILTER, - AsaDiskFileService, - InventoryService, - SccApiError, -) -from cisco_sccfm_core.models.asa_disk_file import AsaDiskFile -from cisco_sccfm_core.types import ConfigLike - -from ..module_utils.config import Config, base_argument_spec, create_config - DOCUMENTATION = r""" --- module: list_asa_disk_files @@ -66,17 +52,14 @@ description: SCCFM region (int, us, eu, apj, au, uae, in, or ci). required: false type: str - env: - - name: SCCFM_REGION api_token: description: API token for SCCFM. required: false type: str - no_log: true - env: - - name: SCCFM_API_TOKEN author: - - Cisco SCCFM Team + - huides00 (@huides00) + - Scoombe (@Scoombe) + - afercal (@afercal) """ EXAMPLES = r""" @@ -140,6 +123,28 @@ """ +from typing import Any, cast + +from ansible.module_utils.basic import AnsibleModule + +from ..module_utils.config import Config, base_argument_spec, create_config +from ..module_utils.dependencies import record_import_error + +try: + from scc_firewall_manager_sdk import ApiException, CdoTransaction, DevicePage + + from cisco_sccfm_core import ( + ASA_DEVICE_TYPE_FILTER, + AsaDiskFileService, + InventoryService, + SccApiError, + ) + from cisco_sccfm_core.models.asa_disk_file import AsaDiskFile + from cisco_sccfm_core.types import ConfigLike +except ImportError as exc: + record_import_error(exc) + + def build_argument_spec() -> dict[str, dict[str, Any]]: return { "query": {"type": "str", "required": False}, diff --git a/sccfm-ansible/plugins/modules/list_asa_local_users.py b/sccfm-ansible/plugins/modules/list_asa_local_users.py index 1e8c31ab..063cf7f3 100644 --- a/sccfm-ansible/plugins/modules/list_asa_local_users.py +++ b/sccfm-ansible/plugins/modules/list_asa_local_users.py @@ -1,26 +1,11 @@ # Copyright 2026 Cisco Systems, Inc. and its affiliates # # SPDX-License-Identifier: Apache-2.0 +# flake8: noqa: E402 +# isort: skip_file from __future__ import annotations -import json -from typing import Any, cast - -from ansible.module_utils.basic import AnsibleModule -from scc_firewall_manager_sdk import ApiException, CdoCliResult, CdoTransaction, DevicePage - -from cisco_sccfm_core import ( - ASA_DEVICE_TYPE_FILTER, - AsaCommandLineService, - InventoryService, - SccApiError, -) -from cisco_sccfm_core.parsers import normalize_cli_output, parse_cli_table, rows_to_dicts -from cisco_sccfm_core.types import ConfigLike - -from ..module_utils.config import Config, base_argument_spec, create_config - DOCUMENTATION = r""" --- module: list_asa_local_users @@ -58,17 +43,14 @@ description: SCCFM region (int, us, eu, apj, au, uae, in, or ci). required: false type: str - env: - - name: SCCFM_REGION api_token: description: API token for SCCFM required: false type: str - no_log: true - env: - - name: SCCFM_API_TOKEN author: - - Cisco SCCFM Team + - huides00 (@huides00) + - Scoombe (@Scoombe) + - afercal (@afercal) """ EXAMPLES = r""" @@ -130,6 +112,29 @@ """ +import json +from typing import Any, cast + +from ansible.module_utils.basic import AnsibleModule + +from ..module_utils.config import Config, base_argument_spec, create_config +from ..module_utils.dependencies import record_import_error + +try: + from scc_firewall_manager_sdk import ApiException, CdoCliResult, CdoTransaction, DevicePage + + from cisco_sccfm_core import ( + ASA_DEVICE_TYPE_FILTER, + AsaCommandLineService, + InventoryService, + SccApiError, + ) + from cisco_sccfm_core.parsers import normalize_cli_output, parse_cli_table, rows_to_dicts + from cisco_sccfm_core.types import ConfigLike +except ImportError as exc: + record_import_error(exc) + + def build_argument_spec() -> dict[str, dict[str, Any]]: return { "query": {"type": "str", "required": False}, diff --git a/sccfm-ansible/plugins/modules/list_asa_not_on_version.py b/sccfm-ansible/plugins/modules/list_asa_not_on_version.py index a71faa23..07b8e1d0 100644 --- a/sccfm-ansible/plugins/modules/list_asa_not_on_version.py +++ b/sccfm-ansible/plugins/modules/list_asa_not_on_version.py @@ -1,19 +1,11 @@ # Copyright 2026 Cisco Systems, Inc. and its affiliates # # SPDX-License-Identifier: Apache-2.0 +# flake8: noqa: E402 +# isort: skip_file from __future__ import annotations -import re -from typing import Any, cast - -from ansible.module_utils.basic import AnsibleModule -from scc_firewall_manager_sdk import ApiException, Device, DevicePage - -from cisco_sccfm_core import ASA_DEVICE_TYPE_FILTER, InventoryService, SccApiError - -from ..module_utils.config import base_argument_spec, create_config - DOCUMENTATION = r""" --- module: list_asa_not_on_version @@ -67,17 +59,14 @@ description: SCCFM region (int, us, eu, apj, au, uae, in, or ci). required: false type: str - env: - - name: SCCFM_REGION api_token: description: API token for SCCFM. required: false type: str - no_log: true - env: - - name: SCCFM_API_TOKEN author: - - Cisco SCCFM Team + - huides00 (@huides00) + - Scoombe (@Scoombe) + - afercal (@afercal) """ EXAMPLES = r""" @@ -165,6 +154,23 @@ type: int """ + +import re +from typing import Any, cast + +from ansible.module_utils.basic import AnsibleModule + +from ..module_utils.config import base_argument_spec, create_config +from ..module_utils.dependencies import record_import_error + +try: + from scc_firewall_manager_sdk import ApiException, Device, DevicePage + + from cisco_sccfm_core import ASA_DEVICE_TYPE_FILTER, InventoryService, SccApiError +except ImportError as exc: + record_import_error(exc) + + _VERSION_RE = re.compile(r"^\d+\.\d+") diff --git a/sccfm-ansible/plugins/modules/list_cdfmc_access_policies.py b/sccfm-ansible/plugins/modules/list_cdfmc_access_policies.py index c88a626a..a17930e7 100644 --- a/sccfm-ansible/plugins/modules/list_cdfmc_access_policies.py +++ b/sccfm-ansible/plugins/modules/list_cdfmc_access_policies.py @@ -1,19 +1,11 @@ # Copyright 2026 Cisco Systems, Inc. and its affiliates # # SPDX-License-Identifier: Apache-2.0 +# flake8: noqa: E402 +# isort: skip_file from __future__ import annotations -from typing import Any - -from ansible.module_utils.basic import AnsibleModule -from scc_firewall_manager_sdk import ApiException - -from cisco_sccfm_core import SccApiError -from cisco_sccfm_core.services.inventory.cdfmc_access_policy_service import CdfmcAccessPolicyService - -from ..module_utils.config import base_argument_spec, create_config - DOCUMENTATION = r""" --- module: list_cdfmc_access_policies @@ -42,17 +34,14 @@ description: SCCFM region (int, us, eu, apj, au, uae, in, or ci). required: false type: str - env: - - name: SCCFM_REGION api_token: description: API token for SCCFM. required: false type: str - no_log: true - env: - - name: SCCFM_API_TOKEN author: - - Cisco SCCFM Team + - huides00 (@huides00) + - Scoombe (@Scoombe) + - afercal (@afercal) """ EXAMPLES = r""" @@ -112,6 +101,24 @@ """ +from typing import Any + +from ansible.module_utils.basic import AnsibleModule + +from ..module_utils.config import base_argument_spec, create_config +from ..module_utils.dependencies import record_import_error + +try: + from scc_firewall_manager_sdk import ApiException + + from cisco_sccfm_core import SccApiError + from cisco_sccfm_core.services.inventory.cdfmc_access_policy_service import ( + CdfmcAccessPolicyService, + ) +except ImportError as exc: + record_import_error(exc) + + def build_argument_spec() -> dict[str, dict[str, Any]]: return { "domain_uid": {"type": "str", "required": True}, diff --git a/sccfm-ansible/plugins/modules/list_ftd_compatible_versions.py b/sccfm-ansible/plugins/modules/list_ftd_compatible_versions.py index 17cbe550..409914be 100644 --- a/sccfm-ansible/plugins/modules/list_ftd_compatible_versions.py +++ b/sccfm-ansible/plugins/modules/list_ftd_compatible_versions.py @@ -1,26 +1,11 @@ # Copyright 2026 Cisco Systems, Inc. and its affiliates # # SPDX-License-Identifier: Apache-2.0 +# flake8: noqa: E402 +# isort: skip_file from __future__ import annotations -from typing import Any, cast - -from ansible.module_utils.basic import AnsibleModule -from scc_firewall_manager_sdk import ( - ApiException, - DevicePage, - EntityType, - FtdVersion, -) - -from cisco_sccfm_core import FTD_DEVICE_TYPE_FILTER, InventoryService, SccApiError -from cisco_sccfm_core.models.ftd_upgrade_version import FtdGroupCompatibleVersions -from cisco_sccfm_core.services.inventory import FtdUpgradeVersionService -from cisco_sccfm_core.types import ConfigLike - -from ..module_utils.config import Config, base_argument_spec, create_config - DOCUMENTATION = r""" --- module: list_ftd_compatible_versions @@ -75,17 +60,14 @@ description: SCCFM region (int, us, eu, apj, au, uae, in, or ci). required: false type: str - env: - - name: SCCFM_REGION api_token: description: API token for SCCFM. required: false type: str - no_log: true - env: - - name: SCCFM_API_TOKEN author: - - Cisco SCCFM Team + - huides00 (@huides00) + - Scoombe (@Scoombe) + - afercal (@afercal) """ EXAMPLES = r""" @@ -173,6 +155,28 @@ """ +from typing import Any, cast + +from ansible.module_utils.basic import AnsibleModule + +from ..module_utils.config import base_argument_spec, create_config +from ..module_utils.dependencies import record_import_error + +try: + from scc_firewall_manager_sdk import ( + ApiException, + DevicePage, + FtdVersion, + ) + + from cisco_sccfm_core import FTD_DEVICE_TYPE_FILTER, InventoryService, SccApiError + from cisco_sccfm_core.models.ftd_upgrade_version import FtdGroupCompatibleVersions + from cisco_sccfm_core.services.inventory import FtdUpgradeVersionService + from cisco_sccfm_core.types import ConfigLike +except ImportError as exc: + record_import_error(exc) + + def build_argument_spec() -> dict[str, dict[str, Any]]: return { "query": {"type": "str", "required": False}, diff --git a/sccfm-ansible/plugins/modules/list_ftd_not_on_version.py b/sccfm-ansible/plugins/modules/list_ftd_not_on_version.py index 477a3c0e..684f3bc8 100644 --- a/sccfm-ansible/plugins/modules/list_ftd_not_on_version.py +++ b/sccfm-ansible/plugins/modules/list_ftd_not_on_version.py @@ -1,21 +1,11 @@ # Copyright 2026 Cisco Systems, Inc. and its affiliates # # SPDX-License-Identifier: Apache-2.0 +# flake8: noqa: E402 +# isort: skip_file from __future__ import annotations -import re -from typing import Any, cast - -from ansible.module_utils.basic import AnsibleModule -from scc_firewall_manager_sdk import ApiException, Device, DevicePage, EntityType, FtdVersion - -from cisco_sccfm_core import FTD_DEVICE_TYPE_FILTER, InventoryService, SccApiError -from cisco_sccfm_core.models.ftd_upgrade_version import FtdGroupCompatibleVersions -from cisco_sccfm_core.services.inventory import FtdUpgradeVersionService - -from ..module_utils.config import base_argument_spec, create_config - DOCUMENTATION = r""" --- module: list_ftd_not_on_version @@ -81,17 +71,14 @@ description: SCCFM region (int, us, eu, apj, au, uae, in, or ci). required: false type: str - env: - - name: SCCFM_REGION api_token: description: API token for SCCFM. required: false type: str - no_log: true - env: - - name: SCCFM_API_TOKEN author: - - Cisco SCCFM Team + - huides00 (@huides00) + - Scoombe (@Scoombe) + - afercal (@afercal) """ EXAMPLES = r""" @@ -189,6 +176,24 @@ type: str """ + +import re +from typing import Any, cast + +from ansible.module_utils.basic import AnsibleModule + +from ..module_utils.config import base_argument_spec, create_config +from ..module_utils.dependencies import record_import_error + +try: + from scc_firewall_manager_sdk import ApiException, Device, DevicePage + + from cisco_sccfm_core import FTD_DEVICE_TYPE_FILTER, InventoryService, SccApiError + from cisco_sccfm_core.services.inventory import FtdUpgradeVersionService +except ImportError as exc: + record_import_error(exc) + + _VERSION_RE = re.compile(r"^\d+\.\d+") diff --git a/sccfm-ansible/plugins/modules/list_managers.py b/sccfm-ansible/plugins/modules/list_managers.py index fdeddc6c..f1f1eac6 100644 --- a/sccfm-ansible/plugins/modules/list_managers.py +++ b/sccfm-ansible/plugins/modules/list_managers.py @@ -1,19 +1,11 @@ # Copyright 2026 Cisco Systems, Inc. and its affiliates # # SPDX-License-Identifier: Apache-2.0 +# flake8: noqa: E402 +# isort: skip_file from __future__ import annotations -from typing import Any - -from ansible.module_utils.basic import AnsibleModule -from scc_firewall_manager_sdk import ApiException, DevicePage - -from cisco_sccfm_core import SccApiError -from cisco_sccfm_core.services.inventory import InventoryService - -from ..module_utils.config import base_argument_spec, create_config - DOCUMENTATION = r""" --- module: list_managers @@ -41,17 +33,14 @@ description: SCCFM region (int, us, eu, apj, au, uae, in, or ci). required: false type: str - env: - - name: SCCFM_REGION api_token: description: API token for SCCFM. required: false type: str - no_log: true - env: - - name: SCCFM_API_TOKEN author: - - Cisco SCCFM Team + - huides00 (@huides00) + - Scoombe (@Scoombe) + - afercal (@afercal) """ EXAMPLES = r""" @@ -130,6 +119,22 @@ """ +from typing import Any + +from ansible.module_utils.basic import AnsibleModule + +from ..module_utils.config import base_argument_spec, create_config +from ..module_utils.dependencies import record_import_error + +try: + from scc_firewall_manager_sdk import ApiException, DevicePage + + from cisco_sccfm_core import SccApiError + from cisco_sccfm_core.services.inventory import InventoryService +except ImportError as exc: + record_import_error(exc) + + def build_argument_spec() -> dict[str, dict[str, Any]]: return { "query": {"type": "str", "required": False, "default": None}, diff --git a/sccfm-ansible/plugins/modules/list_network_groups.py b/sccfm-ansible/plugins/modules/list_network_groups.py index 6d9c6644..92fcbffd 100644 --- a/sccfm-ansible/plugins/modules/list_network_groups.py +++ b/sccfm-ansible/plugins/modules/list_network_groups.py @@ -1,22 +1,11 @@ # Copyright 2026 Cisco Systems, Inc. and its affiliates # # SPDX-License-Identifier: Apache-2.0 +# flake8: noqa: E402 +# isort: skip_file from __future__ import annotations -from typing import Any - -from ansible.module_utils.basic import AnsibleModule -from scc_firewall_manager_sdk import ApiException - -from cisco_sccfm_core.errors import SccApiError -from cisco_sccfm_core.services.object_management import ( - NetworkGroupListResponse, - NetworkGroupService, -) - -from ..module_utils.config import Config, base_argument_spec, create_config - DOCUMENTATION = r""" --- module: list_network_groups @@ -48,17 +37,14 @@ description: SCCFM region (int, us, eu, apj, au, uae, in, or ci). required: false type: str - env: - - name: SCCFM_REGION api_token: description: API token for SCCFM. required: false type: str - no_log: true - env: - - name: SCCFM_API_TOKEN author: - - Cisco SCCFM Team + - huides00 (@huides00) + - Scoombe (@Scoombe) + - afercal (@afercal) """ EXAMPLES = r""" @@ -146,6 +132,25 @@ """ +from typing import Any + +from ansible.module_utils.basic import AnsibleModule + +from ..module_utils.config import Config, base_argument_spec, create_config +from ..module_utils.dependencies import record_import_error + +try: + from scc_firewall_manager_sdk import ApiException + + from cisco_sccfm_core.errors import SccApiError + from cisco_sccfm_core.services.object_management import ( + NetworkGroupListResponse, + NetworkGroupService, + ) +except ImportError as exc: + record_import_error(exc) + + def build_argument_spec() -> dict[str, dict[str, Any]]: return { "query": {"type": "str", "required": False, "default": None}, diff --git a/sccfm-ansible/plugins/modules/list_network_objects.py b/sccfm-ansible/plugins/modules/list_network_objects.py index 44a0fa54..cf0105a4 100644 --- a/sccfm-ansible/plugins/modules/list_network_objects.py +++ b/sccfm-ansible/plugins/modules/list_network_objects.py @@ -1,22 +1,11 @@ # Copyright 2026 Cisco Systems, Inc. and its affiliates # # SPDX-License-Identifier: Apache-2.0 +# flake8: noqa: E402 +# isort: skip_file from __future__ import annotations -from typing import Any - -from ansible.module_utils.basic import AnsibleModule -from scc_firewall_manager_sdk import ApiException - -from cisco_sccfm_core.errors import SccApiError -from cisco_sccfm_core.services.object_management import ( - NetworkObjectListResponse, - NetworkObjectService, -) - -from ..module_utils.config import Config, base_argument_spec, create_config - DOCUMENTATION = r""" --- module: list_network_objects @@ -48,17 +37,14 @@ description: SCCFM region (int, us, eu, apj, au, uae, in, or ci). required: false type: str - env: - - name: SCCFM_REGION api_token: description: API token for SCCFM. required: false type: str - no_log: true - env: - - name: SCCFM_API_TOKEN author: - - Cisco SCCFM Team + - huides00 (@huides00) + - Scoombe (@Scoombe) + - afercal (@afercal) """ EXAMPLES = r""" @@ -143,6 +129,25 @@ """ +from typing import Any + +from ansible.module_utils.basic import AnsibleModule + +from ..module_utils.config import Config, base_argument_spec, create_config +from ..module_utils.dependencies import record_import_error + +try: + from scc_firewall_manager_sdk import ApiException + + from cisco_sccfm_core.errors import SccApiError + from cisco_sccfm_core.services.object_management import ( + NetworkObjectListResponse, + NetworkObjectService, + ) +except ImportError as exc: + record_import_error(exc) + + def build_argument_spec() -> dict[str, dict[str, Any]]: return { "query": {"type": "str", "required": False, "default": None}, diff --git a/sccfm-ansible/plugins/modules/onboard_asa.py b/sccfm-ansible/plugins/modules/onboard_asa.py index 5353374d..230767f2 100644 --- a/sccfm-ansible/plugins/modules/onboard_asa.py +++ b/sccfm-ansible/plugins/modules/onboard_asa.py @@ -1,27 +1,11 @@ # Copyright 2026 Cisco Systems, Inc. and its affiliates # # SPDX-License-Identifier: Apache-2.0 +# flake8: noqa: E402 +# isort: skip_file from __future__ import annotations -from typing import Optional - -from ansible.module_utils.basic import AnsibleModule -from scc_firewall_manager_sdk import ( - ApiException, - AsaCreateOrUpdateInput, - ConnectorType, - Device, - DevicePage, - Labels, -) - -from cisco_sccfm_core import ASA_DEVICE_TYPE_FILTER, InventoryService, SccApiError -from cisco_sccfm_core.services.inventory import AsaOnboardService -from cisco_sccfm_core.types import ConfigLike - -from ..module_utils.config import Config, base_argument_spec, create_config - DOCUMENTATION = r""" --- module: onboard_asa @@ -45,7 +29,6 @@ description: Password used to authenticate with the device. required: true type: str - no_log: true connector_type: description: Connector type used to communicate with the device. required: true @@ -74,17 +57,14 @@ description: SCCFM region (int, us, eu, apj, au, uae, in, or ci). required: false type: str - env: - - name: SCCFM_REGION api_token: description: API token for SCCFM. required: false type: str - no_log: true - env: - - name: SCCFM_API_TOKEN author: - - Cisco SCCFM Team + - huides00 (@huides00) + - Scoombe (@Scoombe) + - afercal (@afercal) """ EXAMPLES = r""" @@ -142,6 +122,30 @@ """ +from typing import Optional + +from ansible.module_utils.basic import AnsibleModule + +from ..module_utils.config import Config, base_argument_spec, create_config +from ..module_utils.dependencies import record_import_error + +try: + from scc_firewall_manager_sdk import ( + ApiException, + AsaCreateOrUpdateInput, + ConnectorType, + Device, + DevicePage, + Labels, + ) + + from cisco_sccfm_core import ASA_DEVICE_TYPE_FILTER, InventoryService, SccApiError + from cisco_sccfm_core.services.inventory import AsaOnboardService + from cisco_sccfm_core.types import ConfigLike +except ImportError as exc: + record_import_error(exc) + + def build_argument_spec() -> dict[str, dict[str, str | bool | list[str]]]: return { "name": {"type": "str", "required": True}, @@ -151,7 +155,7 @@ def build_argument_spec() -> dict[str, dict[str, str | bool | list[str]]]: "connector_type": { "type": "str", "required": True, - "choices": [ConnectorType.CDG, ConnectorType.SDC], + "choices": ["CDG", "SDC"], }, "connector_name": {"type": "str", "required": False}, "ignore_certificate": {"type": "bool", "required": False, "default": False}, diff --git a/sccfm-ansible/plugins/modules/onboard_cdfmc_ftd.py b/sccfm-ansible/plugins/modules/onboard_cdfmc_ftd.py index 1e052a77..841c464f 100644 --- a/sccfm-ansible/plugins/modules/onboard_cdfmc_ftd.py +++ b/sccfm-ansible/plugins/modules/onboard_cdfmc_ftd.py @@ -1,27 +1,11 @@ # Copyright 2026 Cisco Systems, Inc. and its affiliates # # SPDX-License-Identifier: Apache-2.0 +# flake8: noqa: E402 +# isort: skip_file from __future__ import annotations -from typing import Optional - -from ansible.module_utils.basic import AnsibleModule -from scc_firewall_manager_sdk import ( - ApiException, - Device, - DevicePage, - EntityType, - FtdCreateOrUpdateInput, - Labels, -) - -from cisco_sccfm_core import InventoryService, SccApiError -from cisco_sccfm_core.services.inventory import FtdOnboardService -from cisco_sccfm_core.types import ConfigLike - -from ..module_utils.config import Config, base_argument_spec, create_config - DOCUMENTATION = r""" --- module: onboard_cdfmc_ftd @@ -74,17 +58,14 @@ description: SCCFM region (int, us, eu, apj, au, uae, in, or ci). required: false type: str - env: - - name: SCCFM_REGION api_token: description: API token for SCCFM. required: false type: str - no_log: true - env: - - name: SCCFM_API_TOKEN author: - - Cisco SCCFM Team + - huides00 (@huides00) + - Scoombe (@Scoombe) + - afercal (@afercal) """ EXAMPLES = r""" @@ -154,6 +135,30 @@ """ +from typing import Optional + +from ansible.module_utils.basic import AnsibleModule + +from ..module_utils.config import Config, base_argument_spec, create_config +from ..module_utils.dependencies import record_import_error + +try: + from scc_firewall_manager_sdk import ( + ApiException, + Device, + DevicePage, + EntityType, + FtdCreateOrUpdateInput, + Labels, + ) + + from cisco_sccfm_core import InventoryService, SccApiError + from cisco_sccfm_core.services.inventory import FtdOnboardService + from cisco_sccfm_core.types import ConfigLike +except ImportError as exc: + record_import_error(exc) + + _VALID_LICENSES = ["BASE", "CARRIER", "THREAT", "MALWARE", "URLFilter"] _VALID_PERFORMANCE_TIERS = ["FTDv5", "FTDv10", "FTDv20", "FTDv30", "FTDv50", "FTDv100", "FTDv"] @@ -162,7 +167,12 @@ def build_argument_spec() -> dict: return { "name": {"type": "str", "required": True}, "fmc_access_policy_uid": {"type": "str", "required": True}, - "licenses": {"type": "list", "elements": "str", "required": True}, + "licenses": { + "type": "list", + "elements": "str", + "required": True, + "choices": _VALID_LICENSES, + }, "virtual": {"type": "bool", "required": False, "default": False}, "performance_tier": {"type": "str", "required": False, "choices": _VALID_PERFORMANCE_TIERS}, "grouped_labels": {"type": "dict", "required": False}, diff --git a/sccfm-ansible/plugins/modules/onboard_cdfmc_ftd_ztp.py b/sccfm-ansible/plugins/modules/onboard_cdfmc_ftd_ztp.py index 8c354d5b..3ad0f31c 100644 --- a/sccfm-ansible/plugins/modules/onboard_cdfmc_ftd_ztp.py +++ b/sccfm-ansible/plugins/modules/onboard_cdfmc_ftd_ztp.py @@ -1,26 +1,11 @@ # Copyright 2026 Cisco Systems, Inc. and its affiliates # # SPDX-License-Identifier: Apache-2.0 +# flake8: noqa: E402 +# isort: skip_file from __future__ import annotations -from typing import Optional - -from ansible.module_utils.basic import AnsibleModule -from scc_firewall_manager_sdk import ( - ApiException, - Device, - DevicePage, - EntityType, - ZtpOnboardingInput, -) - -from cisco_sccfm_core import InventoryService, SccApiError -from cisco_sccfm_core.services.inventory import FtdZtpOnboardService -from cisco_sccfm_core.types import ConfigLike - -from ..module_utils.config import Config, base_argument_spec, create_config - DOCUMENTATION = r""" --- module: onboard_cdfmc_ftd_ztp @@ -64,7 +49,6 @@ - Required if a password has not already been set on the device. required: false type: str - no_log: true device_group_uid: description: UUID of the device group the device will join after registration. required: false @@ -73,17 +57,14 @@ description: SCCFM region (int, us, eu, apj, au, uae, in, or ci). required: false type: str - env: - - name: SCCFM_REGION api_token: description: API token for SCCFM. required: false type: str - no_log: true - env: - - name: SCCFM_API_TOKEN author: - - Cisco SCCFM Team + - huides00 (@huides00) + - Scoombe (@Scoombe) + - afercal (@afercal) """ EXAMPLES = r""" @@ -137,6 +118,30 @@ type: str """ + +from typing import Optional + +from ansible.module_utils.basic import AnsibleModule + +from ..module_utils.config import Config, base_argument_spec, create_config +from ..module_utils.dependencies import record_import_error + +try: + from scc_firewall_manager_sdk import ( + ApiException, + Device, + DevicePage, + EntityType, + ZtpOnboardingInput, + ) + + from cisco_sccfm_core import InventoryService, SccApiError + from cisco_sccfm_core.services.inventory import FtdZtpOnboardService + from cisco_sccfm_core.types import ConfigLike +except ImportError as exc: + record_import_error(exc) + + _VALID_LICENSES = ["BASE", "CARRIER", "THREAT", "MALWARE", "URLFilter"] @@ -144,7 +149,12 @@ def build_argument_spec() -> dict: return { "name": {"type": "str", "required": True}, "serial_number": {"type": "str", "required": True}, - "licenses": {"type": "list", "elements": "str", "required": True}, + "licenses": { + "type": "list", + "elements": "str", + "required": True, + "choices": _VALID_LICENSES, + }, "fmc_access_policy_uid": {"type": "str", "required": True}, "admin_password": {"type": "str", "required": False, "no_log": True}, "device_group_uid": {"type": "str", "required": False}, diff --git a/sccfm-ansible/plugins/modules/register_cdfmc_ftd.py b/sccfm-ansible/plugins/modules/register_cdfmc_ftd.py index 0d27e057..cea00769 100644 --- a/sccfm-ansible/plugins/modules/register_cdfmc_ftd.py +++ b/sccfm-ansible/plugins/modules/register_cdfmc_ftd.py @@ -1,19 +1,11 @@ # Copyright 2026 Cisco Systems, Inc. and its affiliates # # SPDX-License-Identifier: Apache-2.0 +# flake8: noqa: E402 +# isort: skip_file from __future__ import annotations -from typing import Any - -from ansible.module_utils.basic import AnsibleModule -from scc_firewall_manager_sdk import ApiException, Device - -from cisco_sccfm_core import SccApiError -from cisco_sccfm_core.services.inventory import FtdRegisterService - -from ..module_utils.config import Config, base_argument_spec, create_config - DOCUMENTATION = r""" --- module: register_cdfmc_ftd @@ -39,18 +31,15 @@ - The SCC Firewall Manager region. required: false type: str - env: - - name: SCCFM_REGION api_token: description: - The SCC Firewall Manager API token. required: false type: str - no_log: true - env: - - name: SCCFM_API_TOKEN author: - - Cisco SCCFM Team + - huides00 (@huides00) + - Scoombe (@Scoombe) + - afercal (@afercal) """ EXAMPLES = r""" @@ -76,6 +65,22 @@ """ +from typing import Any + +from ansible.module_utils.basic import AnsibleModule + +from ..module_utils.config import Config, base_argument_spec, create_config +from ..module_utils.dependencies import record_import_error + +try: + from scc_firewall_manager_sdk import ApiException, Device + + from cisco_sccfm_core import SccApiError + from cisco_sccfm_core.services.inventory import FtdRegisterService +except ImportError as exc: + record_import_error(exc) + + def build_argument_spec() -> dict[str, dict[str, Any]]: return { "ftd_uid": {"type": "str", "required": True}, diff --git a/sccfm-ansible/plugins/modules/remove_asa_shun.py b/sccfm-ansible/plugins/modules/remove_asa_shun.py index 84aa9289..b5f35e20 100644 --- a/sccfm-ansible/plugins/modules/remove_asa_shun.py +++ b/sccfm-ansible/plugins/modules/remove_asa_shun.py @@ -1,18 +1,11 @@ # Copyright 2026 Cisco Systems, Inc. and its affiliates # # SPDX-License-Identifier: Apache-2.0 +# flake8: noqa: E402 +# isort: skip_file from __future__ import annotations -from typing import Any, cast - -from ansible.module_utils.basic import AnsibleModule -from scc_firewall_manager_sdk import ApiException, CdoCliResult, CdoTransaction, DevicePage - -from cisco_sccfm_core import ASA_DEVICE_TYPE_FILTER, AsaShunService, InventoryService, SccApiError - -from ..module_utils.config import base_argument_spec, create_config - DOCUMENTATION = r""" --- module: remove_asa_shun @@ -74,17 +67,14 @@ description: SCCFM region (int, us, eu, apj, au, uae, in, or ci). required: false type: str - env: - - name: SCCFM_REGION api_token: description: API token for SCCFM. required: false type: str - no_log: true - env: - - name: SCCFM_API_TOKEN author: - - Cisco SCCFM Team + - huides00 (@huides00) + - Scoombe (@Scoombe) + - afercal (@afercal) """ EXAMPLES = r""" @@ -151,6 +141,26 @@ """ +from typing import Any, cast + +from ansible.module_utils.basic import AnsibleModule + +from ..module_utils.config import base_argument_spec, create_config +from ..module_utils.dependencies import record_import_error + +try: + from scc_firewall_manager_sdk import ApiException, CdoCliResult, CdoTransaction, DevicePage + + from cisco_sccfm_core import ( + ASA_DEVICE_TYPE_FILTER, + AsaShunService, + InventoryService, + SccApiError, + ) +except ImportError as exc: + record_import_error(exc) + + def build_argument_spec() -> dict[str, dict[str, Any]]: return { **base_argument_spec(), diff --git a/sccfm-ansible/plugins/modules/remove_network_group_members.py b/sccfm-ansible/plugins/modules/remove_network_group_members.py index 10426ee5..4e2e65aa 100644 --- a/sccfm-ansible/plugins/modules/remove_network_group_members.py +++ b/sccfm-ansible/plugins/modules/remove_network_group_members.py @@ -1,27 +1,11 @@ # Copyright 2026 Cisco Systems, Inc. and its affiliates # # SPDX-License-Identifier: Apache-2.0 +# flake8: noqa: E402 +# isort: skip_file from __future__ import annotations -from typing import Any - -from ansible.module_utils.basic import AnsibleModule -from scc_firewall_manager_sdk import ApiException - -from cisco_sccfm_core.errors import NotFoundError, SccApiError -from cisco_sccfm_core.services.object_management import ( - NetworkGroupMemberMutationResult, - NetworkGroupService, -) - -from ..module_utils.config import ( - Config, - base_argument_spec, - create_config, - identifier_argument_spec, -) - DOCUMENTATION = r""" --- module: remove_network_group_members @@ -53,17 +37,14 @@ description: SCCFM region (int, us, eu, apj, au, uae, in, or ci). required: false type: str - env: - - name: SCCFM_REGION api_token: description: API token for SCCFM. required: false type: str - no_log: true - env: - - name: SCCFM_API_TOKEN author: - - Cisco SCCFM Team + - huides00 (@huides00) + - Scoombe (@Scoombe) + - afercal (@afercal) """ EXAMPLES = r""" @@ -135,6 +116,30 @@ """ +from typing import Any + +from ansible.module_utils.basic import AnsibleModule + +from ..module_utils.config import ( + Config, + base_argument_spec, + create_config, + identifier_argument_spec, +) +from ..module_utils.dependencies import record_import_error + +try: + from scc_firewall_manager_sdk import ApiException + + from cisco_sccfm_core.errors import NotFoundError, SccApiError + from cisco_sccfm_core.services.object_management import ( + NetworkGroupMemberMutationResult, + NetworkGroupService, + ) +except ImportError as exc: + record_import_error(exc) + + def build_argument_spec() -> dict[str, dict[str, Any]]: return { **identifier_argument_spec(), diff --git a/sccfm-ansible/plugins/modules/show_asa_shun.py b/sccfm-ansible/plugins/modules/show_asa_shun.py index eaf5d489..8048e427 100644 --- a/sccfm-ansible/plugins/modules/show_asa_shun.py +++ b/sccfm-ansible/plugins/modules/show_asa_shun.py @@ -1,19 +1,11 @@ # Copyright 2026 Cisco Systems, Inc. and its affiliates # # SPDX-License-Identifier: Apache-2.0 +# flake8: noqa: E402 +# isort: skip_file from __future__ import annotations -from typing import Any, cast - -from ansible.module_utils.basic import AnsibleModule -from scc_firewall_manager_sdk import ApiException, CdoTransaction, DevicePage - -from cisco_sccfm_core import ASA_DEVICE_TYPE_FILTER, AsaShunService, InventoryService, SccApiError -from cisco_sccfm_core.models.asa_shun_entry import AsaShunEntry, AsaShunInterfaceStats - -from ..module_utils.config import base_argument_spec, create_config - DOCUMENTATION = r""" --- module: show_asa_shun @@ -67,17 +59,14 @@ description: SCCFM region (int, us, eu, apj, au, uae, in, or ci). required: false type: str - env: - - name: SCCFM_REGION api_token: description: API token for SCCFM. required: false type: str - no_log: true - env: - - name: SCCFM_API_TOKEN author: - - Cisco SCCFM Team + - huides00 (@huides00) + - Scoombe (@Scoombe) + - afercal (@afercal) """ EXAMPLES = r""" @@ -163,6 +152,27 @@ """ +from typing import Any, cast + +from ansible.module_utils.basic import AnsibleModule + +from ..module_utils.config import base_argument_spec, create_config +from ..module_utils.dependencies import record_import_error + +try: + from scc_firewall_manager_sdk import ApiException, CdoTransaction, DevicePage + + from cisco_sccfm_core import ( + ASA_DEVICE_TYPE_FILTER, + AsaShunService, + InventoryService, + SccApiError, + ) + from cisco_sccfm_core.models.asa_shun_entry import AsaShunEntry, AsaShunInterfaceStats +except ImportError as exc: + record_import_error(exc) + + def build_argument_spec() -> dict[str, dict[str, Any]]: return { **base_argument_spec(), diff --git a/sccfm-ansible/plugins/modules/tests/conftest.py b/sccfm-ansible/plugins/modules/tests/conftest.py index 86937315..5073ccce 100644 --- a/sccfm-ansible/plugins/modules/tests/conftest.py +++ b/sccfm-ansible/plugins/modules/tests/conftest.py @@ -30,14 +30,7 @@ # Set environment variable that Ansible uses for module argument passing os.environ.setdefault("ANSIBLE_MODULE_ARGS", "{}") -# Load config module directly module_utils_path = Path(__file__).parent.parent.parent / "module_utils" -config_path = module_utils_path / "config.py" -spec = importlib.util.spec_from_file_location("config", config_path) -assert spec is not None and spec.loader is not None -config_module = importlib.util.module_from_spec(spec) -sys.modules["config"] = config_module # Add to sys.modules before executing -spec.loader.exec_module(config_module) # Create proper package hierarchy plugins_module = ModuleType("plugins") @@ -55,27 +48,30 @@ module_utils_module.__package__ = "plugins.module_utils" sys.modules["plugins.module_utils"] = module_utils_module -# Add config as a submodule with all exports -config_submodule = ModuleType("plugins.module_utils.config") -config_submodule.Config = config_module.Config -config_submodule.base_argument_spec = config_module.base_argument_spec -config_submodule.identifier_argument_spec = config_module.identifier_argument_spec -config_submodule.create_config = config_module.create_config -config_submodule.__package__ = "plugins.module_utils" -sys.modules["plugins.module_utils.config"] = config_submodule +# Load shared module utilities with their real package names so relative imports work. +dependencies_path = module_utils_path / "dependencies.py" +dependencies_spec = importlib.util.spec_from_file_location( + "plugins.module_utils.dependencies", dependencies_path +) +assert dependencies_spec is not None and dependencies_spec.loader is not None +dependencies_module = importlib.util.module_from_spec(dependencies_spec) +sys.modules["plugins.module_utils.dependencies"] = dependencies_module +dependencies_spec.loader.exec_module(dependencies_module) + +config_path = module_utils_path / "config.py" +spec = importlib.util.spec_from_file_location("plugins.module_utils.config", config_path) +assert spec is not None and spec.loader is not None +config_module = importlib.util.module_from_spec(spec) +sys.modules["plugins.module_utils.config"] = config_module +sys.modules["config"] = config_module +spec.loader.exec_module(config_module) -# Load operations module directly operations_path = module_utils_path / "operations.py" -ops_spec = importlib.util.spec_from_file_location("operations", operations_path) +ops_spec = importlib.util.spec_from_file_location( + "plugins.module_utils.operations", operations_path +) assert ops_spec is not None and ops_spec.loader is not None operations_module = importlib.util.module_from_spec(ops_spec) +sys.modules["plugins.module_utils.operations"] = operations_module sys.modules["operations"] = operations_module ops_spec.loader.exec_module(operations_module) - -# Add operations as a submodule -operations_submodule = ModuleType("plugins.module_utils.operations") -operations_submodule.fetch_object_by_identifier = operations_module.fetch_object_by_identifier -operations_submodule.run_delete_with_idempotency = operations_module.run_delete_with_idempotency -operations_submodule.fields_need_update = operations_module.fields_need_update -operations_submodule.__package__ = "plugins.module_utils" -sys.modules["plugins.module_utils.operations"] = operations_submodule diff --git a/sccfm-ansible/plugins/modules/tests/test_module_utils_config.py b/sccfm-ansible/plugins/modules/tests/test_module_utils_config.py index cf763686..32c6d526 100644 --- a/sccfm-ansible/plugins/modules/tests/test_module_utils_config.py +++ b/sccfm-ansible/plugins/modules/tests/test_module_utils_config.py @@ -4,8 +4,24 @@ from __future__ import annotations +from typing import Any + import pytest from config import Config +from plugins.module_utils import dependencies + + +class _ModuleFailure(RuntimeError): + """Capture a synthetic Ansible module failure.""" + + def __init__(self, payload: dict[str, Any]) -> None: + super().__init__(payload["msg"]) + self.payload = payload + + +class _FakeModule: + def fail_json(self, **kwargs: Any) -> None: + raise _ModuleFailure(kwargs) def test_config_should_normalize_region_case_and_legacy_aliases() -> None: @@ -17,3 +33,20 @@ def test_config_should_normalize_region_case_and_legacy_aliases() -> None: def test_config_should_reject_unknown_regions() -> None: with pytest.raises(ValueError, match="SCCFM region must be one of"): Config(region="mars", api_token="token-xyz") + + +def test_missing_dependency_uses_actionable_ansible_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + dependencies, + "_IMPORT_ERRORS", + [("cisco_sccfm_core", "synthetic import traceback")], + ) + + with pytest.raises(_ModuleFailure) as exc_info: + dependencies.ensure_required_dependencies(_FakeModule()) + + payload = exc_info.value.payload + assert "cisco_sccfm_core" in payload["msg"] + assert payload["exception"] == "synthetic import traceback" diff --git a/sccfm-ansible/plugins/modules/trigger_asa_upgrade.py b/sccfm-ansible/plugins/modules/trigger_asa_upgrade.py index 0973460d..dbf9f6af 100644 --- a/sccfm-ansible/plugins/modules/trigger_asa_upgrade.py +++ b/sccfm-ansible/plugins/modules/trigger_asa_upgrade.py @@ -1,32 +1,11 @@ # Copyright 2026 Cisco Systems, Inc. and its affiliates # # SPDX-License-Identifier: Apache-2.0 +# flake8: noqa: E402 +# isort: skip_file from __future__ import annotations -from typing import Any, cast - -from ansible.module_utils.basic import AnsibleModule -from scc_firewall_manager_sdk import ( - ApiException, - CdoTransaction, - DevicePage, -) - -from cisco_sccfm_core import ASA_DEVICE_TYPE_FILTER, InventoryService, SccApiError -from cisco_sccfm_core.constants import DEFAULT_TRANSACTION_TIMEOUT_SEC -from cisco_sccfm_core.models.cdo_transaction_status import CdoTransactionStatus -from cisco_sccfm_core.services.inventory import ( - AsaUpgradeService, - AsaUpgradeVersionService, - get_asdm_compatibility_info, - is_version_downgrade, -) -from cisco_sccfm_core.services.transaction_service import TransactionService -from cisco_sccfm_core.types import ConfigLike - -from ..module_utils.config import Config, base_argument_spec, create_config - DOCUMENTATION = r""" --- module: trigger_asa_upgrade @@ -124,17 +103,14 @@ description: SCCFM region (int, us, eu, apj, au, uae, in, or ci). required: false type: str - env: - - name: SCCFM_REGION api_token: description: API token for SCCFM. required: false type: str - no_log: true - env: - - name: SCCFM_API_TOKEN author: - - Cisco SCCFM Team + - huides00 (@huides00) + - Scoombe (@Scoombe) + - afercal (@afercal) """ EXAMPLES = r""" @@ -200,6 +176,36 @@ """ +from typing import Any, cast + +from ansible.module_utils.basic import AnsibleModule + +from ..module_utils.config import Config, base_argument_spec, create_config +from ..module_utils.dependencies import record_import_error + +_DEFAULT_TRANSACTION_TIMEOUT_SEC = 3600 + +try: + from scc_firewall_manager_sdk import ( + ApiException, + CdoTransaction, + DevicePage, + ) + + from cisco_sccfm_core import ASA_DEVICE_TYPE_FILTER, InventoryService, SccApiError + from cisco_sccfm_core.models.cdo_transaction_status import CdoTransactionStatus + from cisco_sccfm_core.services.inventory import ( + AsaUpgradeService, + AsaUpgradeVersionService, + get_asdm_compatibility_info, + is_version_downgrade, + ) + from cisco_sccfm_core.services.transaction_service import TransactionService + from cisco_sccfm_core.types import ConfigLike +except ImportError as exc: + record_import_error(exc) + + def build_argument_spec() -> dict[str, dict[str, Any]]: return { "query": {"type": "str", "required": False}, @@ -216,7 +222,7 @@ def build_argument_spec() -> dict[str, dict[str, Any]]: "timeout": { "type": "int", "required": False, - "default": DEFAULT_TRANSACTION_TIMEOUT_SEC, + "default": _DEFAULT_TRANSACTION_TIMEOUT_SEC, }, **base_argument_spec(), } @@ -478,7 +484,7 @@ def run_module() -> None: ignore_maintenance_window: bool = module.params.get("ignore_maintenance_window", False) upgrade_name: str | None = module.params.get("upgrade_name") wait_for_completion: bool = module.params.get("wait", False) - timeout: int = module.params.get("timeout", DEFAULT_TRANSACTION_TIMEOUT_SEC) + timeout: int = module.params.get("timeout", _DEFAULT_TRANSACTION_TIMEOUT_SEC) transaction = _trigger_upgrade( config=config, diff --git a/sccfm-ansible/plugins/modules/trigger_ftd_upgrade.py b/sccfm-ansible/plugins/modules/trigger_ftd_upgrade.py index 567ae8d0..502cb30b 100644 --- a/sccfm-ansible/plugins/modules/trigger_ftd_upgrade.py +++ b/sccfm-ansible/plugins/modules/trigger_ftd_upgrade.py @@ -1,33 +1,11 @@ # Copyright 2026 Cisco Systems, Inc. and its affiliates # # SPDX-License-Identifier: Apache-2.0 +# flake8: noqa: E402 +# isort: skip_file from __future__ import annotations -from typing import Any, cast - -from ansible.module_utils.basic import AnsibleModule -from scc_firewall_manager_sdk import ( - ApiException, - CdoTransaction, - DevicePage, - EntityType, -) - -from cisco_sccfm_core import FTD_DEVICE_TYPE_FILTER, InventoryService, SccApiError -from cisco_sccfm_core.constants import DEFAULT_TRANSACTION_TIMEOUT_SEC -from cisco_sccfm_core.models.cdo_transaction_status import CdoTransactionStatus -from cisco_sccfm_core.services.inventory import ( - FtdUpgradeService, - FtdUpgradeVersionService, - resolve_upgrade_package_uid, -) -from cisco_sccfm_core.services.inventory.asa_upgrade_version_service import is_version_downgrade -from cisco_sccfm_core.services.transaction_service import TransactionService -from cisco_sccfm_core.types import ConfigLike - -from ..module_utils.config import Config, base_argument_spec, create_config - DOCUMENTATION = r""" --- module: trigger_ftd_upgrade @@ -114,17 +92,14 @@ description: SCCFM region (int, us, eu, apj, au, uae, in, or ci). required: false type: str - env: - - name: SCCFM_REGION api_token: description: API token for SCCFM. required: false type: str - no_log: true - env: - - name: SCCFM_API_TOKEN author: - - Cisco SCCFM Team + - huides00 (@huides00) + - Scoombe (@Scoombe) + - afercal (@afercal) """ EXAMPLES = r""" @@ -187,6 +162,36 @@ """ +from typing import Any, cast + +from ansible.module_utils.basic import AnsibleModule + +from ..module_utils.config import base_argument_spec, create_config +from ..module_utils.dependencies import record_import_error + +_DEFAULT_TRANSACTION_TIMEOUT_SEC = 3600 + +try: + from scc_firewall_manager_sdk import ( + ApiException, + CdoTransaction, + DevicePage, + ) + + from cisco_sccfm_core import FTD_DEVICE_TYPE_FILTER, InventoryService, SccApiError + from cisco_sccfm_core.models.cdo_transaction_status import CdoTransactionStatus + from cisco_sccfm_core.services.inventory import ( + FtdUpgradeService, + FtdUpgradeVersionService, + resolve_upgrade_package_uid, + ) + from cisco_sccfm_core.services.inventory.asa_upgrade_version_service import is_version_downgrade + from cisco_sccfm_core.services.transaction_service import TransactionService + from cisco_sccfm_core.types import ConfigLike +except ImportError as exc: + record_import_error(exc) + + def build_argument_spec() -> dict[str, dict[str, Any]]: return { "query": {"type": "str", "required": False}, @@ -201,7 +206,7 @@ def build_argument_spec() -> dict[str, dict[str, Any]]: "timeout": { "type": "int", "required": False, - "default": DEFAULT_TRANSACTION_TIMEOUT_SEC, + "default": _DEFAULT_TRANSACTION_TIMEOUT_SEC, }, **base_argument_spec(), } @@ -380,7 +385,7 @@ def run_module() -> None: ignore_maintenance_window: bool = module.params.get("ignore_maintenance_window", False) upgrade_name: str | None = module.params.get("upgrade_name") wait_for_completion: bool = module.params.get("wait", False) - timeout: int = module.params.get("timeout", DEFAULT_TRANSACTION_TIMEOUT_SEC) + timeout: int = module.params.get("timeout", _DEFAULT_TRANSACTION_TIMEOUT_SEC) transaction = _trigger_upgrade( config=config, diff --git a/sccfm-ansible/plugins/modules/update_access_rule.py b/sccfm-ansible/plugins/modules/update_access_rule.py index d56e6f00..248b0815 100644 --- a/sccfm-ansible/plugins/modules/update_access_rule.py +++ b/sccfm-ansible/plugins/modules/update_access_rule.py @@ -1,19 +1,11 @@ # Copyright 2026 Cisco Systems, Inc. and its affiliates # # SPDX-License-Identifier: Apache-2.0 +# flake8: noqa: E402 +# isort: skip_file from __future__ import annotations -from typing import Any - -from ansible.module_utils.basic import AnsibleModule -from scc_firewall_manager_sdk import ApiException - -from cisco_sccfm_core.errors import SccApiError -from cisco_sccfm_core.services.policy import AccessRuleService - -from ..module_utils.config import Config, base_argument_spec, create_config - DOCUMENTATION = r""" --- module: update_access_rule @@ -77,17 +69,14 @@ description: SCCFM region (int, us, eu, apj, au, uae, in, or ci). required: false type: str - env: - - name: SCCFM_REGION api_token: description: API token for SCCFM. required: false type: str - no_log: true - env: - - name: SCCFM_API_TOKEN author: - - Cisco SCCFM Team + - huides00 (@huides00) + - Scoombe (@Scoombe) + - afercal (@afercal) """ EXAMPLES = r""" @@ -152,6 +141,23 @@ type: dict """ + +from typing import Any + +from ansible.module_utils.basic import AnsibleModule + +from ..module_utils.config import Config, base_argument_spec, create_config +from ..module_utils.dependencies import record_import_error + +try: + from scc_firewall_manager_sdk import ApiException + + from cisco_sccfm_core.errors import SccApiError + from cisco_sccfm_core.services.policy import AccessRuleService +except ImportError as exc: + record_import_error(exc) + + _UPDATE_FIELDS = [ "index", "rule_action", diff --git a/sccfm-ansible/plugins/modules/update_network_group.py b/sccfm-ansible/plugins/modules/update_network_group.py index 6253f640..49d82a76 100644 --- a/sccfm-ansible/plugins/modules/update_network_group.py +++ b/sccfm-ansible/plugins/modules/update_network_group.py @@ -1,25 +1,11 @@ # Copyright 2026 Cisco Systems, Inc. and its affiliates # # SPDX-License-Identifier: Apache-2.0 +# flake8: noqa: E402 +# isort: skip_file from __future__ import annotations -from typing import Any - -from ansible.module_utils.basic import AnsibleModule -from scc_firewall_manager_sdk import ApiException - -from cisco_sccfm_core.errors import NotFoundError, SccApiError -from cisco_sccfm_core.services.object_management import NetworkGroupResponse, NetworkGroupService - -from ..module_utils.config import ( - Config, - base_argument_spec, - create_config, - identifier_argument_spec, -) -from ..module_utils.operations import fetch_object_by_identifier, fields_need_update - DOCUMENTATION = r""" --- module: update_network_group @@ -72,17 +58,14 @@ description: SCCFM region (int, us, eu, apj, au, uae, in, or ci). required: false type: str - env: - - name: SCCFM_REGION api_token: description: API token for SCCFM. required: false type: str - no_log: true - env: - - name: SCCFM_API_TOKEN author: - - Cisco SCCFM Team + - huides00 (@huides00) + - Scoombe (@Scoombe) + - afercal (@afercal) """ EXAMPLES = r""" @@ -159,6 +142,31 @@ """ +from typing import Any + +from ansible.module_utils.basic import AnsibleModule + +from ..module_utils.config import ( + Config, + base_argument_spec, + create_config, + identifier_argument_spec, +) +from ..module_utils.dependencies import record_import_error +from ..module_utils.operations import fetch_object_by_identifier, fields_need_update + +try: + from scc_firewall_manager_sdk import ApiException + + from cisco_sccfm_core.errors import NotFoundError, SccApiError + from cisco_sccfm_core.services.object_management import ( + NetworkGroupResponse, + NetworkGroupService, + ) +except ImportError as exc: + record_import_error(exc) + + def build_argument_spec() -> dict[str, dict[str, Any]]: return { **identifier_argument_spec(), diff --git a/sccfm-ansible/plugins/modules/update_network_object.py b/sccfm-ansible/plugins/modules/update_network_object.py index 3851142e..cc9a557d 100644 --- a/sccfm-ansible/plugins/modules/update_network_object.py +++ b/sccfm-ansible/plugins/modules/update_network_object.py @@ -1,25 +1,11 @@ # Copyright 2026 Cisco Systems, Inc. and its affiliates # # SPDX-License-Identifier: Apache-2.0 +# flake8: noqa: E402 +# isort: skip_file from __future__ import annotations -from typing import Any - -from ansible.module_utils.basic import AnsibleModule -from scc_firewall_manager_sdk import ApiException - -from cisco_sccfm_core.errors import NotFoundError, SccApiError -from cisco_sccfm_core.services.object_management import NetworkObjectResponse, NetworkObjectService - -from ..module_utils.config import ( - Config, - base_argument_spec, - create_config, - identifier_argument_spec, -) -from ..module_utils.operations import fetch_object_by_identifier, fields_need_update - DOCUMENTATION = r""" --- module: update_network_object @@ -71,17 +57,14 @@ description: SCCFM region (int, us, eu, apj, au, uae, in, or ci). required: false type: str - env: - - name: SCCFM_REGION api_token: description: API token for SCCFM. required: false type: str - no_log: true - env: - - name: SCCFM_API_TOKEN author: - - Cisco SCCFM Team + - huides00 (@huides00) + - Scoombe (@Scoombe) + - afercal (@afercal) """ EXAMPLES = r""" @@ -164,6 +147,31 @@ """ +from typing import Any + +from ansible.module_utils.basic import AnsibleModule + +from ..module_utils.config import ( + Config, + base_argument_spec, + create_config, + identifier_argument_spec, +) +from ..module_utils.dependencies import record_import_error +from ..module_utils.operations import fetch_object_by_identifier, fields_need_update + +try: + from scc_firewall_manager_sdk import ApiException + + from cisco_sccfm_core.errors import NotFoundError, SccApiError + from cisco_sccfm_core.services.object_management import ( + NetworkObjectResponse, + NetworkObjectService, + ) +except ImportError as exc: + record_import_error(exc) + + def build_argument_spec() -> dict[str, dict[str, Any]]: return { **identifier_argument_spec(), diff --git a/sccfm-ansible/plugins/modules/update_object_default.py b/sccfm-ansible/plugins/modules/update_object_default.py index a74168b6..76460012 100644 --- a/sccfm-ansible/plugins/modules/update_object_default.py +++ b/sccfm-ansible/plugins/modules/update_object_default.py @@ -1,19 +1,11 @@ # Copyright 2026 Cisco Systems, Inc. and its affiliates # # SPDX-License-Identifier: Apache-2.0 +# flake8: noqa: E402 +# isort: skip_file from __future__ import annotations -from typing import Any - -from ansible.module_utils.basic import AnsibleModule -from scc_firewall_manager_sdk import ApiException - -from cisco_sccfm_core.errors import SccApiError -from cisco_sccfm_core.services.object_management import ObjectOverrideService - -from ..module_utils.config import Config, base_argument_spec, create_config - DOCUMENTATION = r""" --- module: update_object_default @@ -39,17 +31,14 @@ description: SCCFM region (int, us, eu, apj, au, uae, in, or ci). required: false type: str - env: - - name: SCCFM_REGION api_token: description: API token for SCCFM. required: false type: str - no_log: true - env: - - name: SCCFM_API_TOKEN author: - - Cisco SCCFM Team + - huides00 (@huides00) + - Scoombe (@Scoombe) + - afercal (@afercal) """ EXAMPLES = r""" @@ -122,6 +111,22 @@ """ +from typing import Any + +from ansible.module_utils.basic import AnsibleModule + +from ..module_utils.config import Config, base_argument_spec, create_config +from ..module_utils.dependencies import record_import_error + +try: + from scc_firewall_manager_sdk import ApiException + + from cisco_sccfm_core.errors import SccApiError + from cisco_sccfm_core.services.object_management import ObjectOverrideService +except ImportError as exc: + record_import_error(exc) + + def build_argument_spec() -> dict[str, dict[str, Any]]: return { "uid": {"type": "str", "required": True}, diff --git a/sccfm-ansible/plugins/module_utils/builders/__init__.py b/sccfm-ansible/plugins/plugin_utils/__init__.py similarity index 50% rename from sccfm-ansible/plugins/module_utils/builders/__init__.py rename to sccfm-ansible/plugins/plugin_utils/__init__.py index 2c3a50b8..6ed0f466 100644 --- a/sccfm-ansible/plugins/module_utils/builders/__init__.py +++ b/sccfm-ansible/plugins/plugin_utils/__init__.py @@ -1,7 +1,3 @@ # Copyright 2026 Cisco Systems, Inc. and its affiliates # # SPDX-License-Identifier: Apache-2.0 - -from .inventory_host_builder import InventoryHostBuilder - -__all__ = ["InventoryHostBuilder"] diff --git a/sccfm-ansible/plugins/module_utils/builders/inventory_host_builder.py b/sccfm-ansible/plugins/plugin_utils/inventory_host_builder.py similarity index 78% rename from sccfm-ansible/plugins/module_utils/builders/inventory_host_builder.py rename to sccfm-ansible/plugins/plugin_utils/inventory_host_builder.py index e5a9f781..8f4c3d80 100644 --- a/sccfm-ansible/plugins/module_utils/builders/inventory_host_builder.py +++ b/sccfm-ansible/plugins/plugin_utils/inventory_host_builder.py @@ -2,14 +2,28 @@ # # SPDX-License-Identifier: Apache-2.0 +"""Build Ansible inventory hosts from SCCFM device records.""" + from __future__ import annotations +from typing import Protocol + from ansible.inventory.data import InventoryData -from scc_firewall_manager_sdk import Device + + +class DeviceLike(Protocol): + """Device fields consumed while constructing inventory.""" + + uid: str + name: str + device_type: object + connectivity_state: object + config_state: object + software_version: str | None class InventoryHostBuilder: - """Handles the addition of SCCFM devices to an Ansible inventory.""" + """Add SCCFM devices to an Ansible inventory.""" def __init__(self, inventory: InventoryData, region: str) -> None: self._inventory = inventory @@ -18,11 +32,11 @@ def __init__(self, inventory: InventoryData, region: str) -> None: def add_device_host( self, *, - device: Device, + device: DeviceLike, parent_group: str | None, group_by_device_type: bool, ) -> None: - """Add a device to the inventory as a host with appropriate grouping and variables.""" + """Add a device as a host with grouping and SCCFM metadata variables.""" target_group = self._determine_target_group( device=device, parent_group=parent_group, @@ -35,7 +49,7 @@ def add_device_host( def _determine_target_group( self, *, - device: Device, + device: DeviceLike, parent_group: str | None, group_by_device_type: bool, ) -> str | None: @@ -43,7 +57,6 @@ def _determine_target_group( if not group_by_device_type or not device.device_type: return parent_group - # Sanitize group name: replace dots with underscores for valid Ansible group names device_type_group = str(device.device_type).replace("EntityType.", "") self._inventory.add_group(device_type_group) @@ -52,7 +65,7 @@ def _determine_target_group( return device_type_group - def _set_host_variables(self, *, device: Device) -> None: + def _set_host_variables(self, *, device: DeviceLike) -> None: """Set standard SCCFM variables for a host.""" self._inventory.set_variable(device.name, "sccfm_uid", device.uid) self._inventory.set_variable(device.name, "sccfm_name", device.name) diff --git a/sccfm-ansible/plugins/plugin_utils/inventory_loader.py b/sccfm-ansible/plugins/plugin_utils/inventory_loader.py new file mode 100644 index 00000000..0fd11660 --- /dev/null +++ b/sccfm-ansible/plugins/plugin_utils/inventory_loader.py @@ -0,0 +1,72 @@ +# Copyright 2026 Cisco Systems, Inc. and its affiliates +# +# SPDX-License-Identifier: Apache-2.0 + +"""Load SCCFM device records for the inventory plugin.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from ansible.errors import AnsibleParserError + +if TYPE_CHECKING: + from scc_firewall_manager_sdk import Device, DevicePage + + from cisco_sccfm_core.types import ConfigLike + +try: + from scc_firewall_manager_sdk import ApiException + + from cisco_sccfm_core import SccApiError + from cisco_sccfm_core.services import InventoryService +except ImportError as exc: + _DEPENDENCY_IMPORT_ERROR: ImportError | None = exc +else: + _DEPENDENCY_IMPORT_ERROR = None + + +class InventoryLoader: + """Fetch all SCCFM device pages for a dynamic inventory refresh.""" + + def __init__(self, *, config: "ConfigLike", limit: int, query: str | None) -> None: + if _DEPENDENCY_IMPORT_ERROR is not None: + raise AnsibleParserError( + "cisco-sccfm-devkit must be installed on the Ansible controller " + "to use the cisco.sccfm inventory plugin" + ) from _DEPENDENCY_IMPORT_ERROR + + self._config = config + self._limit = limit + self._query = query + self._inventory_service = InventoryService(config) + + def load_devices(self) -> list["Device"]: + """Return all matching devices, translating API failures for Ansible.""" + try: + return self._fetch_all_pages() + except ApiException as exc: + error = SccApiError.from_exception(exc) + raise AnsibleParserError(f"Failed to load SCCFM devices: {error}") from exc + except Exception as exc: + raise AnsibleParserError(f"Failed to load SCCFM devices: {exc}") from exc + + def _fetch_all_pages(self) -> list["Device"]: + devices: list["Device"] = [] + offset = 0 + + while True: + page: "DevicePage" = self._inventory_service.get_devices( + limit=self._limit, + offset=offset, + query=self._query, + ) + page_items = list(page.items or []) + devices.extend(page_items) + + offset += len(page_items) + total_count = page.count or 0 + if not page_items or offset >= total_count: + break + + return devices diff --git a/sccfm-ansible/tests/sanity/ignore-2.20.txt b/sccfm-ansible/tests/sanity/ignore-2.20.txt new file mode 100644 index 00000000..3aeed84b --- /dev/null +++ b/sccfm-ansible/tests/sanity/ignore-2.20.txt @@ -0,0 +1,50 @@ +plugins/inventory/sccfm.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/add_asa_shun.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/add_network_group_members.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/add_object_override.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/apply_object_override_as_default.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/asa_ha_check.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/change_asa_boot_image.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/change_asa_local_password.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/clear_asa_shun.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/configure_manager.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/create_access_rule.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/create_network_group.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/create_network_object.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/delete_access_rule.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/delete_network_group.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/delete_network_object.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/delete_object_override.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/deploy_cdfmc_ftd.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/edit_object_override.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/execute_asa_cli.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/execute_ftd_cli.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/get_access_group.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/get_access_rule.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/get_object.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/list_access_groups.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/list_access_rules.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/list_asa_boot_registry.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/list_asa_compatible_versions.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/list_asa_disk_files.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/list_asa_local_users.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/list_asa_not_on_version.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/list_cdfmc_access_policies.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/list_ftd_compatible_versions.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/list_ftd_not_on_version.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/list_managers.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/list_network_groups.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/list_network_objects.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/onboard_asa.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/onboard_cdfmc_ftd.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/onboard_cdfmc_ftd_ztp.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/register_cdfmc_ftd.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/remove_asa_shun.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/remove_network_group_members.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/show_asa_shun.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/trigger_asa_upgrade.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/trigger_ftd_upgrade.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/update_access_rule.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/update_network_group.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/update_network_object.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/update_object_default.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately diff --git a/sccfm-ansible/tests/sanity/ignore-2.21.txt b/sccfm-ansible/tests/sanity/ignore-2.21.txt new file mode 100644 index 00000000..3aeed84b --- /dev/null +++ b/sccfm-ansible/tests/sanity/ignore-2.21.txt @@ -0,0 +1,50 @@ +plugins/inventory/sccfm.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/add_asa_shun.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/add_network_group_members.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/add_object_override.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/apply_object_override_as_default.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/asa_ha_check.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/change_asa_boot_image.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/change_asa_local_password.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/clear_asa_shun.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/configure_manager.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/create_access_rule.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/create_network_group.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/create_network_object.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/delete_access_rule.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/delete_network_group.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/delete_network_object.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/delete_object_override.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/deploy_cdfmc_ftd.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/edit_object_override.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/execute_asa_cli.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/execute_ftd_cli.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/get_access_group.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/get_access_rule.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/get_object.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/list_access_groups.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/list_access_rules.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/list_asa_boot_registry.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/list_asa_compatible_versions.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/list_asa_disk_files.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/list_asa_local_users.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/list_asa_not_on_version.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/list_cdfmc_access_policies.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/list_ftd_compatible_versions.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/list_ftd_not_on_version.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/list_managers.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/list_network_groups.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/list_network_objects.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/onboard_asa.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/onboard_cdfmc_ftd.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/onboard_cdfmc_ftd_ztp.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/register_cdfmc_ftd.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/remove_asa_shun.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/remove_network_group_members.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/show_asa_shun.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/trigger_asa_upgrade.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/trigger_ftd_upgrade.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/update_access_rule.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/update_network_group.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/update_network_object.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/modules/update_object_default.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately diff --git a/tests/test_ansible_dependency_metadata.py b/tests/test_ansible_dependency_metadata.py index a350e1a6..aaa4a1f7 100644 --- a/tests/test_ansible_dependency_metadata.py +++ b/tests/test_ansible_dependency_metadata.py @@ -52,3 +52,35 @@ def test_supported_ansible_range_matches_development_and_collection_metadata() - ">=2.20,<2.22" ) assert runtime["requires_ansible"] == ">=2.20.0,<2.22.0" + + +def test_runtime_metadata_excludes_unsupported_module_defaults() -> None: + """Keep module defaults in playbooks, not unsupported runtime metadata.""" + runtime = _yaml_mapping(_COLLECTION_ROOT / "meta" / "runtime.yml") + + assert "module_defaults" not in runtime + assert "cisco.sccfm.all" in runtime["action_groups"] + + +def test_galaxy_metadata_describes_the_published_collection() -> None: + """Describe both public plugin types and use one unambiguous license source.""" + galaxy = _yaml_mapping(_COLLECTION_ROOT / "galaxy.yml") + description = galaxy["description"].lower() + + assert "modules" in description + assert "inventory" in description + assert galaxy["license_file"] == "LICENSE" + assert "license" not in galaxy + + +def test_collection_changelog_matches_published_version() -> None: + """Keep Galaxy metadata and both generated changelog forms version-aligned.""" + galaxy = _yaml_mapping(_COLLECTION_ROOT / "galaxy.yml") + changelog = _yaml_mapping(_COLLECTION_ROOT / "changelogs" / "changelog.yaml") + changelog_config = _yaml_mapping(_COLLECTION_ROOT / "changelogs" / "config.yaml") + version = str(galaxy["version"]) + + assert changelog_config["changes_file"] == "changelog.yaml" + assert changelog_config["notesdir"] == "fragments" + assert version in changelog["releases"] + assert f"v{version}" in (_COLLECTION_ROOT / "CHANGELOG.rst").read_text() diff --git a/tests/test_verify_ansible_collection.py b/tests/test_verify_ansible_collection.py index dcc9c9de..6e702a5c 100644 --- a/tests/test_verify_ansible_collection.py +++ b/tests/test_verify_ansible_collection.py @@ -30,6 +30,7 @@ _COLLECTION_VERSION = str(_COLLECTION_METADATA["version"]) _MINIMUM_DIRECTORIES = { + "changelogs", "examples", "examples/group_vars", "examples/group_vars/all", @@ -38,17 +39,28 @@ "plugins/inventory", "plugins/module_utils", "plugins/modules", + "tests", + "tests/sanity", } _MINIMUM_FILES = { + "CHANGELOG.rst": b"Cisco SCCFM Collection Release Notes\n", "LICENSE": b"Apache License\nVersion 2.0, January 2004\n", "README.md": b"# Test collection\n", "__init__.py": b"", + "changelogs/changelog.yaml": b"---\nancestor: null\nreleases: {}\n", + "changelogs/config.yaml": b"---\ntitle: Cisco SCCFM Collection\n", "examples/.vault_pass.example": b"replace-me\n", "examples/group_vars/all/vault.yml.example": b"---\nsccfm_api_token: placeholder\n", "examples/show_devices.yml": b"---\n- name: Synthetic example\n hosts: localhost\n", "meta/execution-environment.yml": b"---\ndependencies:\n python: requirements.txt\n", "meta/runtime.yml": b"requires_ansible: '>=2.20.0,<2.22.0'\n", "requirements.txt": f"cisco-sccfm-devkit=={_VERSION}\n".encode(), + "tests/sanity/ignore-2.20.txt": ( + b"plugins/modules/example.py validate-modules:missing-gplv3-license\n" + ), + "tests/sanity/ignore-2.21.txt": ( + b"plugins/modules/example.py validate-modules:missing-gplv3-license\n" + ), } @@ -160,6 +172,16 @@ def test_verifier_rejects_sensitive_paths(tmp_path: Path, path: str) -> None: verify_collection_artifact(artifact, expected_version=_VERSION) +def test_verifier_rejects_unreviewed_test_content(tmp_path: Path) -> None: + artifact = _build_synthetic_artifact( + tmp_path, + extra_files={"tests/unit/test_live_tenant.py": b"synthetic\n"}, + ) + + with pytest.raises(ArtifactVerificationError, match="unreviewed test policy"): + verify_collection_artifact(artifact, expected_version=_VERSION) + + def test_verifier_rejects_secret_content_without_echoing_it(tmp_path: Path) -> None: synthetic_secret = b"eyJ" + b"a" * 12 + b"." + b"b" * 12 + b"." + b"c" * 12 artifact = _build_synthetic_artifact( From 3c7b98784ef68381d7297b2a16bebb70f8b461f1 Mon Sep 17 00:00:00 2001 From: Andrei Huides Date: Tue, 11 Aug 2026 22:54:03 +0300 Subject: [PATCH 11/19] fix(lh-102436): poetry run in venv --- cisco_sccfm_scripts/setup_ci_environment.sh | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/cisco_sccfm_scripts/setup_ci_environment.sh b/cisco_sccfm_scripts/setup_ci_environment.sh index e9f3e86c..ec9bf723 100755 --- a/cisco_sccfm_scripts/setup_ci_environment.sh +++ b/cisco_sccfm_scripts/setup_ci_environment.sh @@ -101,11 +101,16 @@ create_venv() { source "${VENV_DIR}/bin/activate" python -m pip install --upgrade pip - if ! command -v poetry >/dev/null 2>&1; then - pip install poetry + local 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 + ln -sfn "../.poetry/bin/poetry" "${VENV_DIR}/bin/poetry" - POETRY_VIRTUALENVS_IN_PROJECT=1 poetry install --with dev,build + POETRY_VIRTUALENVS_IN_PROJECT=1 "${poetry_venv}/bin/poetry" install --with dev,build if [[ ! -x "${VENV_DIR}/bin/cz" ]]; then echo "Commitizen did not install correctly." >&2 From 7338f0dc7ad63a87dd9149caadf678ec9dcd41fd Mon Sep 17 00:00:00 2001 From: Andrei Huides Date: Wed, 12 Aug 2026 00:00:14 +0300 Subject: [PATCH 12/19] fix(lh-102436): stabilizes the final package metadata and README --- .cz.yaml | 2 +- .github/workflows/ci.yml | 5 ++ .github/workflows/publish-to-pypi.yml | 5 +- CONTRIBUTING.md | 6 +- INSTALL.md | 2 +- README.md | 6 +- cisco_sccfm_cli/commands/tests/test_schema.py | 8 +- cisco_sccfm_cli/schema.py | 10 +-- .../tests/test_packaging_metadata.py | 21 +++-- .../build_ansible_collection.py | 2 +- cisco_sccfm_scripts/generate_cli_man_docs.py | 2 +- .../verify_python_artifacts.py | 70 ++++++++++++++++- poetry.lock | 4 +- pyproject.toml | 58 ++++++++------ tests/test_ansible_dependency_metadata.py | 2 +- tests/test_development_commands.py | 6 +- tests/test_verify_python_artifacts.py | 77 +++++++++++++++++-- 17 files changed, 215 insertions(+), 71 deletions(-) diff --git a/.cz.yaml b/.cz.yaml index 701740a8..6799643d 100644 --- a/.cz.yaml +++ b/.cz.yaml @@ -1,5 +1,5 @@ commitizen: name: cz_conventional_commits - version_provider: poetry + version_provider: pep621 tag_format: v$version update_changelog_on_bump: false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f4c472df..b9419bf3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -74,6 +74,7 @@ jobs: - name: Lint run: | + poetry check --strict --lock poetry run black --check . poetry run isort --check-only . poetry run mypy \ @@ -117,6 +118,8 @@ jobs: test -f "${SDIST_PATH}" poetry run python -m cisco_sccfm_scripts.verify_python_artifacts \ "${WHEEL_PATH}" "${SDIST_PATH}" + pipx run --spec "twine==6.2.0" twine check --strict \ + "${WHEEL_PATH}" "${SDIST_PATH}" poetry run build-ansible-collection test -f "${COLLECTION_PATH}" poetry run python -m cisco_sccfm_scripts.verify_clean_controller \ @@ -214,6 +217,8 @@ jobs: test -f "${SDIST_PATH}" poetry run python -m cisco_sccfm_scripts.verify_python_artifacts \ "${WHEEL_PATH}" "${SDIST_PATH}" + pipx run --spec "twine==6.2.0" twine check --strict \ + "${WHEEL_PATH}" "${SDIST_PATH}" SMOKE_ROOT="$(mktemp -d "${RUNNER_TEMP}/sccfm-wheel-smoke.XXXXXX")" python -m venv "${SMOKE_ROOT}/venv" SMOKE_PYTHON="${SMOKE_ROOT}/venv/bin/python" diff --git a/.github/workflows/publish-to-pypi.yml b/.github/workflows/publish-to-pypi.yml index 596e6bc8..4fd743a0 100644 --- a/.github/workflows/publish-to-pypi.yml +++ b/.github/workflows/publish-to-pypi.yml @@ -35,7 +35,7 @@ jobs: with open("pyproject.toml", "rb") as pyproject_file: pyproject = tomllib.load(pyproject_file) - print(pyproject["tool"]["poetry"]["version"]) + print(pyproject["project"]["version"]) PY )" @@ -50,7 +50,7 @@ jobs: - name: Install build dependencies run: | python -m pip install --upgrade pip - python -m pip install build + python -m pip install build twine==6.2.0 - name: Build and verify package artifacts env: @@ -75,6 +75,7 @@ jobs: python -m cisco_sccfm_scripts.verify_python_artifacts \ "${WHEEL_PATH}" \ "${SDIST_PATH}" + python -m twine check --strict "${WHEEL_PATH}" "${SDIST_PATH}" - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7e87d4dc..42c516f0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,7 +7,7 @@ addressing your issue, assessing changes, and helping you finalize your pull req we endeavor to review incoming issues and pull requests within 10 days, and will close any lingering issues or pull requests after 60 days of inactivity. -Please note that all of your interactions in the project are subject to our [Code of Conduct](/CODE_OF_CONDUCT.md). This +Please note that all of your interactions in the project are subject to our [Code of Conduct](https://github.com/CiscoDevNet/sccfm-devkit/blob/main/CODE_OF_CONDUCT.md). This includes creation of issues or pull requests, commenting on issues or pull requests, and extends to all interactions in any real-time space e.g., Slack, Discord, etc. @@ -21,13 +21,13 @@ any real-time space e.g., Slack, Discord, etc. ## Reporting Issues Before reporting a new issue, please ensure that the issue was not already reported or fixed by searching through our -[issues list](TODO) +[issues list](https://github.com/CiscoDevNet/sccfm-devkit/issues) When creating a new issue, please be sure to include a **title and clear description**, as much relevant information as possible, and, if possible, a test case. **If you discover a security bug, please do not report it through GitHub. Instead, please see security procedures in -[SECURITY.md](/SECURITY.md).** +[SECURITY.md](https://github.com/CiscoDevNet/sccfm-devkit/blob/main/SECURITY.md).** ## Sending Pull Requests diff --git a/INSTALL.md b/INSTALL.md index 37527608..9a8fa58b 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -199,4 +199,4 @@ The Python and collection versions printed above must be identical. Provide `SCCFM_REGION` and `SCCFM_API_TOKEN` through the controller or execution environment's secret manager. See the collection's [packaged installation, authentication, execution environment, -and example instructions](sccfm-ansible/README.md#installation) for the complete consumer workflow. +and example instructions](https://github.com/CiscoDevNet/sccfm-devkit/blob/main/sccfm-ansible/README.md#installation) for the complete consumer workflow. diff --git a/README.md b/README.md index 964d54cb..92c75730 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ To install or refresh the CLI man pages for local `man sccfm-cli` lookup: install-cli-man-docs ``` -See [docs/README.md](docs/README.md) for generation details. +See [docs/README.md](https://github.com/CiscoDevNet/sccfm-devkit/blob/main/docs/README.md) for generation details. ## Python library @@ -109,7 +109,7 @@ The package root exports the supported public service classes and response model or group variable. Do not use inventory output modes that render vars when your own `group_vars` or `host_vars` contain secrets. - A starter playbook is in `sccfm-ansible/examples/show_devices.yml`; it runs against the SCCFM devices discovered by the inventory plugin. -- Generated Ansible reference docs can be previewed locally with `generate-ansible-docs`; see [docs/README.md](docs/README.md) for details. +- Generated Ansible reference docs can be previewed locally with `generate-ansible-docs`; see [docs/README.md](https://github.com/CiscoDevNet/sccfm-devkit/blob/main/docs/README.md) for details. ## Development @@ -196,4 +196,4 @@ pytest # Rerun tests ## License -Distributed under the Apache 2.0 License. See [LICENSE](LICENSE) for more information. +Distributed under the Apache 2.0 License. See [LICENSE](https://github.com/CiscoDevNet/sccfm-devkit/blob/main/LICENSE) for more information. diff --git a/cisco_sccfm_cli/commands/tests/test_schema.py b/cisco_sccfm_cli/commands/tests/test_schema.py index 92f91912..88272353 100644 --- a/cisco_sccfm_cli/commands/tests/test_schema.py +++ b/cisco_sccfm_cli/commands/tests/test_schema.py @@ -377,11 +377,9 @@ def _commands_by_name(payload: dict[str, Any]) -> dict[str, dict[str, Any]]: def _project_version() -> str: pyproject = Path(__file__).resolve().parents[3] / "pyproject.toml" data = tomllib.loads(pyproject.read_text(encoding="utf-8")) - tool = data["tool"] - assert isinstance(tool, dict) - poetry = tool["poetry"] - assert isinstance(poetry, dict) - version = poetry["version"] + project = data["project"] + assert isinstance(project, dict) + version = project["version"] assert isinstance(version, str) return version diff --git a/cisco_sccfm_cli/schema.py b/cisco_sccfm_cli/schema.py index 1d233887..e1d49333 100644 --- a/cisco_sccfm_cli/schema.py +++ b/cisco_sccfm_cli/schema.py @@ -258,15 +258,11 @@ def _pyproject_version() -> str | None: except (OSError, tomllib.TOMLDecodeError): return None - tool = pyproject.get("tool") - if not isinstance(tool, dict): + project = pyproject.get("project") + if not isinstance(project, dict): return None - poetry = tool.get("poetry") - if not isinstance(poetry, dict): - return None - - project_version = poetry.get("version") + project_version = project.get("version") if not isinstance(project_version, str) or not project_version: return None diff --git a/cisco_sccfm_core/tests/test_packaging_metadata.py b/cisco_sccfm_core/tests/test_packaging_metadata.py index 41119a97..fb3ca7fb 100644 --- a/cisco_sccfm_core/tests/test_packaging_metadata.py +++ b/cisco_sccfm_core/tests/test_packaging_metadata.py @@ -11,14 +11,21 @@ PROJECT_ROOT = Path(__file__).resolve().parents[2] -def _poetry_config() -> dict[str, Any]: +def _pyproject() -> dict[str, Any]: with (PROJECT_ROOT / "pyproject.toml").open("rb") as pyproject_file: - pyproject = tomllib.load(pyproject_file) - return dict(pyproject["tool"]["poetry"]) + return tomllib.load(pyproject_file) + + +def _project_config() -> dict[str, Any]: + return dict(_pyproject()["project"]) + + +def _poetry_config() -> dict[str, Any]: + return dict(_pyproject()["tool"]["poetry"]) def test_distribution_uses_cisco_devkit_name() -> None: - assert _poetry_config()["name"] == "cisco-sccfm-devkit" + assert _project_config()["name"] == "cisco-sccfm-devkit" def test_published_package_contract_is_cli_and_core_only() -> None: @@ -29,7 +36,7 @@ def test_published_package_contract_is_cli_and_core_only() -> None: "cisco_sccfm_cli", "cisco_sccfm_core", } - assert poetry["scripts"] == {"sccfm-cli": "cisco_sccfm_cli.cli:cli"} + assert _project_config()["scripts"] == {"sccfm-cli": "cisco_sccfm_cli.cli:cli"} def test_published_packages_exclude_repository_only_code() -> None: @@ -47,9 +54,7 @@ def test_published_packages_exclude_repository_only_code() -> None: def test_generated_sdk_is_pinned_to_the_verified_compatible_version() -> None: - poetry = _poetry_config() - - assert poetry["dependencies"]["scc-firewall-manager-sdk"] == "1.17.27" + assert "scc-firewall-manager-sdk==1.17.27" in _project_config()["dependencies"] def test_pyinstaller_spec_uses_repository_relative_entrypoint() -> None: diff --git a/cisco_sccfm_scripts/build_ansible_collection.py b/cisco_sccfm_scripts/build_ansible_collection.py index 54464e46..f25e280b 100644 --- a/cisco_sccfm_scripts/build_ansible_collection.py +++ b/cisco_sccfm_scripts/build_ansible_collection.py @@ -88,7 +88,7 @@ def main() -> int: # Read version from pyproject.toml with open(pyproject_path, "rb") as f: pyproject = tomllib.load(f) - version = pyproject["tool"]["poetry"]["version"] + version = pyproject["project"]["version"] print(f"📦 Using version {version} from pyproject.toml") try: diff --git a/cisco_sccfm_scripts/generate_cli_man_docs.py b/cisco_sccfm_scripts/generate_cli_man_docs.py index 9ce08d49..ed8035cb 100644 --- a/cisco_sccfm_scripts/generate_cli_man_docs.py +++ b/cisco_sccfm_scripts/generate_cli_man_docs.py @@ -40,7 +40,7 @@ def _project_root() -> Path: def _project_version(project_root: Path) -> str: with (project_root / "pyproject.toml").open("rb") as pyproject: data = tomllib.load(pyproject) - version = data["tool"]["poetry"]["version"] + version = data["project"]["version"] if not isinstance(version, str): raise RuntimeError("Project version in pyproject.toml must be a string.") return version diff --git a/cisco_sccfm_scripts/verify_python_artifacts.py b/cisco_sccfm_scripts/verify_python_artifacts.py index f8fec417..9a15769e 100644 --- a/cisco_sccfm_scripts/verify_python_artifacts.py +++ b/cisco_sccfm_scripts/verify_python_artifacts.py @@ -8,15 +8,19 @@ import argparse import configparser +import email.policy import io +import re import stat import tarfile import tomllib import zipfile from collections.abc import Sequence from dataclasses import dataclass +from email.parser import BytesParser from pathlib import Path, PurePosixPath from typing import Any +from urllib.parse import urlsplit _DISTRIBUTION_STEM = "cisco_sccfm_devkit" _PACKAGE_ROOTS = frozenset({"cisco_sccfm_cli", "cisco_sccfm_core"}) @@ -24,12 +28,26 @@ { "LICENSE", "LICENSES", + "CHANGELOG.md", + "CONTRIBUTING.md", + "INSTALL.md", "PKG-INFO", "README.md", + "SECURITY.md", "pyproject.toml", } ) +_REQUIRED_SDIST_DOCUMENTS = frozenset( + { + "CHANGELOG.md", + "CONTRIBUTING.md", + "INSTALL.md", + "README.md", + "SECURITY.md", + } +) _EXPECTED_SCRIPTS = {"sccfm-cli": "cisco_sccfm_cli.cli:cli"} +_MARKDOWN_LINK = re.compile(r"!?\[[^\]]*\]\(\s*(?:<(?P[^>]+)>|(?P[^\s)]+))") _FORBIDDEN_DIRECTORY_NAMES = frozenset( { ".cache", @@ -211,14 +229,36 @@ def _verify_entry_points(raw: bytes) -> None: raise PythonArtifactVerificationError("wheel does not expose exactly the sccfm-cli command") +def _verify_markdown_links(text: str, source: str) -> None: + """Reject links that would resolve relative to the PyPI project page.""" + for match in _MARKDOWN_LINK.finditer(text): + target = match.group("angle") or match.group("plain") + if target.startswith("#") or target.startswith("//") or urlsplit(target).scheme: + continue + raise PythonArtifactVerificationError(f"{source} contains a relative Markdown link") + + +def _verify_metadata_description(raw: bytes, source: str) -> None: + """Validate the Markdown long description embedded in package metadata.""" + try: + metadata = BytesParser(policy=email.policy.default).parsebytes(raw) + description = metadata.get_payload() + except (TypeError, ValueError) as exc: + raise PythonArtifactVerificationError(f"{source} is invalid") from exc + if not isinstance(description, str): + raise PythonArtifactVerificationError(f"{source} has an invalid description") + _verify_markdown_links(description, source) + + def _verify_sdist_pyproject(raw: bytes) -> None: """Ensure a wheel rebuilt from the sdist retains the public package policy.""" try: pyproject: dict[str, Any] = tomllib.loads(raw.decode("utf-8")) + project = pyproject["project"] poetry = pyproject["tool"]["poetry"] except (KeyError, TypeError, UnicodeDecodeError, tomllib.TOMLDecodeError) as exc: raise PythonArtifactVerificationError("sdist has invalid Poetry metadata") from exc - if not isinstance(poetry, dict): + if not isinstance(project, dict) or not isinstance(poetry, dict): raise PythonArtifactVerificationError("sdist has invalid Poetry metadata") packages = poetry.get("packages") @@ -235,7 +275,7 @@ def _verify_sdist_pyproject(raw: bytes) -> None: if package_roots != _PACKAGE_ROOTS: raise PythonArtifactVerificationError("sdist declares unexpected package roots") - scripts = poetry.get("scripts") + scripts = project.get("scripts") if scripts != _EXPECTED_SCRIPTS: raise PythonArtifactVerificationError("sdist does not expose exactly the sccfm-cli command") @@ -273,6 +313,10 @@ def _verify_wheel(path: Path, version: str) -> int: if entry_points_name not in members: raise PythonArtifactVerificationError("wheel has no entry-point metadata") _verify_entry_points(archive.read(members[entry_points_name])) + metadata_name = f"{expected_dist_info}/METADATA" + if metadata_name not in members: + raise PythonArtifactVerificationError("wheel has no package metadata") + _verify_metadata_description(archive.read(members[metadata_name]), "wheel metadata") except (OSError, zipfile.BadZipFile) as exc: raise PythonArtifactVerificationError("wheel is not a readable ZIP archive") from exc return len(members) @@ -339,11 +383,33 @@ def _verify_sdist(path: Path, version: str) -> int: raise PythonArtifactVerificationError( "sdist does not contain the expected packages" ) + missing_documents = _REQUIRED_SDIST_DOCUMENTS.difference(relative_members) + if missing_documents: + raise PythonArtifactVerificationError("sdist is missing required project documents") pyproject_name = "pyproject.toml" pyproject_member = relative_members.get(pyproject_name) if pyproject_member is None or not pyproject_member.isfile(): raise PythonArtifactVerificationError("sdist has no pyproject.toml") _verify_sdist_pyproject(_read_tar_member(archive, pyproject_member)) + for document_name in sorted(_REQUIRED_SDIST_DOCUMENTS): + document_member = relative_members[document_name] + if not document_member.isfile(): + raise PythonArtifactVerificationError( + f"sdist project document is not a regular file: {document_name}" + ) + try: + document = _read_tar_member(archive, document_member).decode("utf-8") + except UnicodeDecodeError as exc: + raise PythonArtifactVerificationError( + f"sdist project document is not UTF-8: {document_name}" + ) from exc + _verify_markdown_links(document, f"sdist {document_name}") + package_info_member = relative_members.get("PKG-INFO") + if package_info_member is None or not package_info_member.isfile(): + raise PythonArtifactVerificationError("sdist has no package metadata") + _verify_metadata_description( + _read_tar_member(archive, package_info_member), "sdist package metadata" + ) except (OSError, tarfile.TarError) as exc: raise PythonArtifactVerificationError("sdist is not a readable tar.gz archive") from exc return sum(member.isfile() for member in members.values()) diff --git a/poetry.lock b/poetry.lock index 7c05fa17..44a34f25 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1905,5 +1905,5 @@ files = [ [metadata] lock-version = "2.1" -python-versions = "^3.12" -content-hash = "f1aab5c6a46bff4152589b745913d3857b105ec358fda5999bf9e9263f054ff8" +python-versions = ">=3.12,<4.0" +content-hash = "065539d901211006d76a3c91258f9f22d80916c554a8609dd650980ea3ffdb15" diff --git a/pyproject.toml b/pyproject.toml index f87a3598..8dd33536 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,18 +1,18 @@ -[tool.poetry] +[project] name = "cisco-sccfm-devkit" version = "0.38.0" description = "Cisco SCC Firewall Manager CLI and Python automation library" -authors = ["Cisco Security Cloud Control Firewall Manager Team"] +authors = [{ name = "Cisco Security Cloud Control Firewall Manager Team" }] license = "Apache-2.0" -homepage = "https://github.com/CiscoDevNet/sccfm-devkit" -repository = "https://github.com/CiscoDevNet/sccfm-devkit" +license-files = ["LICENSE", "LICENSES/*"] +readme = "README.md" +requires-python = ">=3.12,<4.0" keywords = ["Cisco", "SCCFM", "Firewall Manager", "CLI", "Ansible", "Security"] classifiers = [ "Development Status :: 4 - Beta", "Environment :: Console", "Intended Audience :: Developers", "Intended Audience :: System Administrators", - "License :: OSI Approved :: Apache Software License", "Natural Language :: English", "Operating System :: OS Independent", "Programming Language :: Python :: 3", @@ -21,11 +21,37 @@ classifiers = [ "Topic :: System :: Networking", "Topic :: System :: Systems Administration", ] -readme = "README.md" +dependencies = [ + "click>=8.3.3,<9", + "rich>=14.2.0,<15", + "click-option-group>=0.5.9,<0.6", + "scc-firewall-manager-sdk==1.17.27", + "paramiko>=5.0.0,<6", + "cryptography>=50.0.0,<51", + "pygments>=2.20.0,<3", +] + +[project.urls] +Homepage = "https://github.com/CiscoDevNet/sccfm-devkit" +Repository = "https://github.com/CiscoDevNet/sccfm-devkit" +Documentation = "https://ciscodevnet.github.io/sccfm-devkit/" +"Bug Tracker" = "https://github.com/CiscoDevNet/sccfm-devkit/issues" +Changelog = "https://github.com/CiscoDevNet/sccfm-devkit/releases" + +[project.scripts] +sccfm-cli = "cisco_sccfm_cli.cli:cli" + +[tool.poetry] packages = [ { include = "cisco_sccfm_cli" }, { include = "cisco_sccfm_core" }, ] +include = [ + { path = "CHANGELOG.md", format = "sdist" }, + { path = "CONTRIBUTING.md", format = "sdist" }, + { path = "INSTALL.md", format = "sdist" }, + { path = "SECURITY.md", format = "sdist" }, +] exclude = [ "cisco_sccfm_scripts", "**/tests", @@ -38,24 +64,6 @@ exclude = [ "**/.DS_Store", ] -[tool.poetry.urls] -"Documentation" = "https://ciscodevnet.github.io/sccfm-devkit/" -"Bug Tracker" = "https://github.com/CiscoDevNet/sccfm-devkit/issues" -"Changelog" = "https://github.com/CiscoDevNet/sccfm-devkit/releases" - -[tool.poetry.dependencies] -python = "^3.12" -click = ">=8.3.3,<9" -rich = "^14.2.0" -click-option-group = "^0.5.9" -scc-firewall-manager-sdk = "1.17.27" -paramiko = ">=5.0.0,<6" -cryptography = ">=50.0.0,<51" -pygments = ">=2.20.0,<3" - -[tool.poetry.scripts] -sccfm-cli = "cisco_sccfm_cli.cli:cli" - [tool.poetry.group.dev.dependencies] cisco-sccfm-devtools = { path = "devtools", develop = true } ansible-core = ">=2.20,<2.22" @@ -75,7 +83,7 @@ questionary = "^2.1.1" pyyaml = "^6.0.0" [build-system] -requires = ["poetry-core"] +requires = ["poetry-core>=2.2.0,<3.0.0"] build-backend = "poetry.core.masonry.api" [tool.black] diff --git a/tests/test_ansible_dependency_metadata.py b/tests/test_ansible_dependency_metadata.py index aaa4a1f7..fd6b3c31 100644 --- a/tests/test_ansible_dependency_metadata.py +++ b/tests/test_ansible_dependency_metadata.py @@ -24,7 +24,7 @@ def _yaml_mapping(path: Path) -> dict[str, Any]: def test_collection_python_requirement_matches_release_versions() -> None: """Require the collection and its Python runtime package to ship in lockstep.""" pyproject = tomllib.loads((_REPOSITORY_ROOT / "pyproject.toml").read_text()) - project_version = pyproject["tool"]["poetry"]["version"] + project_version = pyproject["project"]["version"] galaxy_version = _yaml_mapping(_COLLECTION_ROOT / "galaxy.yml")["version"] requirement_lines = [ line.strip() diff --git a/tests/test_development_commands.py b/tests/test_development_commands.py index 4f50f1c7..c9aac33f 100644 --- a/tests/test_development_commands.py +++ b/tests/test_development_commands.py @@ -51,8 +51,10 @@ def test_devtools_declares_exact_maintainer_commands() -> None: def test_root_declares_devtools_only_as_a_development_dependency() -> None: pyproject = _load_pyproject(PROJECT_ROOT / "pyproject.toml") tool = pyproject["tool"] + project = pyproject["project"] assert isinstance(tool, dict) + assert isinstance(project, dict) poetry = tool["poetry"] assert isinstance(poetry, dict) dependencies = poetry["group"]["dev"]["dependencies"] @@ -60,7 +62,9 @@ def test_root_declares_devtools_only_as_a_development_dependency() -> None: "path": "devtools", "develop": True, } - assert "cisco-sccfm-devtools" not in poetry["dependencies"] + assert all( + not dependency.startswith("cisco-sccfm-devtools") for dependency in project["dependencies"] + ) def test_installed_devtools_entry_points_match_and_load() -> None: diff --git a/tests/test_verify_python_artifacts.py b/tests/test_verify_python_artifacts.py index 0cd7dc4c..b0d9eeb9 100644 --- a/tests/test_verify_python_artifacts.py +++ b/tests/test_verify_python_artifacts.py @@ -21,15 +21,24 @@ _VERSION = "1.2.3" _DIST_INFO = f"cisco_sccfm_devkit-{_VERSION}.dist-info" _ENTRY_POINTS = b"[console_scripts]\nsccfm-cli=cisco_sccfm_cli.cli:cli\n" +_DESCRIPTION = b"# Synthetic package\n\nSee [documentation](https://example.com/docs).\n" +_REQUIRED_PROJECT_DOCUMENTS = { + "CHANGELOG.md", + "CONTRIBUTING.md", + "INSTALL.md", + "SECURITY.md", +} _PYPROJECT = b"""\ +[project] + +[project.scripts] +sccfm-cli = "cisco_sccfm_cli.cli:cli" + [tool.poetry] packages = [ { include = "cisco_sccfm_cli" }, { include = "cisco_sccfm_core" }, ] - -[tool.poetry.scripts] -sccfm-cli = "cisco_sccfm_cli.cli:cli" """ @@ -47,12 +56,19 @@ def _build_artifacts( sdist_extra: Mapping[str, bytes] | None = None, entry_points: bytes = _ENTRY_POINTS, pyproject: bytes = _PYPROJECT, + wheel_description: bytes = _DESCRIPTION, + sdist_description: bytes = _DESCRIPTION, + omitted_sdist_files: frozenset[str] = frozenset(), ) -> tuple[Path, Path]: wheel = tmp_path / f"cisco_sccfm_devkit-{_VERSION}-py3-none-any.whl" wheel_files = { "cisco_sccfm_cli/__init__.py": b"", "cisco_sccfm_core/__init__.py": b"", - f"{_DIST_INFO}/METADATA": b"Name: cisco-sccfm-devkit\nVersion: 1.2.3\n", + f"{_DIST_INFO}/METADATA": ( + b"Name: cisco-sccfm-devkit\n" + b"Version: 1.2.3\n" + b"Description-Content-Type: text/markdown\n\n" + wheel_description + ), f"{_DIST_INFO}/WHEEL": b"Wheel-Version: 1.0\n", f"{_DIST_INFO}/entry_points.txt": entry_points, f"{_DIST_INFO}/RECORD": b"", @@ -67,8 +83,16 @@ def _build_artifacts( sdist_files = { "LICENSE": b"Apache-2.0\n", "LICENSES/Apache-2.0.txt": b"Apache-2.0\n", - "PKG-INFO": b"Name: cisco-sccfm-devkit\nVersion: 1.2.3\n", - "README.md": b"# Synthetic package\n", + "CHANGELOG.md": b"# Changelog\n", + "CONTRIBUTING.md": b"# Contributing\n", + "INSTALL.md": b"# Installation\n", + "PKG-INFO": ( + b"Name: cisco-sccfm-devkit\n" + b"Version: 1.2.3\n" + b"Description-Content-Type: text/markdown\n\n" + sdist_description + ), + "README.md": sdist_description, + "SECURITY.md": b"# Security\n", "cisco_sccfm_cli/__init__.py": b"", "cisco_sccfm_core/__init__.py": b"", "pyproject.toml": pyproject, @@ -76,6 +100,8 @@ def _build_artifacts( } with tarfile.open(sdist, mode="w:gz") as archive: for name, content in sdist_files.items(): + if name in omitted_sdist_files: + continue _write_tar_file(archive, f"{prefix}/{name}", content) return wheel, sdist @@ -86,7 +112,7 @@ def test_verifier_accepts_public_artifact_pair(tmp_path: Path) -> None: result = verify_python_artifacts(wheel, sdist) assert result.wheel_files == 6 - assert result.sdist_files == 7 + assert result.sdist_files == 11 def test_wheel_verifier_accepts_public_wheel_without_sdist(tmp_path: Path) -> None: @@ -136,7 +162,11 @@ def test_verifier_rejects_additional_wheel_entry_point(tmp_path: Path) -> None: def test_verifier_rejects_additional_sdist_entry_point(tmp_path: Path) -> None: - pyproject = _PYPROJECT + b'devkit = "cisco_sccfm_scripts.devkit_cli:main"\n' + pyproject = _PYPROJECT.replace( + b'sccfm-cli = "cisco_sccfm_cli.cli:cli"\n', + b'sccfm-cli = "cisco_sccfm_cli.cli:cli"\n' + b'devkit = "cisco_sccfm_scripts.devkit_cli:main"\n', + ) wheel, sdist = _build_artifacts(tmp_path, pyproject=pyproject) with pytest.raises(PythonArtifactVerificationError, match="exactly the sccfm-cli"): @@ -161,3 +191,34 @@ def test_verifier_rejects_mismatched_versions(tmp_path: Path) -> None: with pytest.raises(PythonArtifactVerificationError, match="versions do not match"): verify_python_artifacts(wheel, renamed_sdist) + + +@pytest.mark.parametrize("document", sorted(_REQUIRED_PROJECT_DOCUMENTS)) +def test_verifier_rejects_missing_sdist_document(tmp_path: Path, document: str) -> None: + wheel, sdist = _build_artifacts( + tmp_path, + omitted_sdist_files=frozenset({document}), + ) + + with pytest.raises(PythonArtifactVerificationError, match="required project documents"): + verify_python_artifacts(wheel, sdist) + + +def test_verifier_rejects_relative_link_in_wheel_description(tmp_path: Path) -> None: + wheel, sdist = _build_artifacts( + tmp_path, + wheel_description=b"See [documentation](docs/README.md).\n", + ) + + with pytest.raises(PythonArtifactVerificationError, match="relative Markdown link"): + verify_python_artifacts(wheel, sdist) + + +def test_verifier_rejects_relative_link_in_sdist_description(tmp_path: Path) -> None: + wheel, sdist = _build_artifacts( + tmp_path, + sdist_description=b"See [license](LICENSE).\n", + ) + + with pytest.raises(PythonArtifactVerificationError, match="relative Markdown link"): + verify_python_artifacts(wheel, sdist) From e58ecbb64bc32d974c4c5d9287ab8b5d4e815f6c Mon Sep 17 00:00:00 2001 From: Andrei Huides Date: Wed, 12 Aug 2026 11:14:03 +0300 Subject: [PATCH 13/19] fix(lh-102436): add manual build-once release workflow --- .github/workflows/ci.yml | 194 +---- .github/workflows/publish-to-pypi.yml | 84 -- .github/workflows/release.yml | 799 ++++++++++++++++++ CONTRIBUTING.md | 3 + RELEASING.md | 95 +++ .../prepare_ansible_release.py | 374 ++++++++ cisco_sccfm_scripts/release_artifacts.py | 294 +++++++ cisco_sccfm_scripts/verify_pypi_release.py | 244 ++++++ .../verify_python_artifacts.py | 58 +- tests/test_prepare_ansible_release.py | 266 ++++++ tests/test_release_artifacts.py | 236 ++++++ tests/test_verify_pypi_release.py | 320 +++++++ tests/test_verify_python_artifacts.py | 77 +- 13 files changed, 2750 insertions(+), 294 deletions(-) delete mode 100644 .github/workflows/publish-to-pypi.yml create mode 100644 .github/workflows/release.yml create mode 100644 RELEASING.md create mode 100644 cisco_sccfm_scripts/prepare_ansible_release.py create mode 100644 cisco_sccfm_scripts/release_artifacts.py create mode 100644 cisco_sccfm_scripts/verify_pypi_release.py create mode 100644 tests/test_prepare_ansible_release.py create mode 100644 tests/test_release_artifacts.py create mode 100644 tests/test_verify_pypi_release.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b9419bf3..3b4a3bcf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -81,8 +81,11 @@ jobs: cisco_sccfm_cli \ cisco_sccfm_core \ cisco_sccfm_scripts/build_ansible_collection.py \ + cisco_sccfm_scripts/prepare_ansible_release.py \ + cisco_sccfm_scripts/release_artifacts.py \ cisco_sccfm_scripts/verify_ansible_collection.py \ cisco_sccfm_scripts/verify_clean_controller.py \ + cisco_sccfm_scripts/verify_pypi_release.py \ cisco_sccfm_scripts/verify_python_artifacts.py - name: Test @@ -106,7 +109,7 @@ jobs: ANSIBLE_LOCAL_TEMP="${SANITY_ROOT}/local" \ "${VENV_PATH}/bin/ansible-test" sanity --local --truncate 0 - - name: Build and verify release artifacts + - name: Build and verify release candidates run: | set -euo pipefail poetry build @@ -124,192 +127,3 @@ jobs: test -f "${COLLECTION_PATH}" poetry run python -m cisco_sccfm_scripts.verify_clean_controller \ "${WHEEL_PATH}" "${COLLECTION_PATH}" --expected-version "${PACKAGE_VERSION}" - - release: - needs: lint-and-test - if: github.ref == 'refs/heads/main' - runs-on: ubuntu-latest - environment: release-bot - permissions: - contents: write - steps: - - name: Checkout - uses: actions/checkout@v7 - with: - fetch-depth: 0 - ssh-key: ${{ secrets.SCCFM_CI_DEPLOY_KEY }} - - - name: Set up Python - uses: actions/setup-python@v7 - with: - python-version: "3.12" - - - name: Install pipx and Poetry - run: | - python -m pip install --upgrade pip pipx - python -m pipx ensurepath - echo "$HOME/.local/bin" >> "$GITHUB_PATH" - pipx install poetry - pipx install commitizen - - - name: Install dependencies - run: poetry install --no-interaction --with dev,build - - - name: Bump version and build collection - id: bump - run: | - set -e - git config user.name "github-actions" - git config user.email "github-actions@users.noreply.cisco.com" - - # Check if version bump is needed - cz bump --dry-run --yes --changelog || exit 0 - - # Bump version in files only (no commit/tag yet) - cz bump --yes --changelog --files-only - NEW_VERSION=$(poetry version -s) - NEW_TAG="v${NEW_VERSION}" - - # Build Ansible collection with updated version - poetry run build-ansible-collection - ARTIFACT_PATH="dist/cisco-sccfm-${NEW_VERSION}.tar.gz" - - # Verify the exact artifact that will be attached to the release. - poetry run python -m cisco_sccfm_scripts.verify_ansible_collection \ - "${ARTIFACT_PATH}" --expected-version "${NEW_VERSION}" - ARTIFACT_SHA256=$(sha256sum "${ARTIFACT_PATH}" | cut -d ' ' -f 1) - - echo "bumped=true" >> "$GITHUB_OUTPUT" - echo "new_tag=${NEW_TAG}" >> "$GITHUB_OUTPUT" - echo "artifact_path=${ARTIFACT_PATH}" >> "$GITHUB_OUTPUT" - echo "artifact_sha256=${ARTIFACT_SHA256}" >> "$GITHUB_OUTPUT" - - - name: Install pinned Gitleaks - if: steps.bump.outputs.bumped == 'true' - run: | - GITLEAKS_BIN_DIR="${RUNNER_TEMP}/gitleaks-bin" - mkdir -p "${GITLEAKS_BIN_DIR}" - GOBIN="${GITLEAKS_BIN_DIR}" go install github.com/gitleaks/gitleaks/v8@v8.30.1 - echo "${GITLEAKS_BIN_DIR}" >> "$GITHUB_PATH" - - - name: Scan exact collection artifact - if: steps.bump.outputs.bumped == 'true' - run: | - gitleaks dir \ - --no-banner \ - --no-color \ - --redact=100 \ - --max-archive-depth=1 \ - "${{ steps.bump.outputs.artifact_path }}" - - - name: Build and verify Python artifacts - if: steps.bump.outputs.bumped == 'true' - id: wheel - run: | - set -euo pipefail - poetry build - PACKAGE_VERSION="$(poetry version -s)" - WHEEL_RELATIVE_PATH="dist/cisco_sccfm_devkit-${PACKAGE_VERSION}-py3-none-any.whl" - SDIST_RELATIVE_PATH="dist/cisco_sccfm_devkit-${PACKAGE_VERSION}.tar.gz" - WHEEL_PATH="${GITHUB_WORKSPACE}/${WHEEL_RELATIVE_PATH}" - SDIST_PATH="${GITHUB_WORKSPACE}/${SDIST_RELATIVE_PATH}" - test -f "${WHEEL_PATH}" - test -f "${SDIST_PATH}" - poetry run python -m cisco_sccfm_scripts.verify_python_artifacts \ - "${WHEEL_PATH}" "${SDIST_PATH}" - pipx run --spec "twine==6.2.0" twine check --strict \ - "${WHEEL_PATH}" "${SDIST_PATH}" - SMOKE_ROOT="$(mktemp -d "${RUNNER_TEMP}/sccfm-wheel-smoke.XXXXXX")" - python -m venv "${SMOKE_ROOT}/venv" - SMOKE_PYTHON="${SMOKE_ROOT}/venv/bin/python" - SMOKE_CLI="${SMOKE_ROOT}/venv/bin/sccfm-cli" - unset PYTHONHOME PYTHONPATH POETRY_ACTIVE - cd "${SMOKE_ROOT}" - "${SMOKE_PYTHON}" -I -m pip install --no-cache-dir "${WHEEL_PATH}" - "${SMOKE_PYTHON}" -I -m pip check - TODAY_UTC="$(date -u +%F)" - if [[ "${TODAY_UTC}" > "${DEP002_EXCEPTION_EXPIRES}" ]]; then - echo "::error::DEP-002 exceptions expired on ${DEP002_EXCEPTION_EXPIRES}" - exit 1 - fi - RUNTIME_REQUIREMENTS="${SMOKE_ROOT}/runtime-requirements.txt" - "${SMOKE_PYTHON}" -I -m pip freeze \ - --exclude cisco-sccfm-devkit \ - > "${RUNTIME_REQUIREMENTS}" - test -s "${RUNTIME_REQUIREMENTS}" - read -r -a AUDIT_EXCEPTION_ARGS <<< "${DEP002_PIP_AUDIT_EXCEPTIONS}" - pipx run --spec "pip-audit==${PIP_AUDIT_VERSION}" pip-audit \ - --strict \ - --no-deps \ - --disable-pip \ - --vulnerability-service osv \ - --progress-spinner off \ - --aliases on \ - --desc off \ - "${AUDIT_EXCEPTION_ARGS[@]}" \ - --requirement "${RUNTIME_REQUIREMENTS}" - "${SMOKE_PYTHON}" -I - <<'PY' - import importlib - from importlib.metadata import distribution, version - from importlib.util import find_spec - - expected_sdk = "1.17.27" - installed_sdk = version("scc-firewall-manager-sdk") - if installed_sdk != expected_sdk: - raise SystemExit(f"expected SDK {expected_sdk}, installed {installed_sdk}") - for package in ( - "scc_firewall_manager_sdk", - "cisco_sccfm_cli", - "cisco_sccfm_core", - ): - importlib.import_module(package) - if find_spec("cisco_sccfm_scripts") is not None: - raise SystemExit("cisco_sccfm_scripts must not be installed from the public wheel") - console_scripts = { - entry_point.name: entry_point.value - for entry_point in distribution("cisco-sccfm-devkit").entry_points - if entry_point.group == "console_scripts" - } - expected_console_scripts = {"sccfm-cli": "cisco_sccfm_cli.cli:cli"} - if console_scripts != expected_console_scripts: - raise SystemExit( - f"expected console scripts {expected_console_scripts}, got {console_scripts}" - ) - PY - "${SMOKE_CLI}" --help >/dev/null - "${SMOKE_CLI}" schema export --format json | "${SMOKE_PYTHON}" -I -c \ - 'from importlib.metadata import version; import json, sys; payload = json.load(sys.stdin); commands = payload.get("commands"); schema_version = payload.get("version"); installed_version = version("cisco-sccfm-devkit"); assert schema_version == installed_version, f"expected schema version {installed_version}, got {schema_version}"; assert isinstance(commands, list) and len(commands) == 57, f"expected 57 commands, got {len(commands) if isinstance(commands, list) else 0}"' - echo "path=${WHEEL_RELATIVE_PATH}" >> "$GITHUB_OUTPUT" - - - name: Verify clean controller artifact pair - if: steps.bump.outputs.bumped == 'true' - run: | - poetry run python -m cisco_sccfm_scripts.verify_clean_controller \ - "${{ steps.wheel.outputs.path }}" \ - "${{ steps.bump.outputs.artifact_path }}" \ - --expected-version "$(poetry version -s)" - - - name: Commit and tag verified release - if: steps.bump.outputs.bumped == 'true' - env: - NEW_TAG: ${{ steps.bump.outputs.new_tag }} - run: | - git add . - git commit -m "bump: version ${NEW_TAG#v}" -m "[skip ci]" - git tag "${NEW_TAG}" - - - name: Push changes and tags - if: steps.bump.outputs.bumped == 'true' - env: - BRANCH: ${{ github.ref_name }} - run: | - git push origin HEAD:${BRANCH} - git push origin --tags - - - name: Create release - if: steps.bump.outputs.bumped == 'true' - uses: ncipollo/release-action@v1 - with: - tag: ${{ steps.bump.outputs.new_tag }} - artifacts: "${{ steps.wheel.outputs.path }},${{ steps.bump.outputs.artifact_path }}" - token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/publish-to-pypi.yml b/.github/workflows/publish-to-pypi.yml deleted file mode 100644 index 4fd743a0..00000000 --- a/.github/workflows/publish-to-pypi.yml +++ /dev/null @@ -1,84 +0,0 @@ -name: Publish to PyPI - -on: - release: - types: [published] - -permissions: - contents: read - -jobs: - build-and-publish: - if: github.repository == 'CiscoDevNet/sccfm-devkit' - runs-on: ubuntu-latest - - steps: - - name: Checkout release tag - uses: actions/checkout@v7 - with: - ref: ${{ github.event.release.tag_name }} - - - name: Set up Python - uses: actions/setup-python@v7 - with: - python-version: "3.12" - - - name: Verify version matches release tag - id: version - run: | - set -euo pipefail - TAG="${{ github.event.release.tag_name }}" - TAG_VERSION="${TAG#v}" - PKG_VERSION="$(python - <<'PY' - import tomllib - - with open("pyproject.toml", "rb") as pyproject_file: - pyproject = tomllib.load(pyproject_file) - - print(pyproject["project"]["version"]) - PY - )" - - if [ "$TAG_VERSION" != "$PKG_VERSION" ]; then - echo "Version mismatch: tag=$TAG_VERSION, pyproject.toml=$PKG_VERSION" - exit 1 - fi - - echo "Version OK: $PKG_VERSION" - echo "package_version=${PKG_VERSION}" >> "$GITHUB_OUTPUT" - - - name: Install build dependencies - run: | - python -m pip install --upgrade pip - python -m pip install build twine==6.2.0 - - - name: Build and verify package artifacts - env: - PACKAGE_VERSION: ${{ steps.version.outputs.package_version }} - run: | - set -euo pipefail - ARTIFACT_DIR="dist" - WHEEL_PATH="${ARTIFACT_DIR}/cisco_sccfm_devkit-${PACKAGE_VERSION}-py3-none-any.whl" - SDIST_PATH="${ARTIFACT_DIR}/cisco_sccfm_devkit-${PACKAGE_VERSION}.tar.gz" - - test ! -e "${ARTIFACT_DIR}" - python -m build --outdir "${ARTIFACT_DIR}" - test -f "${WHEEL_PATH}" - test -f "${SDIST_PATH}" - - ARTIFACT_COUNT="$(find "${ARTIFACT_DIR}" -mindepth 1 -maxdepth 1 | wc -l | tr -d ' ')" - if [[ "${ARTIFACT_COUNT}" != "2" ]]; then - echo "Expected exactly one wheel and one sdist; found ${ARTIFACT_COUNT} files" - exit 1 - fi - - python -m cisco_sccfm_scripts.verify_python_artifacts \ - "${WHEEL_PATH}" \ - "${SDIST_PATH}" - python -m twine check --strict "${WHEEL_PATH}" "${SDIST_PATH}" - - - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 - with: - password: ${{ secrets.PYPI_API_TOKEN }} - packages-dir: dist/ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..5fba6e26 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,799 @@ +name: Release + +on: + workflow_dispatch: + inputs: + version: + description: "Exact stable version to release (X.Y.Z, without a leading v)" + required: true + type: string + +permissions: + contents: read + +concurrency: + group: production-release + cancel-in-progress: false + +env: + PIP_AUDIT_VERSION: "2.10.1" + DEP002_EXCEPTION_EXPIRES: "2026-09-10" + DEP002_PIP_AUDIT_EXCEPTIONS: >- + --ignore-vuln PYSEC-2026-141 + --ignore-vuln PYSEC-2026-1994 + --ignore-vuln PYSEC-2026-1995 + --ignore-vuln PYSEC-2026-1996 + --ignore-vuln PYSEC-2026-1998 + --ignore-vuln PYSEC-2026-1999 + +jobs: + build-release: + runs-on: ubuntu-latest + environment: release-bot + permissions: + contents: write + outputs: + version: ${{ steps.version.outputs.version }} + tag: ${{ steps.version.outputs.tag }} + source_commit: ${{ steps.source.outputs.source_commit }} + bundle_name: ${{ steps.source.outputs.bundle_name }} + steps: + - name: Checkout main + uses: actions/checkout@v7 + with: + ref: main + fetch-depth: 0 + ssh-key: ${{ secrets.SCCFM_CI_DEPLOY_KEY }} + + - name: Set up Python + uses: actions/setup-python@v7 + with: + python-version: "3.12" + + - name: Install pipx and Poetry + run: | + python -m pip install --upgrade pip pipx + python -m pipx ensurepath + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + pipx install poetry + + - name: Install dependencies + run: poetry install --no-interaction --with dev,build + + - name: Validate requested release + id: version + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + test "${GITHUB_REPOSITORY}" = "CiscoDevNet/sccfm-devkit" + test "${GITHUB_REF}" = "refs/heads/main" + test "$(git rev-parse HEAD)" = "$(git rev-parse origin/main)" + test -z "$(git status --porcelain)" + + if [[ ! "${RELEASE_VERSION}" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then + echo "::error::version must be canonical stable X.Y.Z without a leading v" + exit 1 + fi + + CURRENT_VERSION="$(poetry version -s)" + python - "${CURRENT_VERSION}" "${RELEASE_VERSION}" <<'PY' + import re + import sys + + pattern = re.compile(r"^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$") + current, requested = sys.argv[1:] + if pattern.fullmatch(current) is None: + raise SystemExit(f"current project version is not stable SemVer: {current}") + current_parts = tuple(int(part) for part in current.split(".")) + requested_parts = tuple(int(part) for part in requested.split(".")) + if requested_parts <= current_parts: + raise SystemExit( + f"release version must be greater than current version {current}" + ) + PY + + RELEASE_TAG="v${RELEASE_VERSION}" + if git show-ref --verify --quiet "refs/tags/${RELEASE_TAG}"; then + echo "::error::tag ${RELEASE_TAG} already exists; retry the original workflow run" + exit 1 + fi + if gh release view "${RELEASE_TAG}" --repo "${GITHUB_REPOSITORY}" >/dev/null 2>&1; then + echo "::error::GitHub release ${RELEASE_TAG} already exists" + exit 1 + fi + + for registry_and_url in \ + "PyPI|https://pypi.org/pypi/cisco-sccfm-devkit/${RELEASE_VERSION}/json" \ + "Ansible Galaxy|https://galaxy.ansible.com/api/v3/plugin/ansible/content/published/collections/index/cisco/sccfm/versions/${RELEASE_VERSION}/"; do + registry="${registry_and_url%%|*}" + registry_url="${registry_and_url#*|}" + http_status="$(curl --silent --show-error --location \ + --retry 3 --retry-all-errors \ + --max-filesize 1048576 \ + --output /dev/null \ + --write-out '%{http_code}' \ + "${registry_url}")" + case "${http_status}" in + 404) + ;; + 200) + echo "::error::${registry} already contains version ${RELEASE_VERSION}" + exit 1 + ;; + *) + echo "::error::${registry} preflight failed with HTTP ${http_status}" + exit 1 + ;; + esac + done + + echo "version=${RELEASE_VERSION}" >> "$GITHUB_OUTPUT" + echo "tag=${RELEASE_TAG}" >> "$GITHUB_OUTPUT" + echo "previous_version=${CURRENT_VERSION}" >> "$GITHUB_OUTPUT" + + - name: Synchronize exact release version + env: + RELEASE_VERSION: ${{ steps.version.outputs.version }} + PREVIOUS_VERSION: ${{ steps.version.outputs.previous_version }} + run: | + set -euo pipefail + poetry run cz bump "${RELEASE_VERSION}" \ + --yes \ + --changelog \ + --files-only \ + --check-consistency + test "$(poetry version -s)" = "${RELEASE_VERSION}" + poetry run python -m cisco_sccfm_scripts.prepare_ansible_release \ + sccfm-ansible \ + --previous-version "${PREVIOUS_VERSION}" \ + --release-version "${RELEASE_VERSION}" \ + --release-date "$(date -u +%F)" + poetry run generate-cli-docs + poetry run generate-cli-man-docs + poetry run generate-ansible-docs + + - name: Build release artifacts once + id: artifacts + env: + RELEASE_VERSION: ${{ steps.version.outputs.version }} + run: | + set -euo pipefail + test ! -e dist + poetry run build-ansible-collection + poetry build + + WHEEL_PATH="dist/cisco_sccfm_devkit-${RELEASE_VERSION}-py3-none-any.whl" + SDIST_PATH="dist/cisco_sccfm_devkit-${RELEASE_VERSION}.tar.gz" + COLLECTION_PATH="dist/cisco-sccfm-${RELEASE_VERSION}.tar.gz" + test -f "${WHEEL_PATH}" + test -f "${SDIST_PATH}" + test -f "${COLLECTION_PATH}" + + ARTIFACT_COUNT="$(find dist -mindepth 1 -maxdepth 1 | wc -l | tr -d ' ')" + if [[ "${ARTIFACT_COUNT}" != "3" ]]; then + echo "::error::expected exactly three release artifacts, found ${ARTIFACT_COUNT}" + exit 1 + fi + + poetry run python -m cisco_sccfm_scripts.verify_python_artifacts \ + "${WHEEL_PATH}" "${SDIST_PATH}" + pipx run --spec "twine==6.2.0" twine check --strict \ + "${WHEEL_PATH}" "${SDIST_PATH}" + poetry run python -m cisco_sccfm_scripts.verify_ansible_collection \ + "${COLLECTION_PATH}" --expected-version "${RELEASE_VERSION}" + + echo "wheel_path=${WHEEL_PATH}" >> "$GITHUB_OUTPUT" + echo "sdist_path=${SDIST_PATH}" >> "$GITHUB_OUTPUT" + echo "collection_path=${COLLECTION_PATH}" >> "$GITHUB_OUTPUT" + + - name: Run source gates + run: | + set -euo pipefail + poetry check --strict --lock + git ls-files '*.py' | xargs poetry run reuse lint-file + poetry run black --check . + poetry run isort --check-only . + poetry run mypy \ + cisco_sccfm_cli \ + cisco_sccfm_core \ + cisco_sccfm_scripts/build_ansible_collection.py \ + cisco_sccfm_scripts/prepare_ansible_release.py \ + cisco_sccfm_scripts/release_artifacts.py \ + cisco_sccfm_scripts/verify_ansible_collection.py \ + cisco_sccfm_scripts/verify_clean_controller.py \ + cisco_sccfm_scripts/verify_pypi_release.py \ + cisco_sccfm_scripts/verify_python_artifacts.py + poetry run pytest --color=yes + poetry run check-doc-links + poetry run check-doc-artifacts + + - name: Install pinned Gitleaks + run: | + GITLEAKS_BIN_DIR="${RUNNER_TEMP}/gitleaks-bin" + mkdir -p "${GITLEAKS_BIN_DIR}" + GOBIN="${GITLEAKS_BIN_DIR}" go install github.com/gitleaks/gitleaks/v8@v8.30.1 + echo "${GITLEAKS_BIN_DIR}" >> "$GITHUB_PATH" + + - name: Scan exact release artifacts + run: | + set -euo pipefail + WHEEL_SCAN_ROOT="$(mktemp -d "${RUNNER_TEMP}/sccfm-wheel-scan.XXXXXX")" + python -m zipfile -e \ + "${{ steps.artifacts.outputs.wheel_path }}" \ + "${WHEEL_SCAN_ROOT}" + gitleaks dir --no-banner --no-color --redact=100 "${WHEEL_SCAN_ROOT}" + + for artifact in \ + "${{ steps.artifacts.outputs.sdist_path }}" \ + "${{ steps.artifacts.outputs.collection_path }}"; do + gitleaks dir \ + --no-banner \ + --no-color \ + --redact=100 \ + --max-archive-depth=1 \ + "${artifact}" + done + + - name: Verify exact wheel and sdist installations + env: + RELEASE_VERSION: ${{ steps.version.outputs.version }} + run: | + set -euo pipefail + TODAY_UTC="$(date -u +%F)" + if [[ "${TODAY_UTC}" > "${DEP002_EXCEPTION_EXPIRES}" ]]; then + echo "::error::DEP-002 exceptions expired on ${DEP002_EXCEPTION_EXPIRES}" + exit 1 + fi + read -r -a AUDIT_EXCEPTION_ARGS <<< "${DEP002_PIP_AUDIT_EXCEPTIONS}" + + verify_python_distribution() { + local artifact_path="$1" + local artifact_kind="$2" + local smoke_root + smoke_root="$(mktemp -d "${RUNNER_TEMP}/sccfm-${artifact_kind}-smoke.XXXXXX")" + python -m venv "${smoke_root}/venv" + local smoke_python="${smoke_root}/venv/bin/python" + local smoke_cli="${smoke_root}/venv/bin/sccfm-cli" + + cd "${smoke_root}" + "${smoke_python}" -I -m pip install --no-cache-dir "${artifact_path}" + "${smoke_python}" -I -m pip check + local requirements="${smoke_root}/runtime-requirements.txt" + "${smoke_python}" -I -m pip freeze \ + --exclude cisco-sccfm-devkit \ + > "${requirements}" + test -s "${requirements}" + pipx run --spec "pip-audit==${PIP_AUDIT_VERSION}" pip-audit \ + --strict \ + --no-deps \ + --disable-pip \ + --vulnerability-service osv \ + --progress-spinner off \ + --aliases on \ + --desc off \ + "${AUDIT_EXCEPTION_ARGS[@]}" \ + --requirement "${requirements}" + "${smoke_python}" -I - "${artifact_kind}" <<'PY' + import importlib + import sys + from importlib.metadata import distribution, version + from importlib.util import find_spec + + artifact_kind = sys.argv[1] + if version("scc-firewall-manager-sdk") != "1.17.27": + raise SystemExit("the installed SDK version is not the supported release pin") + for package in ( + "scc_firewall_manager_sdk", + "cisco_sccfm_cli", + "cisco_sccfm_core", + ): + importlib.import_module(package) + if find_spec("cisco_sccfm_scripts") is not None: + raise SystemExit(f"repository scripts leaked into the public {artifact_kind}") + console_scripts = { + entry.name: entry.value + for entry in distribution("cisco-sccfm-devkit").entry_points + if entry.group == "console_scripts" + } + expected = {"sccfm-cli": "cisco_sccfm_cli.cli:cli"} + if console_scripts != expected: + raise SystemExit(f"unexpected public console scripts: {console_scripts}") + PY + "${smoke_cli}" --help >/dev/null + "${smoke_cli}" schema export --format json | "${smoke_python}" -I -c \ + 'from importlib.metadata import version; import json, sys; payload = json.load(sys.stdin); commands = payload.get("commands"); assert payload.get("version") == version("cisco-sccfm-devkit"); assert isinstance(commands, list) and len(commands) == 57' + } + + unset PYTHONHOME PYTHONPATH POETRY_ACTIVE + verify_python_distribution \ + "${GITHUB_WORKSPACE}/${{ steps.artifacts.outputs.wheel_path }}" wheel + verify_python_distribution \ + "${GITHUB_WORKSPACE}/${{ steps.artifacts.outputs.sdist_path }}" sdist + + - name: Verify exact wheel and collection pair + run: | + poetry run python -m cisco_sccfm_scripts.verify_clean_controller \ + "${{ steps.artifacts.outputs.wheel_path }}" \ + "${{ steps.artifacts.outputs.collection_path }}" \ + --expected-version "${{ steps.version.outputs.version }}" + + - name: Run sanity against exact collection artifact + run: | + set -euo pipefail + VENV_PATH="$(poetry env info --path)" + SANITY_ROOT="$(mktemp -d "${RUNNER_TEMP}/sccfm-release-sanity.XXXXXX")" + COLLECTION_ROOT="${SANITY_ROOT}/ansible_collections/cisco/sccfm" + mkdir -p "${COLLECTION_ROOT}" "${SANITY_ROOT}/home" "${SANITY_ROOT}/local" + tar -xzf "${{ steps.artifacts.outputs.collection_path }}" -C "${COLLECTION_ROOT}" + cd "${COLLECTION_ROOT}" + HOME="${SANITY_ROOT}/home" \ + XDG_CACHE_HOME="${SANITY_ROOT}/home/.cache" \ + ANSIBLE_LOCAL_TEMP="${SANITY_ROOT}/local" \ + "${VENV_PATH}/bin/ansible-test" sanity --local --truncate 0 + + - name: Commit and tag verified source + id: source + env: + RELEASE_TAG: ${{ steps.version.outputs.tag }} + RELEASE_VERSION: ${{ steps.version.outputs.version }} + run: | + set -euo pipefail + { + git diff --name-only + git ls-files --others --exclude-standard + } | sort -u | while IFS= read -r changed_path; do + case "${changed_path}" in + CHANGELOG.md|pyproject.toml|sccfm-ansible/CHANGELOG.rst|\ + sccfm-ansible/changelogs/changelog.yaml|sccfm-ansible/galaxy.yml|\ + sccfm-ansible/requirements.txt|docs/cli/*|docs/man/*|docs/ansible/*) + ;; + *) + echo "::error::release preparation changed unexpected path: ${changed_path}" + exit 1 + ;; + esac + done < <(git diff --name-only) + + git config user.name "github-actions" + git config user.email "github-actions@users.noreply.cisco.com" + git add \ + CHANGELOG.md \ + pyproject.toml \ + sccfm-ansible/CHANGELOG.rst \ + sccfm-ansible/changelogs/changelog.yaml \ + sccfm-ansible/galaxy.yml \ + sccfm-ansible/requirements.txt \ + docs/cli \ + docs/man \ + docs/ansible + git commit -m "bump: version ${RELEASE_VERSION}" -m "[skip ci]" + git tag "${RELEASE_TAG}" + test -z "$(git status --porcelain)" + + SOURCE_COMMIT="$(git rev-parse HEAD)" + BUNDLE_NAME="sccfm-release-${RELEASE_VERSION}-${SOURCE_COMMIT}-attempt-${GITHUB_RUN_ATTEMPT}" + echo "source_commit=${SOURCE_COMMIT}" >> "$GITHUB_OUTPUT" + echo "bundle_name=${BUNDLE_NAME}" >> "$GITHUB_OUTPUT" + + - name: Create and verify release manifest + id: manifest + run: | + poetry run python -m cisco_sccfm_scripts.release_artifacts create dist \ + --version "${{ steps.version.outputs.version }}" \ + --tag "${{ steps.version.outputs.tag }}" \ + --source-commit "${{ steps.source.outputs.source_commit }}" + echo "path=dist/release-manifest.json" >> "$GITHUB_OUTPUT" + + - name: Preserve exact release bundle + uses: actions/upload-artifact@v4 + with: + name: ${{ steps.source.outputs.bundle_name }} + path: | + ${{ steps.artifacts.outputs.wheel_path }} + ${{ steps.artifacts.outputs.sdist_path }} + ${{ steps.artifacts.outputs.collection_path }} + ${{ steps.manifest.outputs.path }} + if-no-files-found: error + compression-level: 0 + retention-days: 30 + + - name: Push release commit and tag atomically + env: + RELEASE_TAG: ${{ steps.version.outputs.tag }} + run: | + git push --atomic origin \ + HEAD:refs/heads/main \ + "refs/tags/${RELEASE_TAG}:refs/tags/${RELEASE_TAG}" + + create-draft-release: + needs: build-release + runs-on: ubuntu-latest + environment: release-bot + permissions: + contents: write + steps: + - name: Checkout verified release tag + uses: actions/checkout@v7 + with: + ref: ${{ needs.build-release.outputs.tag }} + + - name: Set up Python + uses: actions/setup-python@v7 + with: + python-version: "3.12" + + - name: Download exact release bundle + uses: actions/download-artifact@v4 + with: + name: ${{ needs.build-release.outputs.bundle_name }} + path: ${{ runner.temp }}/release-bundle + + - name: Verify bundle and upload draft assets + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_TAG: ${{ needs.build-release.outputs.tag }} + RELEASE_VERSION: ${{ needs.build-release.outputs.version }} + SOURCE_COMMIT: ${{ needs.build-release.outputs.source_commit }} + BUNDLE_DIR: ${{ runner.temp }}/release-bundle + run: | + set -euo pipefail + test "$(git rev-parse HEAD)" = "${SOURCE_COMMIT}" + python -m cisco_sccfm_scripts.release_artifacts verify "${BUNDLE_DIR}" \ + --version "${RELEASE_VERSION}" \ + --tag "${RELEASE_TAG}" \ + --source-commit "${SOURCE_COMMIT}" + + if gh release view "${RELEASE_TAG}" --repo "${GITHUB_REPOSITORY}" \ + --json isDraft --jq '.isDraft' > "${RUNNER_TEMP}/release-is-draft" 2>/dev/null; then + test "$(cat "${RUNNER_TEMP}/release-is-draft")" = "true" + else + gh release create "${RELEASE_TAG}" \ + --repo "${GITHUB_REPOSITORY}" \ + --verify-tag \ + --draft \ + --generate-notes \ + --title "${RELEASE_TAG}" + fi + + for local_asset in "${BUNDLE_DIR}"/*; do + asset_name="$(basename "${local_asset}")" + existing_root="${RUNNER_TEMP}/existing-${asset_name}" + mkdir -p "${existing_root}" + if gh release download "${RELEASE_TAG}" \ + --repo "${GITHUB_REPOSITORY}" \ + --pattern "${asset_name}" \ + --dir "${existing_root}" >/dev/null 2>&1; then + cmp -s "${local_asset}" "${existing_root}/${asset_name}" || { + echo "::error::draft release asset differs: ${asset_name}" + exit 1 + } + else + gh release upload "${RELEASE_TAG}" "${local_asset}" \ + --repo "${GITHUB_REPOSITORY}" + fi + done + + VERIFY_ROOT="${RUNNER_TEMP}/verified-draft-assets" + mkdir -p "${VERIFY_ROOT}" + gh release download "${RELEASE_TAG}" \ + --repo "${GITHUB_REPOSITORY}" \ + --dir "${VERIFY_ROOT}" + python -m cisco_sccfm_scripts.release_artifacts verify "${VERIFY_ROOT}" \ + --version "${RELEASE_VERSION}" \ + --tag "${RELEASE_TAG}" \ + --source-commit "${SOURCE_COMMIT}" + + publish-to-pypi: + needs: + - build-release + - create-draft-release + runs-on: ubuntu-latest + environment: pypi + permissions: + contents: read + steps: + - name: Checkout verified release tag + uses: actions/checkout@v7 + with: + ref: ${{ needs.build-release.outputs.tag }} + + - name: Set up Python + uses: actions/setup-python@v7 + with: + python-version: "3.12" + + - name: Install publication checks + run: | + python -m pip install --upgrade pip + python -m pip install twine==6.2.0 + + - name: Download exact release bundle + uses: actions/download-artifact@v4 + with: + name: ${{ needs.build-release.outputs.bundle_name }} + path: ${{ runner.temp }}/release-bundle + + - name: Verify bundle and inspect PyPI state + id: pypi + env: + RELEASE_TAG: ${{ needs.build-release.outputs.tag }} + RELEASE_VERSION: ${{ needs.build-release.outputs.version }} + SOURCE_COMMIT: ${{ needs.build-release.outputs.source_commit }} + BUNDLE_DIR: ${{ runner.temp }}/release-bundle + run: | + set -euo pipefail + test "$(git rev-parse HEAD)" = "${SOURCE_COMMIT}" + python -m cisco_sccfm_scripts.release_artifacts verify "${BUNDLE_DIR}" \ + --version "${RELEASE_VERSION}" \ + --tag "${RELEASE_TAG}" \ + --source-commit "${SOURCE_COMMIT}" + + WHEEL_PATH="${BUNDLE_DIR}/cisco_sccfm_devkit-${RELEASE_VERSION}-py3-none-any.whl" + SDIST_PATH="${BUNDLE_DIR}/cisco_sccfm_devkit-${RELEASE_VERSION}.tar.gz" + python -m cisco_sccfm_scripts.verify_python_artifacts \ + "${WHEEL_PATH}" "${SDIST_PATH}" + python -m twine check --strict "${WHEEL_PATH}" "${SDIST_PATH}" + + set +e + python -m cisco_sccfm_scripts.verify_pypi_release "${BUNDLE_DIR}" \ + --version "${RELEASE_VERSION}" \ + --tag "${RELEASE_TAG}" \ + --source-commit "${SOURCE_COMMIT}" + PYPI_STATUS=$? + set -e + case "${PYPI_STATUS}" in + 0) + echo "publish=false" >> "$GITHUB_OUTPUT" + ;; + 2|3) + echo "publish=true" >> "$GITHUB_OUTPUT" + ;; + *) + exit "${PYPI_STATUS}" + ;; + esac + + test ! -e dist + mkdir dist + cp "${WHEEL_PATH}" "${SDIST_PATH}" dist/ + echo "packages_dir=dist/" >> "$GITHUB_OUTPUT" + + - name: Publish exact Python artifacts + if: steps.pypi.outputs.publish == 'true' + uses: pypa/gh-action-pypi-publish@release/v1 + with: + password: ${{ secrets.PYPI_API_TOKEN }} + packages-dir: ${{ steps.pypi.outputs.packages_dir }} + skip-existing: true + + - name: Verify published PyPI release + env: + RELEASE_TAG: ${{ needs.build-release.outputs.tag }} + RELEASE_VERSION: ${{ needs.build-release.outputs.version }} + SOURCE_COMMIT: ${{ needs.build-release.outputs.source_commit }} + BUNDLE_DIR: ${{ runner.temp }}/release-bundle + run: | + set -euo pipefail + for attempt in {1..12}; do + if python -m cisco_sccfm_scripts.verify_pypi_release "${BUNDLE_DIR}" \ + --version "${RELEASE_VERSION}" \ + --tag "${RELEASE_TAG}" \ + --source-commit "${SOURCE_COMMIT}"; then + break + fi + if [[ "${attempt}" = "12" ]]; then + echo "::error::PyPI did not expose the verified release in time" + exit 1 + fi + sleep 5 + done + + INSTALL_ROOT="$(mktemp -d "${RUNNER_TEMP}/sccfm-pypi-install.XXXXXX")" + python -m venv "${INSTALL_ROOT}/venv" + "${INSTALL_ROOT}/venv/bin/python" -I -m pip install \ + --no-cache-dir \ + --index-url https://pypi.org/simple \ + "cisco-sccfm-devkit==${RELEASE_VERSION}" + "${INSTALL_ROOT}/venv/bin/python" -I -m pip check + "${INSTALL_ROOT}/venv/bin/sccfm-cli" --help >/dev/null + "${INSTALL_ROOT}/venv/bin/sccfm-cli" schema export --format json \ + | "${INSTALL_ROOT}/venv/bin/python" -I -c \ + 'from importlib.metadata import version; import json, sys; payload=json.load(sys.stdin); assert payload.get("version") == version("cisco-sccfm-devkit"); assert len(payload.get("commands", [])) == 57' + + publish-to-galaxy: + needs: + - build-release + - publish-to-pypi + runs-on: ubuntu-latest + environment: ansible-galaxy + permissions: + contents: read + steps: + - name: Checkout verified release tag + uses: actions/checkout@v7 + with: + ref: ${{ needs.build-release.outputs.tag }} + + - name: Set up Python + uses: actions/setup-python@v7 + with: + python-version: "3.12" + + - name: Install Ansible + run: | + python -m pip install --upgrade pip + python -m pip install "ansible-core>=2.20,<2.22" + + - name: Download exact release bundle + uses: actions/download-artifact@v4 + with: + name: ${{ needs.build-release.outputs.bundle_name }} + path: ${{ runner.temp }}/release-bundle + + - name: Verify bundle and inspect Galaxy state + id: galaxy + env: + RELEASE_TAG: ${{ needs.build-release.outputs.tag }} + RELEASE_VERSION: ${{ needs.build-release.outputs.version }} + SOURCE_COMMIT: ${{ needs.build-release.outputs.source_commit }} + BUNDLE_DIR: ${{ runner.temp }}/release-bundle + run: | + set -euo pipefail + test "$(git rev-parse HEAD)" = "${SOURCE_COMMIT}" + python -m cisco_sccfm_scripts.release_artifacts verify "${BUNDLE_DIR}" \ + --version "${RELEASE_VERSION}" \ + --tag "${RELEASE_TAG}" \ + --source-commit "${SOURCE_COMMIT}" + + COLLECTION_PATH="${BUNDLE_DIR}/cisco-sccfm-${RELEASE_VERSION}.tar.gz" + python -m cisco_sccfm_scripts.verify_ansible_collection \ + "${COLLECTION_PATH}" --expected-version "${RELEASE_VERSION}" + LOCAL_SHA256="$(sha256sum "${COLLECTION_PATH}" | awk '{print $1}')" + GALAXY_RESPONSE="${RUNNER_TEMP}/galaxy-version.json" + GALAXY_URL="https://galaxy.ansible.com/api/v3/plugin/ansible/content/published/collections/index/cisco/sccfm/versions/${RELEASE_VERSION}/" + LOOKUP_ATTEMPTS=1 + if [[ "${GITHUB_RUN_ATTEMPT}" -gt 1 ]]; then + LOOKUP_ATTEMPTS=12 + fi + for attempt in $(seq 1 "${LOOKUP_ATTEMPTS}"); do + HTTP_STATUS="$(curl --silent --show-error --location \ + --retry 3 --retry-all-errors \ + --max-filesize 1048576 \ + --output "${GALAXY_RESPONSE}" \ + --write-out '%{http_code}' \ + "${GALAXY_URL}")" + if [[ "${HTTP_STATUS}" != "404" || "${attempt}" = "${LOOKUP_ATTEMPTS}" ]]; then + break + fi + sleep 5 + done + case "${HTTP_STATUS}" in + 200) + test "$(jq -er '.version' "${GALAXY_RESPONSE}")" = "${RELEASE_VERSION}" + test "$(jq -er '.artifact.filename' "${GALAXY_RESPONSE}")" \ + = "cisco-sccfm-${RELEASE_VERSION}.tar.gz" + test "$(jq -er '.artifact.sha256' "${GALAXY_RESPONSE}")" = "${LOCAL_SHA256}" + echo "publish=false" >> "$GITHUB_OUTPUT" + ;; + 404) + echo "publish=true" >> "$GITHUB_OUTPUT" + ;; + *) + echo "::error::Galaxy version lookup failed with HTTP ${HTTP_STATUS}" + exit 1 + ;; + esac + echo "collection_path=${COLLECTION_PATH}" >> "$GITHUB_OUTPUT" + + - name: Publish exact collection and wait for import + if: steps.galaxy.outputs.publish == 'true' + env: + ANSIBLE_GALAXY_SERVER_LIST: release + ANSIBLE_GALAXY_SERVER_RELEASE_URL: https://galaxy.ansible.com/ + ANSIBLE_GALAXY_SERVER_RELEASE_TOKEN: ${{ secrets.GALAXY_API_KEY }} + ANSIBLE_LOCAL_TEMP: ${{ runner.temp }}/ansible-local + run: | + mkdir -p "${ANSIBLE_LOCAL_TEMP}" + ansible-galaxy collection publish \ + "${{ steps.galaxy.outputs.collection_path }}" \ + --server release \ + --timeout 60 \ + --import-timeout 600 + + - name: Verify published Galaxy collection + env: + RELEASE_VERSION: ${{ needs.build-release.outputs.version }} + COLLECTION_PATH: ${{ steps.galaxy.outputs.collection_path }} + ANSIBLE_LOCAL_TEMP: ${{ runner.temp }}/ansible-local + run: | + set -euo pipefail + mkdir -p "${ANSIBLE_LOCAL_TEMP}" + LOCAL_SHA256="$(sha256sum "${COLLECTION_PATH}" | awk '{print $1}')" + GALAXY_RESPONSE="${RUNNER_TEMP}/published-galaxy-version.json" + GALAXY_URL="https://galaxy.ansible.com/api/v3/plugin/ansible/content/published/collections/index/cisco/sccfm/versions/${RELEASE_VERSION}/" + for attempt in {1..12}; do + HTTP_STATUS="$(curl --silent --show-error --location \ + --retry 3 --retry-all-errors \ + --max-filesize 1048576 \ + --output "${GALAXY_RESPONSE}" \ + --write-out '%{http_code}' \ + "${GALAXY_URL}")" + if [[ "${HTTP_STATUS}" = "200" ]]; then + break + fi + if [[ "${attempt}" = "12" ]]; then + echo "::error::Galaxy did not expose the imported collection in time" + exit 1 + fi + sleep 5 + done + test "$(jq -er '.version' "${GALAXY_RESPONSE}")" = "${RELEASE_VERSION}" + test "$(jq -er '.artifact.filename' "${GALAXY_RESPONSE}")" \ + = "cisco-sccfm-${RELEASE_VERSION}.tar.gz" + test "$(jq -er '.artifact.sha256' "${GALAXY_RESPONSE}")" = "${LOCAL_SHA256}" + + DOWNLOAD_ROOT="$(mktemp -d "${RUNNER_TEMP}/sccfm-galaxy-download.XXXXXX")" + ansible-galaxy collection download \ + "cisco.sccfm:==${RELEASE_VERSION}" \ + --server https://galaxy.ansible.com \ + --download-path "${DOWNLOAD_ROOT}" \ + --no-deps + DOWNLOADED_COLLECTION="${DOWNLOAD_ROOT}/cisco-sccfm-${RELEASE_VERSION}.tar.gz" + cmp -s "${COLLECTION_PATH}" "${DOWNLOADED_COLLECTION}" + + INSTALL_ROOT="$(mktemp -d "${RUNNER_TEMP}/sccfm-galaxy-install.XXXXXX")" + python -m pip install \ + --no-cache-dir \ + --index-url https://pypi.org/simple \ + "cisco-sccfm-devkit==${RELEASE_VERSION}" + ansible-galaxy collection install \ + "${DOWNLOADED_COLLECTION}" \ + --collections-path "${INSTALL_ROOT}" \ + --force + ANSIBLE_COLLECTIONS_PATH="${INSTALL_ROOT}" \ + ansible-doc -j -l -t module cisco.sccfm > "${RUNNER_TEMP}/modules.json" + ANSIBLE_COLLECTIONS_PATH="${INSTALL_ROOT}" \ + ansible-doc -j -l -t inventory cisco.sccfm > "${RUNNER_TEMP}/inventory.json" + python - "${RUNNER_TEMP}/modules.json" "${RUNNER_TEMP}/inventory.json" <<'PY' + import json + import sys + from pathlib import Path + + modules = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) + inventory = json.loads(Path(sys.argv[2]).read_text(encoding="utf-8")) + if not isinstance(modules, dict) or len(modules) != 49: + raise SystemExit("published Galaxy artifact did not expose 49 modules") + if not isinstance(inventory, dict) or len(inventory) != 1: + raise SystemExit("published Galaxy artifact did not expose one inventory plugin") + PY + + publish-github-release: + needs: + - build-release + - publish-to-galaxy + runs-on: ubuntu-latest + environment: release-bot + permissions: + contents: write + steps: + - name: Publish verified GitHub release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_TAG: ${{ needs.build-release.outputs.tag }} + run: | + set -euo pipefail + IS_DRAFT="$(gh release view "${RELEASE_TAG}" \ + --repo "${GITHUB_REPOSITORY}" \ + --json isDraft \ + --jq '.isDraft')" + if [[ "${IS_DRAFT}" = "true" ]]; then + gh release edit "${RELEASE_TAG}" \ + --repo "${GITHUB_REPOSITORY}" \ + --draft=false \ + --latest + else + test "${IS_DRAFT}" = "false" + echo "GitHub release ${RELEASE_TAG} is already public." + fi diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 42c516f0..cf9969d8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -72,6 +72,9 @@ We enforce conventional commits via Commitizen. Please: follow the Conventional Commits spec (e.g., `feat: add inventory manager list pagination`). - CI will fail if commit messages do not comply. +Project maintainers perform releases manually by following the +[release guide](https://github.com/CiscoDevNet/sccfm-devkit/blob/main/RELEASING.md). + ## Other Ways to Contribute We welcome anyone that wants to contribute to this CLI tool to triage and reply to open issues to help troubleshoot diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 00000000..88b89b43 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,95 @@ +# Releasing + +Releases are deliberate maintainer operations. Merging or pushing to `main` runs CI but does not +bump a version, create a tag, or publish a package. A maintainer starts the GitHub Actions +**Release** workflow manually and supplies the exact version to publish. + +The workflow publishes the Python package first and the matching Ansible collection second. It +builds the wheel, source distribution, and collection tarball once, then promotes those exact +verified files to GitHub Releases, PyPI, and Ansible Galaxy without rebuilding them. + +## One-time repository setup + +Configure these protected GitHub environments, ideally with required reviewers: + +- `release-bot`: `SCCFM_CI_DEPLOY_KEY`, with permission to push the release commit and tag. +- `pypi`: `PYPI_API_TOKEN`, authorized to publish `cisco-sccfm-devkit`. For the first release, the + token must be allowed to create the project. +- `ansible-galaxy`: `GALAXY_API_KEY`, owned by an account authorized to publish in the `cisco` + namespace. + +Store credentials only as environment secrets. Do not put them in workflow inputs or repository +files. Protect `main`, the release environments, and release tags according to the repository's +maintainer policy. + +## Before a release + +1. Merge all intended changes and confirm CI passes on the exact `main` commit to release. +2. Confirm the changelogs and documentation describe the intended public release. +3. Choose an unused exact version such as `0.39.0`. Enter it without a leading `v`. +4. Confirm that the version and its `v` tag do not already exist on PyPI, Ansible Galaxy, + or GitHub Releases. +5. Confirm the PyPI account and Galaxy account still have the required namespace permissions. + +Published registry versions are immutable. Never reuse a version for different contents. + +## Run the release + +1. Open **Actions** in `CiscoDevNet/sccfm-devkit` and select **Release**. +2. Select **Run workflow**, choose the `main` branch, and enter the exact `version`. +3. Review and approve the protected environments as each publication stage is reached. +4. Keep the run open until every job succeeds. + +The workflow performs these operations in order: + +1. Validates the requested version and release source, synchronizes version metadata, and runs the + release gates. +2. Builds the wheel, source distribution, and Galaxy tarball once; scans and verifies all three. +3. Creates the release commit and `v` tag, then uploads the three artifacts and their + SHA-256 manifest to a draft GitHub Release. +4. Downloads and re-verifies the draft-release assets, publishes the wheel and source distribution + to PyPI, and verifies the published files. +5. Downloads and re-verifies the same collection tarball, publishes it to Ansible Galaxy, and + waits for Galaxy import validation. +6. Publishes the GitHub Release only after both registries succeed. + +## Verify the release + +The successful run is the authoritative publication record. Confirm that: + +- the GitHub Release is public and contains the wheel, source distribution, collection tarball, + and `release-manifest.json`; +- PyPI exposes `cisco-sccfm-devkit==`; and +- Ansible Galaxy exposes `cisco.sccfm` at the same version. + +For an independent clean-install check: + +```bash +RELEASE_VERSION=0.39.0 +RELEASE_CHECK_ROOT="$(mktemp -d)" +python3.12 -m venv "${RELEASE_CHECK_ROOT}/venv" +"${RELEASE_CHECK_ROOT}/venv/bin/python" -m pip install \ + "cisco-sccfm-devkit==${RELEASE_VERSION}" \ + "ansible-core>=2.20,<2.22" +"${RELEASE_CHECK_ROOT}/venv/bin/sccfm-cli" --help +"${RELEASE_CHECK_ROOT}/venv/bin/ansible-galaxy" collection install \ + "cisco.sccfm:==${RELEASE_VERSION}" \ + --collections-path "${RELEASE_CHECK_ROOT}/collections" +ANSIBLE_COLLECTIONS_PATH="${RELEASE_CHECK_ROOT}/collections" \ + "${RELEASE_CHECK_ROOT}/venv/bin/ansible-galaxy" collection list cisco.sccfm +``` + +## Failures and retries + +- Use **Re-run failed jobs**. Do not use **Re-run all jobs** after any registry publication may + have succeeded. +- If PyPI succeeds and Galaxy fails, retry only the failed Galaxy path. It downloads and verifies + the collection from the draft GitHub Release; it must not rebuild it. +- A draft GitHub Release after a failed run is expected. Do not publish it manually while either + registry is incomplete or unverified. +- If a checksum, version, tag, or published-file verification fails, stop and investigate. Do not + replace an artifact, delete a registry release, or bypass a verification gate. +- Before starting a new workflow run after a failure, inspect the tag, draft release, PyPI, and + Galaxy state. If any registry accepted the version, continue only by promoting the existing + manifest-bound artifacts. + diff --git a/cisco_sccfm_scripts/prepare_ansible_release.py b/cisco_sccfm_scripts/prepare_ansible_release.py new file mode 100644 index 00000000..6fa1b91b --- /dev/null +++ b/cisco_sccfm_scripts/prepare_ansible_release.py @@ -0,0 +1,374 @@ +# Copyright 2026 Cisco Systems, Inc. and its affiliates +# +# SPDX-License-Identifier: Apache-2.0 + +"""Prepare existing Ansible changelog metadata for a selected release version.""" + +from __future__ import annotations + +import argparse +import re +import sys +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from datetime import date, datetime, timezone +from pathlib import Path + +import yaml + +_SEMVER = re.compile(r"^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$") +_DATE = re.compile(r"^[0-9]{4}-[0-9]{2}-[0-9]{2}$") +_RELEASE_KEY = re.compile( + r"^ (?P(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)):$" +) +_RST_VERSION = re.compile( + r"^v(?P(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*))$" +) +_MAINTAINER_GUIDANCE = "prepare the Ansible changelog in source before releasing" + + +class AnsibleReleaseError(RuntimeError): + """Raised when changelog state is unsafe to transform automatically.""" + + +@dataclass(frozen=True) +class AnsibleReleasePreparation: + """Summary of prepared Ansible release metadata.""" + + version: str + release_date: str + changed: bool + + +@dataclass(frozen=True) +class _ReleaseBlock: + """Line boundaries for one release in changelog.yaml.""" + + version: str + start: int + end: int + + +class _UniqueKeyLoader(yaml.SafeLoader): + """YAML safe loader that rejects duplicate mapping keys.""" + + +def _construct_unique_mapping( + loader: _UniqueKeyLoader, + node: yaml.nodes.MappingNode, + deep: bool = False, +) -> dict[object, object]: + """Construct a YAML mapping without silently accepting duplicate keys.""" + loader.flatten_mapping(node) + result: dict[object, object] = {} + for key_node, value_node in node.value: + key: object = loader.construct_object(key_node, deep=deep) + try: + duplicate = key in result + except TypeError as exc: + raise yaml.constructor.ConstructorError( + "while constructing a mapping", + node.start_mark, + "found an unhashable key", + key_node.start_mark, + ) from exc + if duplicate: + raise yaml.constructor.ConstructorError( + "while constructing a mapping", + node.start_mark, + f"found duplicate key {key!r}", + key_node.start_mark, + ) + result[key] = loader.construct_object(value_node, deep=deep) + return result + + +_UniqueKeyLoader.add_constructor( + yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, + _construct_unique_mapping, +) + + +def _validate_version(value: str, label: str) -> None: + """Require a canonical stable semantic version.""" + if _SEMVER.fullmatch(value) is None: + raise AnsibleReleaseError(f"{label} must be a canonical stable semantic version") + + +def _resolved_date(value: str | None) -> str: + """Return a validated ISO date, defaulting to the current UTC date.""" + resolved = value or datetime.now(timezone.utc).date().isoformat() + try: + parsed = date.fromisoformat(resolved) + except ValueError as exc: + raise AnsibleReleaseError("release date must be a valid ISO date (YYYY-MM-DD)") from exc + if _DATE.fullmatch(resolved) is None or parsed.isoformat() != resolved: + raise AnsibleReleaseError("release date must be a valid ISO date (YYYY-MM-DD)") + return resolved + + +def _read_regular_file(path: Path) -> str: + """Read a required regular file without following a symlink.""" + if path.is_symlink() or not path.is_file(): + raise AnsibleReleaseError(f"required release file is missing or unsafe: {path.name}") + try: + return path.read_text(encoding="utf-8") + except OSError as exc: + raise AnsibleReleaseError(f"could not read release file: {path.name}") from exc + + +def _load_releases(content: str) -> dict[str, object]: + """Load and validate the changelog release mapping.""" + try: + document: object = yaml.load(content, Loader=_UniqueKeyLoader) + except yaml.YAMLError as exc: + raise AnsibleReleaseError(f"invalid changelog.yaml; {_MAINTAINER_GUIDANCE}") from exc + if not isinstance(document, Mapping): + raise AnsibleReleaseError(f"changelog.yaml is not a mapping; {_MAINTAINER_GUIDANCE}") + releases: object = document.get("releases") + if not isinstance(releases, Mapping) or not releases: + raise AnsibleReleaseError(f"changelog.yaml has no release entries; {_MAINTAINER_GUIDANCE}") + if any(not isinstance(version, str) for version in releases): + raise AnsibleReleaseError( + f"changelog.yaml has invalid release keys; {_MAINTAINER_GUIDANCE}" + ) + return dict(releases) + + +def _entry_date(value: object) -> str | None: + """Return a canonical date from a parsed changelog entry value.""" + if isinstance(value, datetime): + return None + if isinstance(value, date): + return value.isoformat() + if isinstance(value, str) and _DATE.fullmatch(value) is not None: + try: + return date.fromisoformat(value).isoformat() + except ValueError: + return None + return None + + +def _validate_entry(raw: object, version: str) -> Mapping[str, object]: + """Require a complete generated changelog entry without modifying its changes.""" + if not isinstance(raw, Mapping): + raise AnsibleReleaseError( + f"release {version} is not a changelog mapping; {_MAINTAINER_GUIDANCE}" + ) + changes = raw.get("changes") + fragments = raw.get("fragments") + if not isinstance(changes, Mapping) or not changes: + raise AnsibleReleaseError( + f"release {version} has no recorded changes; {_MAINTAINER_GUIDANCE}" + ) + if not isinstance(fragments, list) or any(not isinstance(item, str) for item in fragments): + raise AnsibleReleaseError( + f"release {version} has invalid fragments; {_MAINTAINER_GUIDANCE}" + ) + if _entry_date(raw.get("release_date")) is None: + raise AnsibleReleaseError( + f"release {version} has an invalid release date; {_MAINTAINER_GUIDANCE}" + ) + return raw + + +def _release_blocks(lines: list[str]) -> dict[str, _ReleaseBlock]: + """Locate unquoted generated release keys for minimal, safe edits.""" + starts: list[tuple[str, int]] = [] + for index, line in enumerate(lines): + match = _RELEASE_KEY.fullmatch(line.rstrip("\n")) + if match is not None: + starts.append((match.group("version"), index)) + blocks: dict[str, _ReleaseBlock] = {} + for position, (version, start) in enumerate(starts): + end = starts[position + 1][1] if position + 1 < len(starts) else len(lines) + if version in blocks: + raise AnsibleReleaseError(f"duplicate release blocks; {_MAINTAINER_GUIDANCE}") + blocks[version] = _ReleaseBlock(version, start, end) + return blocks + + +def _replace_release_date(lines: list[str], block: _ReleaseBlock, release_date: str) -> None: + """Replace the one simple release_date scalar in a release block.""" + candidates = [ + index + for index in range(block.start + 1, block.end) + if lines[index].startswith(" release_date:") + ] + if len(candidates) != 1: + raise AnsibleReleaseError(f"release date cannot be edited safely; {_MAINTAINER_GUIDANCE}") + index = candidates[0] + current = lines[index].rstrip("\n") + simple_date = re.fullmatch( + r" release_date: (?:'(?P[0-9]{4}-[0-9]{2}-[0-9]{2})'|" + r'"(?P[0-9]{4}-[0-9]{2}-[0-9]{2})"|' + r"(?P[0-9]{4}-[0-9]{2}-[0-9]{2}))", + current, + ) + if simple_date is None: + raise AnsibleReleaseError(f"release date cannot be edited safely; {_MAINTAINER_GUIDANCE}") + if release_date not in simple_date.groups(): + newline = "\n" if lines[index].endswith("\n") else "" + lines[index] = f" release_date: '{release_date}'{newline}" + + +def _retarget_fragments( + lines: list[str], + block: _ReleaseBlock, + fragments: object, + previous_version: str, + release_version: str, +) -> None: + """Retarget only fragment scalars exactly named after the previous version.""" + if not isinstance(fragments, list): + raise AnsibleReleaseError(f"release fragments cannot be edited; {_MAINTAINER_GUIDANCE}") + old_name = f"{previous_version}.yml" + expected = sum(item == old_name for item in fragments) + patterns = {f" - {old_name}", f" - '{old_name}'", f' - "{old_name}"'} + candidates = [ + index + for index in range(block.start + 1, block.end) + if lines[index].rstrip("\n") in patterns + ] + if len(candidates) != expected: + raise AnsibleReleaseError( + f"release fragments cannot be edited safely; {_MAINTAINER_GUIDANCE}" + ) + for index in candidates: + lines[index] = lines[index].replace(previous_version, release_version, 1) + + +def _rst_headings(lines: list[str]) -> dict[str, int]: + """Validate and locate all stable-version RST headings.""" + headings: dict[str, int] = {} + for index, line in enumerate(lines): + match = _RST_VERSION.fullmatch(line.rstrip("\n")) + if match is None: + continue + version = match.group("version") + expected = len(f"v{version}") + if index + 1 >= len(lines) or lines[index + 1].rstrip("\n") != "=" * expected: + raise AnsibleReleaseError(f"invalid RST release heading; {_MAINTAINER_GUIDANCE}") + if version in headings: + raise AnsibleReleaseError(f"duplicate RST release heading; {_MAINTAINER_GUIDANCE}") + headings[version] = index + return headings + + +def _retarget_rst_heading(lines: list[str], index: int, release_version: str) -> None: + """Retarget one validated RST heading while preserving line endings.""" + heading_newline = "\n" if lines[index].endswith("\n") else "" + underline_newline = "\n" if lines[index + 1].endswith("\n") else "" + heading = f"v{release_version}" + lines[index] = f"{heading}{heading_newline}" + lines[index + 1] = f"{'=' * len(heading)}{underline_newline}" + + +def _write_changed(path: Path, content: str, original: str) -> bool: + """Write one changed UTF-8 file and report whether a write occurred.""" + if content == original: + return False + try: + path.write_text(content, encoding="utf-8") + except OSError as exc: + raise AnsibleReleaseError(f"could not update release file: {path.name}") from exc + return True + + +def prepare_ansible_release( + collection_root: Path, + previous_version: str, + release_version: str, + release_date: str | None = None, +) -> AnsibleReleasePreparation: + """Align existing collection changelogs with one manually selected version.""" + _validate_version(previous_version, "previous version") + _validate_version(release_version, "release version") + resolved_date = _resolved_date(release_date) + if collection_root.is_symlink() or not collection_root.is_dir(): + raise AnsibleReleaseError("collection root must be a regular directory") + + yaml_path = collection_root / "changelogs" / "changelog.yaml" + rst_path = collection_root / "CHANGELOG.rst" + original_yaml = _read_regular_file(yaml_path) + original_rst = _read_regular_file(rst_path) + releases = _load_releases(original_yaml) + yaml_lines = original_yaml.splitlines(keepends=True) + rst_lines = original_rst.splitlines(keepends=True) + blocks = _release_blocks(yaml_lines) + headings = _rst_headings(rst_lines) + if set(blocks) != set(releases): + raise AnsibleReleaseError(f"release blocks cannot be edited safely; {_MAINTAINER_GUIDANCE}") + + yaml_has_target = release_version in releases + rst_has_target = release_version in headings + if yaml_has_target != rst_has_target: + raise AnsibleReleaseError( + f"changelog files disagree on the release; {_MAINTAINER_GUIDANCE}" + ) + + if yaml_has_target: + _validate_entry(releases[release_version], release_version) + _replace_release_date(yaml_lines, blocks[release_version], resolved_date) + else: + if set(releases) != {previous_version} or set(headings) != {previous_version}: + raise AnsibleReleaseError( + f"only a single initial release can be retargeted; {_MAINTAINER_GUIDANCE}" + ) + entry = _validate_entry(releases[previous_version], previous_version) + block = blocks[previous_version] + _replace_release_date(yaml_lines, block, resolved_date) + _retarget_fragments( + yaml_lines, + block, + entry.get("fragments"), + previous_version, + release_version, + ) + key_newline = "\n" if yaml_lines[block.start].endswith("\n") else "" + yaml_lines[block.start] = f" {release_version}:{key_newline}" + _retarget_rst_heading(rst_lines, headings[previous_version], release_version) + + updated_yaml = "".join(yaml_lines) + updated_rst = "".join(rst_lines) + updated_releases = _load_releases(updated_yaml) + _validate_entry(updated_releases.get(release_version), release_version) + updated_headings = _rst_headings(rst_lines) + if release_version not in updated_headings: + raise AnsibleReleaseError(f"release heading update failed; {_MAINTAINER_GUIDANCE}") + + yaml_changed = _write_changed(yaml_path, updated_yaml, original_yaml) + rst_changed = _write_changed(rst_path, updated_rst, original_rst) + return AnsibleReleasePreparation(release_version, resolved_date, yaml_changed or rst_changed) + + +def _parser() -> argparse.ArgumentParser: + """Build the release preparation CLI parser.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("collection_root", type=Path) + parser.add_argument("--previous-version", required=True) + parser.add_argument("--release-version", required=True) + parser.add_argument("--release-date") + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + """Prepare Ansible release metadata from CI or a maintainer shell.""" + args = _parser().parse_args(argv) + try: + result = prepare_ansible_release( + args.collection_root, + args.previous_version, + args.release_version, + args.release_date, + ) + except AnsibleReleaseError as exc: + print(f"Ansible release preparation rejected: {exc}", file=sys.stderr) + return 1 + state = "updated" if result.changed else "already prepared" + print(f"Ansible changelog {state}: version={result.version} date={result.release_date}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/cisco_sccfm_scripts/release_artifacts.py b/cisco_sccfm_scripts/release_artifacts.py new file mode 100644 index 00000000..bd291b78 --- /dev/null +++ b/cisco_sccfm_scripts/release_artifacts.py @@ -0,0 +1,294 @@ +# Copyright 2026 Cisco Systems, Inc. and its affiliates +# +# SPDX-License-Identifier: Apache-2.0 + +"""Create and verify the immutable artifact manifest for one release.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +_MANIFEST_NAME = "release-manifest.json" +_PROJECT_NAME = "cisco-sccfm-devkit" +_SCHEMA_VERSION = 1 +_MAX_MANIFEST_BYTES = 64 * 1024 +_VERSION = re.compile(r"^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$") +_COMMIT = re.compile(r"^[0-9a-f]{40}$") +_SHA256 = re.compile(r"^[0-9a-f]{64}$") + + +class ReleaseArtifactError(RuntimeError): + """Raised when a release artifact bundle violates the immutable policy.""" + + +@dataclass(frozen=True) +class ReleaseBundleVerification: + """Summary of a successfully verified release bundle.""" + + version: str + artifact_count: int + manifest_sha256: str + + +def _expected_artifacts(version: str) -> dict[str, str]: + """Return the exact release filenames and their public artifact kinds.""" + return { + f"cisco-sccfm-{version}.tar.gz": "ansible-collection", + f"cisco_sccfm_devkit-{version}-py3-none-any.whl": "python-wheel", + f"cisco_sccfm_devkit-{version}.tar.gz": "python-sdist", + } + + +def _validate_identity(version: str, tag: str, source_commit: str) -> None: + """Validate the source identity bound into the release manifest.""" + if _VERSION.fullmatch(version) is None: + raise ReleaseArtifactError("release version is invalid") + if tag != f"v{version}": + raise ReleaseArtifactError("release tag does not match the version") + if _COMMIT.fullmatch(source_commit) is None: + raise ReleaseArtifactError("source commit must be a lowercase 40-character Git SHA") + + +def _file_digest(path: Path) -> tuple[int, str]: + """Return the size and SHA-256 of one regular, non-symlink artifact.""" + if path.is_symlink() or not path.is_file(): + raise ReleaseArtifactError(f"release artifact must be a regular file: {path.name}") + digest = hashlib.sha256() + size = 0 + try: + with path.open("rb") as artifact: + while chunk := artifact.read(1024 * 1024): + size += len(chunk) + digest.update(chunk) + except OSError as exc: + raise ReleaseArtifactError(f"could not read release artifact: {path.name}") from exc + return size, digest.hexdigest() + + +def _manifest_payload( + directory: Path, + version: str, + tag: str, + source_commit: str, +) -> dict[str, Any]: + """Build the canonical manifest payload for the three release artifacts.""" + artifacts = [] + for filename, kind in sorted(_expected_artifacts(version).items()): + size, digest = _file_digest(directory / filename) + artifacts.append( + { + "filename": filename, + "kind": kind, + "sha256": digest, + "size": size, + } + ) + return { + "schema_version": _SCHEMA_VERSION, + "project": _PROJECT_NAME, + "version": version, + "tag": tag, + "source_commit": source_commit, + "artifacts": artifacts, + } + + +def create_release_manifest( + directory: Path, + version: str, + tag: str, + source_commit: str, +) -> ReleaseBundleVerification: + """Create the manifest once, then verify the complete bundle.""" + _validate_identity(version, tag, source_commit) + if directory.is_symlink() or not directory.is_dir(): + raise ReleaseArtifactError("release bundle directory must be a regular directory") + manifest = directory / _MANIFEST_NAME + if manifest.exists() or manifest.is_symlink(): + raise ReleaseArtifactError("release manifest already exists") + expected_before = set(_expected_artifacts(version)) + try: + actual_before = {path.name for path in directory.iterdir()} + except OSError as exc: + raise ReleaseArtifactError("could not inspect release bundle directory") from exc + if actual_before != expected_before: + raise ReleaseArtifactError("release bundle must contain exactly the three artifacts") + + payload = _manifest_payload(directory, version, tag, source_commit) + try: + with manifest.open("x", encoding="utf-8", newline="\n") as output: + json.dump(payload, output, indent=2, sort_keys=True) + output.write("\n") + except FileExistsError as exc: + raise ReleaseArtifactError("release manifest already exists") from exc + except OSError as exc: + raise ReleaseArtifactError("could not write release manifest") from exc + + return verify_release_bundle(directory, version, tag, source_commit) + + +def _load_manifest(path: Path) -> tuple[dict[str, Any], bytes]: + """Load a small JSON manifest object without accepting links or special files.""" + if path.is_symlink() or not path.is_file(): + raise ReleaseArtifactError("release manifest must be a regular file") + try: + if path.stat().st_size > _MAX_MANIFEST_BYTES: + raise ReleaseArtifactError("release manifest exceeds the size limit") + raw = path.read_bytes() + parsed: object = json.loads(raw, object_pairs_hook=_unique_json_object) + except (OSError, ValueError) as exc: + raise ReleaseArtifactError("release manifest is not valid JSON") from exc + if not isinstance(parsed, dict): + raise ReleaseArtifactError("release manifest must be a JSON object") + return dict(parsed), raw + + +def _unique_json_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + """Reject duplicate JSON object keys at every manifest nesting level.""" + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise ReleaseArtifactError("release manifest contains a duplicate JSON key") + result[key] = value + return result + + +def _require_exact_keys(value: Mapping[str, Any], expected: set[str], label: str) -> None: + """Require one manifest object to expose no missing or unknown fields.""" + if set(value) != expected: + raise ReleaseArtifactError(f"release manifest has invalid {label} fields") + + +def _manifest_artifacts(raw: object, version: str) -> dict[str, dict[str, Any]]: + """Return validated, duplicate-free artifact records keyed by filename.""" + if not isinstance(raw, list) or len(raw) != 3: + raise ReleaseArtifactError("release manifest must describe exactly three artifacts") + expected = _expected_artifacts(version) + records: dict[str, dict[str, Any]] = {} + for item in raw: + if not isinstance(item, dict): + raise ReleaseArtifactError("release manifest contains an invalid artifact record") + record = dict(item) + _require_exact_keys(record, {"filename", "kind", "sha256", "size"}, "artifact") + filename = record.get("filename") + kind = record.get("kind") + digest = record.get("sha256") + size = record.get("size") + if not isinstance(filename, str) or filename in records or filename not in expected: + raise ReleaseArtifactError("release manifest contains an unexpected artifact filename") + if kind != expected[filename]: + raise ReleaseArtifactError("release manifest contains an unexpected artifact kind") + if not isinstance(digest, str) or _SHA256.fullmatch(digest) is None: + raise ReleaseArtifactError("release manifest contains an invalid SHA-256") + if isinstance(size, bool) or not isinstance(size, int) or size < 0: + raise ReleaseArtifactError("release manifest contains an invalid artifact size") + records[filename] = record + if set(records) != set(expected): + raise ReleaseArtifactError("release manifest does not describe the expected artifacts") + return records + + +def verify_release_bundle( + directory: Path, + expected_version: str, + expected_tag: str, + expected_source_commit: str, +) -> ReleaseBundleVerification: + """Verify identity, filenames, sizes, and hashes for an exact release bundle.""" + _validate_identity(expected_version, expected_tag, expected_source_commit) + if directory.is_symlink() or not directory.is_dir(): + raise ReleaseArtifactError("release bundle directory must be a regular directory") + + expected_names = set(_expected_artifacts(expected_version)) | {_MANIFEST_NAME} + try: + actual_names = {path.name for path in directory.iterdir()} + except OSError as exc: + raise ReleaseArtifactError("could not inspect release bundle directory") from exc + if actual_names != expected_names: + raise ReleaseArtifactError("release bundle contains missing or unexpected files") + + manifest, raw_manifest = _load_manifest(directory / _MANIFEST_NAME) + _require_exact_keys( + manifest, + {"schema_version", "project", "version", "tag", "source_commit", "artifacts"}, + "top-level", + ) + schema_version = manifest.get("schema_version") + if type(schema_version) is not int or schema_version != _SCHEMA_VERSION: + raise ReleaseArtifactError("release manifest uses an unsupported schema version") + if manifest.get("project") != _PROJECT_NAME: + raise ReleaseArtifactError("release manifest names an unexpected project") + if manifest.get("version") != expected_version: + raise ReleaseArtifactError("release manifest version does not match") + if manifest.get("tag") != expected_tag: + raise ReleaseArtifactError("release manifest tag does not match") + if manifest.get("source_commit") != expected_source_commit: + raise ReleaseArtifactError("release manifest source commit does not match") + + records = _manifest_artifacts(manifest.get("artifacts"), expected_version) + for filename, record in records.items(): + size, digest = _file_digest(directory / filename) + if size != record["size"]: + raise ReleaseArtifactError(f"release artifact size does not match: {filename}") + if digest != record["sha256"]: + raise ReleaseArtifactError(f"release artifact SHA-256 does not match: {filename}") + + return ReleaseBundleVerification( + version=expected_version, + artifact_count=len(records), + manifest_sha256=hashlib.sha256(raw_manifest).hexdigest(), + ) + + +def _parser() -> argparse.ArgumentParser: + """Build the release manifest CLI parser.""" + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest="command", required=True) + for command in ("create", "verify"): + subparser = commands.add_parser(command) + subparser.add_argument("directory", type=Path) + subparser.add_argument("--version", required=True) + subparser.add_argument("--tag", required=True) + subparser.add_argument("--source-commit", required=True) + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + """Create or verify an exact release bundle from CI.""" + args = _parser().parse_args(argv) + try: + if args.command == "create": + result = create_release_manifest( + args.directory, + args.version, + args.tag, + args.source_commit, + ) + else: + result = verify_release_bundle( + args.directory, + args.version, + args.tag, + args.source_commit, + ) + except ReleaseArtifactError as exc: + print(f"Release artifact bundle rejected: {exc}") + return 1 + + print( + "Release artifact bundle verified: " + f"version={result.version} artifacts={result.artifact_count} " + f"manifest_sha256={result.manifest_sha256}" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/cisco_sccfm_scripts/verify_pypi_release.py b/cisco_sccfm_scripts/verify_pypi_release.py new file mode 100644 index 00000000..46f343b5 --- /dev/null +++ b/cisco_sccfm_scripts/verify_pypi_release.py @@ -0,0 +1,244 @@ +# Copyright 2026 Cisco Systems, Inc. and its affiliates +# +# SPDX-License-Identifier: Apache-2.0 + +"""Verify that PyPI serves the exact Python artifacts from a release bundle.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from enum import Enum +from pathlib import Path +from typing import Any +from urllib.error import HTTPError, URLError +from urllib.parse import quote +from urllib.request import Request, urlopen + +from cisco_sccfm_scripts.release_artifacts import ( + ReleaseArtifactError, + verify_release_bundle, +) + +_PYPI_PROJECT = "cisco-sccfm-devkit" +_PYPI_ENDPOINT = "https://pypi.org/pypi/cisco-sccfm-devkit/{version}/json" +_MAX_RESPONSE_BYTES = 1024 * 1024 +_REQUEST_TIMEOUT_SECONDS = 10.0 +_SHA256 = re.compile(r"^[0-9a-f]{64}$") + + +class PyPIReleaseError(RuntimeError): + """Raised when a PyPI response cannot prove an exact artifact match.""" + + +class PyPIReleaseNotPublishedError(PyPIReleaseError): + """Raised when PyPI reports that the requested version does not exist.""" + + +class PyPIReleaseStatus(Enum): + """Publication state of the expected Python artifacts.""" + + COMPLETE = "complete" + PARTIAL = "partial" + + +@dataclass(frozen=True) +class PyPIReleaseVerification: + """Summary of a complete or safely resumable PyPI release.""" + + version: str + file_count: int + status: PyPIReleaseStatus + + +def _python_artifact_names(version: str) -> tuple[str, str]: + """Return the exact wheel and sdist filenames for one release.""" + return ( + f"cisco_sccfm_devkit-{version}-py3-none-any.whl", + f"cisco_sccfm_devkit-{version}.tar.gz", + ) + + +def _file_sha256(path: Path) -> str: + """Hash one regular, non-symlink file without loading it into memory.""" + if path.is_symlink() or not path.is_file(): + raise PyPIReleaseError(f"local release artifact is not a regular file: {path.name}") + digest = hashlib.sha256() + try: + with path.open("rb") as artifact: + while chunk := artifact.read(1024 * 1024): + digest.update(chunk) + except OSError as exc: + raise PyPIReleaseError(f"could not read local release artifact: {path.name}") from exc + return digest.hexdigest() + + +def _local_python_hashes( + directory: Path, + version: str, + tag: str, + source_commit: str, +) -> dict[str, str]: + """Return hashes from an exact bundle that passes manifest verification.""" + try: + verify_release_bundle(directory, version, tag, source_commit) + hashes = { + filename: _file_sha256(directory / filename) + for filename in _python_artifact_names(version) + } + # Close the small check/hash race by requiring the complete manifest-bound bundle + # to remain valid after hashing as well. + verify_release_bundle(directory, version, tag, source_commit) + except ReleaseArtifactError as exc: + raise PyPIReleaseError(f"local release bundle is invalid: {exc}") from exc + return hashes + + +def _unique_json_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + """Reject duplicate keys instead of accepting ambiguous remote JSON.""" + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise PyPIReleaseError("PyPI response contains a duplicate JSON key") + result[key] = value + return result + + +def _read_response(response: Any, expected_url: str) -> object: + """Read and decode one bounded response from the fixed PyPI endpoint.""" + if response.geturl() != expected_url: + raise PyPIReleaseError("PyPI response was redirected to an unexpected endpoint") + try: + raw: bytes = response.read(_MAX_RESPONSE_BYTES + 1) + except OSError as exc: + raise PyPIReleaseError("could not read the PyPI response") from exc + if len(raw) > _MAX_RESPONSE_BYTES: + raise PyPIReleaseError("PyPI response exceeds the size limit") + try: + return json.loads(raw, object_pairs_hook=_unique_json_object) + except (UnicodeDecodeError, ValueError) as exc: + raise PyPIReleaseError("PyPI response is not valid JSON") from exc + + +def _fetch_release(version: str, timeout: float) -> object: + """Fetch one release document from the fixed official PyPI JSON endpoint.""" + url = _PYPI_ENDPOINT.format(version=quote(version, safe="")) + request = Request( + url, + headers={ + "Accept": "application/json", + "User-Agent": "cisco-sccfm-devkit-release-verifier", + }, + ) + try: + with urlopen(request, timeout=timeout) as response: + return _read_response(response, url) + except HTTPError as exc: + if exc.code == 404: + raise PyPIReleaseNotPublishedError( + f"{_PYPI_PROJECT} {version} is not published" + ) from exc + raise PyPIReleaseError("PyPI returned an unexpected HTTP error") from exc + except (URLError, TimeoutError, OSError) as exc: + raise PyPIReleaseError("could not query PyPI") from exc + + +def _remote_python_hashes(payload: object, version: str) -> dict[str, str]: + """Extract a nonempty expected filename-to-SHA-256 mapping from PyPI.""" + if not isinstance(payload, dict): + raise PyPIReleaseError("PyPI response must be a JSON object") + info = payload.get("info") + urls = payload.get("urls") + if not isinstance(info, dict) or info.get("version") != version: + raise PyPIReleaseError("PyPI response describes an unexpected version") + if not isinstance(urls, list): + raise PyPIReleaseError("PyPI response has an invalid files list") + + expected_names = set(_python_artifact_names(version)) + hashes: dict[str, str] = {} + for item in urls: + if not isinstance(item, dict): + raise PyPIReleaseError("PyPI response contains an invalid file record") + filename = item.get("filename") + digests = item.get("digests") + if not isinstance(filename, str) or not isinstance(digests, Mapping): + raise PyPIReleaseError("PyPI response contains an invalid file record") + sha256 = digests.get("sha256") + if ( + filename not in expected_names + or filename in hashes + or not isinstance(sha256, str) + or _SHA256.fullmatch(sha256) is None + ): + raise PyPIReleaseError("PyPI response contains an unexpected file record") + hashes[filename] = sha256 + if not hashes: + raise PyPIReleaseError("PyPI release does not contain an expected file") + return hashes + + +def verify_pypi_release( + directory: Path, + version: str, + tag: str, + source_commit: str, + *, + timeout: float = _REQUEST_TIMEOUT_SECONDS, +) -> PyPIReleaseVerification: + """Verify a complete release or a safe manifest-bound proper subset on PyPI.""" + local_hashes = _local_python_hashes(directory, version, tag, source_commit) + remote_hashes = _remote_python_hashes(_fetch_release(version, timeout), version) + if any(local_hashes[filename] != digest for filename, digest in remote_hashes.items()): + raise PyPIReleaseError("PyPI file hashes do not match the verified release bundle") + status = ( + PyPIReleaseStatus.COMPLETE if remote_hashes == local_hashes else PyPIReleaseStatus.PARTIAL + ) + return PyPIReleaseVerification( + version=version, + file_count=len(remote_hashes), + status=status, + ) + + +def _parser() -> argparse.ArgumentParser: + """Build the PyPI verification CLI parser.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("directory", type=Path) + parser.add_argument("--version", required=True) + parser.add_argument("--tag", required=True) + parser.add_argument("--source-commit", required=True) + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + """Verify one published PyPI release for use by the release workflow.""" + args = _parser().parse_args(argv) + try: + result = verify_pypi_release( + args.directory, + args.version, + args.tag, + args.source_commit, + ) + except PyPIReleaseNotPublishedError as exc: + print(f"PyPI release not published: {exc}") + return 2 + except PyPIReleaseError as exc: + print(f"PyPI release verification failed: {exc}") + return 1 + + if result.status is PyPIReleaseStatus.PARTIAL: + print( + f"PyPI release partially published: version={result.version} files={result.file_count}" + ) + return 3 + print(f"PyPI release verified: version={result.version} files={result.file_count}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/cisco_sccfm_scripts/verify_python_artifacts.py b/cisco_sccfm_scripts/verify_python_artifacts.py index 9a15769e..08a18d3a 100644 --- a/cisco_sccfm_scripts/verify_python_artifacts.py +++ b/cisco_sccfm_scripts/verify_python_artifacts.py @@ -9,6 +9,7 @@ import argparse import configparser import email.policy +import hashlib import io import re import stat @@ -17,6 +18,7 @@ import zipfile from collections.abc import Sequence from dataclasses import dataclass +from email.message import Message from email.parser import BytesParser from pathlib import Path, PurePosixPath from typing import Any @@ -47,6 +49,8 @@ } ) _EXPECTED_SCRIPTS = {"sccfm-cli": "cisco_sccfm_cli.cli:cli"} +_EXPECTED_LICENSE_FILES = ("LICENSE", "LICENSES/Apache-2.0.txt") +_APACHE_2_LICENSE_SHA256 = "c71d239df91726fc519c6eb72d318ec65820627232b2f796219e87dcf35d0ab4" _MARKDOWN_LINK = re.compile(r"!?\[[^\]]*\]\(\s*(?:<(?P[^>]+)>|(?P[^\s)]+))") _FORBIDDEN_DIRECTORY_NAMES = frozenset( { @@ -238,18 +242,40 @@ def _verify_markdown_links(text: str, source: str) -> None: raise PythonArtifactVerificationError(f"{source} contains a relative Markdown link") -def _verify_metadata_description(raw: bytes, source: str) -> None: - """Validate the Markdown long description embedded in package metadata.""" +def _parse_package_metadata(raw: bytes, source: str) -> Message: + """Parse one bounded package metadata document.""" try: metadata = BytesParser(policy=email.policy.default).parsebytes(raw) - description = metadata.get_payload() except (TypeError, ValueError) as exc: raise PythonArtifactVerificationError(f"{source} is invalid") from exc + return metadata + + +def _verify_package_metadata(raw: bytes, source: str, version: str) -> None: + """Validate identity, license policy, and the embedded Markdown description.""" + metadata = _parse_package_metadata(raw, source) + if metadata.get("Name") != "cisco-sccfm-devkit" or metadata.get("Version") != version: + raise PythonArtifactVerificationError(f"{source} has unexpected package identity") + if metadata.get("License-Expression") != "Apache-2.0": + raise PythonArtifactVerificationError(f"{source} has unexpected license expression") + if metadata.get_all("License-File", []) != list(_EXPECTED_LICENSE_FILES): + raise PythonArtifactVerificationError(f"{source} has unexpected license files") + if metadata.get("Description-Content-Type") != "text/markdown": + raise PythonArtifactVerificationError(f"{source} has unexpected description type") + description = metadata.get_payload() if not isinstance(description, str): raise PythonArtifactVerificationError(f"{source} has an invalid description") _verify_markdown_links(description, source) +def _verify_apache_license(raw: bytes, source: str) -> None: + """Require the canonical Apache-2.0 license in an artifact license file.""" + if hashlib.sha256(raw).hexdigest() != _APACHE_2_LICENSE_SHA256: + raise PythonArtifactVerificationError( + f"{source} does not contain the canonical Apache-2.0 text" + ) + + def _verify_sdist_pyproject(raw: bytes) -> None: """Ensure a wheel rebuilt from the sdist retains the public package policy.""" try: @@ -316,7 +342,16 @@ def _verify_wheel(path: Path, version: str) -> int: metadata_name = f"{expected_dist_info}/METADATA" if metadata_name not in members: raise PythonArtifactVerificationError("wheel has no package metadata") - _verify_metadata_description(archive.read(members[metadata_name]), "wheel metadata") + _verify_package_metadata( + archive.read(members[metadata_name]), "wheel metadata", version + ) + for license_name in sorted(_EXPECTED_LICENSE_FILES): + member_name = f"{expected_dist_info}/licenses/{license_name}" + if member_name not in members: + raise PythonArtifactVerificationError( + "wheel is missing a required license file" + ) + _verify_apache_license(archive.read(members[member_name]), f"wheel {license_name}") except (OSError, zipfile.BadZipFile) as exc: raise PythonArtifactVerificationError("wheel is not a readable ZIP archive") from exc return len(members) @@ -383,6 +418,15 @@ def _verify_sdist(path: Path, version: str) -> int: raise PythonArtifactVerificationError( "sdist does not contain the expected packages" ) + for license_name in sorted(_EXPECTED_LICENSE_FILES): + license_member = relative_members.get(license_name) + if license_member is None or not license_member.isfile(): + raise PythonArtifactVerificationError( + "sdist is missing a required license file" + ) + _verify_apache_license( + _read_tar_member(archive, license_member), f"sdist {license_name}" + ) missing_documents = _REQUIRED_SDIST_DOCUMENTS.difference(relative_members) if missing_documents: raise PythonArtifactVerificationError("sdist is missing required project documents") @@ -407,8 +451,10 @@ def _verify_sdist(path: Path, version: str) -> int: package_info_member = relative_members.get("PKG-INFO") if package_info_member is None or not package_info_member.isfile(): raise PythonArtifactVerificationError("sdist has no package metadata") - _verify_metadata_description( - _read_tar_member(archive, package_info_member), "sdist package metadata" + _verify_package_metadata( + _read_tar_member(archive, package_info_member), + "sdist package metadata", + version, ) except (OSError, tarfile.TarError) as exc: raise PythonArtifactVerificationError("sdist is not a readable tar.gz archive") from exc diff --git a/tests/test_prepare_ansible_release.py b/tests/test_prepare_ansible_release.py new file mode 100644 index 00000000..6d75a72a --- /dev/null +++ b/tests/test_prepare_ansible_release.py @@ -0,0 +1,266 @@ +# Copyright 2026 Cisco Systems, Inc. and its affiliates +# +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for preparing manually selected Ansible release metadata.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +import yaml + +from cisco_sccfm_scripts.prepare_ansible_release import ( + AnsibleReleaseError, + main, + prepare_ansible_release, +) + +_INITIAL_VERSION = "0.38.0" +_RELEASE_VERSION = "1.0.0" +_RELEASE_DATE = "2026-08-12" +_SUMMARY = ( + "Initial development release of the cisco.sccfm collection, with dynamic inventory " + "and modules for automating Cisco Security Cloud Control Firewall Manager." +) + + +def _yaml_release( + version: str = _INITIAL_VERSION, + release_date: str = "2026-07-27", + fragment: str = "0.38.0.yml", +) -> str: + return f"""--- +ancestor: null +releases: + {version}: + changes: + release_summary: {_SUMMARY} + fragments: + - {fragment} + release_date: '{release_date}' +""" + + +def _rst_release(version: str = _INITIAL_VERSION) -> str: + heading = f"v{version}" + return f"""==================================== +Cisco SCCFM Collection Release Notes +==================================== + +.. contents:: Topics + +{heading} +{'=' * len(heading)} + +Release Summary +--------------- + +{_SUMMARY} +""" + + +def _collection( + tmp_path: Path, + yaml_content: str | None = None, + rst_content: str | None = None, +) -> Path: + root = tmp_path / "sccfm-ansible" + changelogs = root / "changelogs" + changelogs.mkdir(parents=True) + (changelogs / "changelog.yaml").write_text(yaml_content or _yaml_release(), encoding="utf-8") + (root / "CHANGELOG.rst").write_text(rst_content or _rst_release(), encoding="utf-8") + return root + + +def _parsed_release(root: Path, version: str) -> dict[str, object]: + document = yaml.safe_load((root / "changelogs" / "changelog.yaml").read_text()) + release: object = document["releases"][version] + assert isinstance(release, dict) + return release + + +def test_retargets_only_the_initial_release_metadata(tmp_path: Path) -> None: + root = _collection(tmp_path) + + result = prepare_ansible_release( + root, + _INITIAL_VERSION, + _RELEASE_VERSION, + _RELEASE_DATE, + ) + + assert result.version == _RELEASE_VERSION + assert result.release_date == _RELEASE_DATE + assert result.changed + release = _parsed_release(root, _RELEASE_VERSION) + assert release["release_date"] == _RELEASE_DATE + assert release["fragments"] == ["1.0.0.yml"] + assert release["changes"] == {"release_summary": _SUMMARY} + rst = (root / "CHANGELOG.rst").read_text(encoding="utf-8") + assert "v1.0.0\n======" in rst + assert "v0.38.0" not in rst + assert _SUMMARY in rst + + +def test_preserves_a_fragment_not_named_after_the_previous_version(tmp_path: Path) -> None: + root = _collection(tmp_path, yaml_content=_yaml_release(fragment="initial-release.yml")) + + prepare_ansible_release(root, _INITIAL_VERSION, _RELEASE_VERSION, _RELEASE_DATE) + + assert _parsed_release(root, _RELEASE_VERSION)["fragments"] == ["initial-release.yml"] + + +def test_an_already_prepared_release_is_idempotent(tmp_path: Path) -> None: + root = _collection( + tmp_path, + yaml_content=_yaml_release(_RELEASE_VERSION, _RELEASE_DATE, "1.0.0.yml"), + rst_content=_rst_release(_RELEASE_VERSION), + ) + yaml_before = (root / "changelogs" / "changelog.yaml").read_bytes() + rst_before = (root / "CHANGELOG.rst").read_bytes() + + result = prepare_ansible_release( + root, + _INITIAL_VERSION, + _RELEASE_VERSION, + _RELEASE_DATE, + ) + + assert not result.changed + assert (root / "changelogs" / "changelog.yaml").read_bytes() == yaml_before + assert (root / "CHANGELOG.rst").read_bytes() == rst_before + + +def test_an_already_prepared_release_only_updates_its_date(tmp_path: Path) -> None: + root = _collection( + tmp_path, + yaml_content=_yaml_release(_RELEASE_VERSION, "2026-08-01", "1.0.0.yml"), + rst_content=_rst_release(_RELEASE_VERSION), + ) + rst_before = (root / "CHANGELOG.rst").read_bytes() + + result = prepare_ansible_release( + root, + _INITIAL_VERSION, + _RELEASE_VERSION, + _RELEASE_DATE, + ) + + assert result.changed + assert _parsed_release(root, _RELEASE_VERSION)["release_date"] == _RELEASE_DATE + assert (root / "CHANGELOG.rst").read_bytes() == rst_before + + +def test_accepts_an_existing_target_among_historical_releases(tmp_path: Path) -> None: + first = _yaml_release("0.9.0", "2026-07-01", "0.9.0.yml") + second = _yaml_release(_RELEASE_VERSION, _RELEASE_DATE, "1.0.0.yml").split( + "releases:\n", maxsplit=1 + )[1] + yaml_content = first + second + rst_content = ( + _rst_release(_RELEASE_VERSION) + + "\n" + + _rst_release("0.9.0").split(".. contents:: Topics\n", maxsplit=1)[1].lstrip() + ) + root = _collection(tmp_path, yaml_content=yaml_content, rst_content=rst_content) + + result = prepare_ansible_release(root, "0.9.0", _RELEASE_VERSION, _RELEASE_DATE) + + assert not result.changed + + +@pytest.mark.parametrize( + "version", + ["01.2.3", "1.02.3", "1.2.03", "v1.2.3", "1.2", "1.2.3-rc.1", "1.2.3+1"], +) +def test_rejects_noncanonical_or_unstable_versions(tmp_path: Path, version: str) -> None: + root = _collection(tmp_path) + + with pytest.raises(AnsibleReleaseError, match="canonical stable semantic version"): + prepare_ansible_release(root, _INITIAL_VERSION, version, _RELEASE_DATE) + + +@pytest.mark.parametrize("release_date", ["2026-02-29", "2026-8-12", "12-08-2026"]) +def test_rejects_invalid_release_dates(tmp_path: Path, release_date: str) -> None: + root = _collection(tmp_path) + + with pytest.raises(AnsibleReleaseError, match="valid ISO date"): + prepare_ansible_release(root, _INITIAL_VERSION, _RELEASE_VERSION, release_date) + + +def test_rejects_mixed_yaml_and_rst_versions_without_writing(tmp_path: Path) -> None: + root = _collection(tmp_path, rst_content=_rst_release(_RELEASE_VERSION)) + yaml_path = root / "changelogs" / "changelog.yaml" + rst_path = root / "CHANGELOG.rst" + before = (yaml_path.read_bytes(), rst_path.read_bytes()) + + with pytest.raises(AnsibleReleaseError, match="disagree.*prepare the Ansible changelog"): + prepare_ansible_release(root, _INITIAL_VERSION, _RELEASE_VERSION, _RELEASE_DATE) + + assert (yaml_path.read_bytes(), rst_path.read_bytes()) == before + + +def test_rejects_multiple_initial_entries_without_writing(tmp_path: Path) -> None: + extra = _yaml_release("0.37.0", "2026-06-01", "0.37.0.yml").split("releases:\n", maxsplit=1)[1] + root = _collection(tmp_path, yaml_content=_yaml_release() + extra) + yaml_path = root / "changelogs" / "changelog.yaml" + before = yaml_path.read_bytes() + + with pytest.raises(AnsibleReleaseError, match="single initial release.*prepare"): + prepare_ansible_release(root, _INITIAL_VERSION, _RELEASE_VERSION, _RELEASE_DATE) + + assert yaml_path.read_bytes() == before + + +def test_rejects_malformed_rst_release_heading(tmp_path: Path) -> None: + malformed = _rst_release().replace("v0.38.0\n=======\n", "v0.38.0\n======\n") + root = _collection(tmp_path, rst_content=malformed) + + with pytest.raises(AnsibleReleaseError, match="invalid RST release heading.*prepare"): + prepare_ansible_release(root, _INITIAL_VERSION, _RELEASE_VERSION, _RELEASE_DATE) + + +def test_rejects_duplicate_yaml_keys(tmp_path: Path) -> None: + yaml_content = _yaml_release().replace( + " release_date: '2026-07-27'", + " release_date: '2026-07-27'\n release_date: '2026-07-28'", + ) + root = _collection(tmp_path, yaml_content=yaml_content) + + with pytest.raises(AnsibleReleaseError, match="invalid changelog.yaml.*prepare"): + prepare_ansible_release(root, _INITIAL_VERSION, _RELEASE_VERSION, _RELEASE_DATE) + + +def test_cli_reports_success_and_validation_failure( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + root = _collection(tmp_path) + + success = main( + [ + str(root), + "--previous-version", + _INITIAL_VERSION, + "--release-version", + _RELEASE_VERSION, + "--release-date", + _RELEASE_DATE, + ] + ) + failure = main( + [ + str(root), + "--previous-version", + _INITIAL_VERSION, + "--release-version", + "not-a-version", + ] + ) + + captured = capsys.readouterr() + assert success == 0 + assert failure == 1 + assert "Ansible changelog updated" in captured.out + assert "canonical stable semantic version" in captured.err diff --git a/tests/test_release_artifacts.py b/tests/test_release_artifacts.py new file mode 100644 index 00000000..09fe2c93 --- /dev/null +++ b/tests/test_release_artifacts.py @@ -0,0 +1,236 @@ +# Copyright 2026 Cisco Systems, Inc. and its affiliates +# +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for the immutable release artifact manifest.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pytest + +from cisco_sccfm_scripts.release_artifacts import ( + ReleaseArtifactError, + create_release_manifest, + verify_release_bundle, +) + +_VERSION = "1.2.3" +_TAG = "v1.2.3" +_COMMIT = "a" * 40 +_ARTIFACTS = { + "cisco-sccfm-1.2.3.tar.gz": b"collection", + "cisco_sccfm_devkit-1.2.3-py3-none-any.whl": b"wheel", + "cisco_sccfm_devkit-1.2.3.tar.gz": b"sdist", +} + + +def _bundle(tmp_path: Path) -> Path: + bundle = tmp_path / "release" + bundle.mkdir() + for filename, content in _ARTIFACTS.items(): + (bundle / filename).write_bytes(content) + return bundle + + +def _create(tmp_path: Path) -> Path: + bundle = _bundle(tmp_path) + create_release_manifest(bundle, _VERSION, _TAG, _COMMIT) + return bundle + + +def _manifest(bundle: Path) -> dict[str, Any]: + value: object = json.loads((bundle / "release-manifest.json").read_text(encoding="utf-8")) + assert isinstance(value, dict) + return value + + +def test_create_and_verify_release_bundle(tmp_path: Path) -> None: + bundle = _bundle(tmp_path) + + created = create_release_manifest(bundle, _VERSION, _TAG, _COMMIT) + verified = verify_release_bundle(bundle, _VERSION, _TAG, _COMMIT) + + assert created == verified + assert verified.version == _VERSION + assert verified.artifact_count == 3 + manifest = _manifest(bundle) + assert manifest["source_commit"] == _COMMIT + assert [entry["filename"] for entry in manifest["artifacts"]] == sorted(_ARTIFACTS) + + +def test_create_rejects_missing_or_extra_artifacts(tmp_path: Path) -> None: + bundle = _bundle(tmp_path) + (bundle / "unexpected.txt").write_text("unexpected", encoding="utf-8") + + with pytest.raises(ReleaseArtifactError, match="exactly the three artifacts"): + create_release_manifest(bundle, _VERSION, _TAG, _COMMIT) + + +def test_create_never_overwrites_a_manifest(tmp_path: Path) -> None: + bundle = _create(tmp_path) + + with pytest.raises(ReleaseArtifactError, match="manifest already exists"): + create_release_manifest(bundle, _VERSION, _TAG, _COMMIT) + + +@pytest.mark.parametrize( + ("version", "tag", "commit", "message"), + [ + ("not/a/version", "vnot/a/version", _COMMIT, "version is invalid"), + ("01.2.3", "v01.2.3", _COMMIT, "version is invalid"), + ("1.2.3rc1", "v1.2.3rc1", _COMMIT, "version is invalid"), + (_VERSION, "v9.9.9", _COMMIT, "tag does not match"), + (_VERSION, _TAG, "not-a-commit", "source commit"), + ], +) +def test_identity_must_be_canonical( + tmp_path: Path, + version: str, + tag: str, + commit: str, + message: str, +) -> None: + bundle = _bundle(tmp_path) + + with pytest.raises(ReleaseArtifactError, match=message): + create_release_manifest(bundle, version, tag, commit) + + +def test_verify_rejects_tampered_artifact_without_exposing_content(tmp_path: Path) -> None: + bundle = _create(tmp_path) + sentinel = "REL001-SECRET-SENTINEL" + artifact = bundle / "cisco_sccfm_devkit-1.2.3-py3-none-any.whl" + artifact.write_text(sentinel, encoding="utf-8") + + with pytest.raises(ReleaseArtifactError) as error: + verify_release_bundle(bundle, _VERSION, _TAG, _COMMIT) + + assert sentinel not in str(error.value) + + +@pytest.mark.parametrize("field", ["project", "version", "tag", "source_commit"]) +def test_verify_rejects_manifest_identity_changes(tmp_path: Path, field: str) -> None: + bundle = _create(tmp_path) + manifest = _manifest(bundle) + manifest[field] = "changed" + (bundle / "release-manifest.json").write_text(json.dumps(manifest), encoding="utf-8") + + with pytest.raises(ReleaseArtifactError, match="does not match|unexpected project"): + verify_release_bundle(bundle, _VERSION, _TAG, _COMMIT) + + +def test_verify_rejects_unknown_manifest_fields(tmp_path: Path) -> None: + bundle = _create(tmp_path) + manifest = _manifest(bundle) + manifest["unknown"] = True + (bundle / "release-manifest.json").write_text(json.dumps(manifest), encoding="utf-8") + + with pytest.raises(ReleaseArtifactError, match="top-level fields"): + verify_release_bundle(bundle, _VERSION, _TAG, _COMMIT) + + +@pytest.mark.parametrize("schema_version", [True, 1.0]) +def test_verify_requires_integer_schema_version(tmp_path: Path, schema_version: object) -> None: + bundle = _create(tmp_path) + manifest = _manifest(bundle) + manifest["schema_version"] = schema_version + (bundle / "release-manifest.json").write_text(json.dumps(manifest), encoding="utf-8") + + with pytest.raises(ReleaseArtifactError, match="unsupported schema version"): + verify_release_bundle(bundle, _VERSION, _TAG, _COMMIT) + + +def test_verify_normalizes_json_integer_limit_errors(tmp_path: Path) -> None: + bundle = _create(tmp_path) + manifest_path = bundle / "release-manifest.json" + raw = manifest_path.read_text(encoding="utf-8") + manifest_path.write_text(raw.replace('"schema_version": 1', '"schema_version": ' + "9" * 5000)) + + with pytest.raises(ReleaseArtifactError, match="not valid JSON"): + verify_release_bundle(bundle, _VERSION, _TAG, _COMMIT) + + +def test_verify_rejects_duplicate_manifest_keys(tmp_path: Path) -> None: + bundle = _create(tmp_path) + manifest_path = bundle / "release-manifest.json" + raw = manifest_path.read_text(encoding="utf-8") + manifest_path.write_text(raw.replace('"project":', '"project": "duplicate",\n "project":')) + + with pytest.raises(ReleaseArtifactError, match="duplicate JSON key"): + verify_release_bundle(bundle, _VERSION, _TAG, _COMMIT) + + +def test_verify_rejects_symlinked_artifact(tmp_path: Path) -> None: + bundle = _create(tmp_path) + artifact = bundle / "cisco-sccfm-1.2.3.tar.gz" + target = tmp_path / "outside.tar.gz" + target.write_bytes(artifact.read_bytes()) + artifact.unlink() + artifact.symlink_to(target) + + with pytest.raises(ReleaseArtifactError, match="regular file"): + verify_release_bundle(bundle, _VERSION, _TAG, _COMMIT) + + +def test_workflows_promote_release_assets_without_rebuilding() -> None: + repository = Path(__file__).resolve().parents[1] + ci = (repository / ".github/workflows/ci.yml").read_text(encoding="utf-8") + release = (repository / ".github/workflows/release.yml").read_text(encoding="utf-8") + + assert " release:\n" not in ci + assert " publish-to-pypi:\n" not in ci + assert " publish-to-galaxy:\n" not in ci + assert "workflow_dispatch:" in release + assert "release:\n types:" not in release + assert release.count("${{ inputs.version }}") == 1 + assert "RELEASE_VERSION: ${{ inputs.version }}" in release + + build = release.split(" build-release:\n", maxsplit=1)[1].split( + " create-draft-release:\n", maxsplit=1 + )[0] + draft = release.split(" create-draft-release:\n", maxsplit=1)[1].split( + " publish-to-pypi:\n", maxsplit=1 + )[0] + pypi = release.split(" publish-to-pypi:\n", maxsplit=1)[1].split( + " publish-to-galaxy:\n", maxsplit=1 + )[0] + galaxy = release.split(" publish-to-galaxy:\n", maxsplit=1)[1].split( + " publish-github-release:\n", maxsplit=1 + )[0] + finalizer = release.split(" publish-github-release:\n", maxsplit=1)[1] + + assert build.count("poetry build") == 1 + assert build.count("poetry run build-ansible-collection") == 1 + assert "release_artifacts create" in build + assert "release-manifest.json" in build + assert "python -m zipfile -e" in build + assert build.count("pip-audit \\") == 1 + assert build.count("verify_python_distribution \\") == 2 + assert 'steps.artifacts.outputs.wheel_path }}" wheel' in build + assert 'steps.artifacts.outputs.sdist_path }}" sdist' in build + assert "git push --atomic" in build + + assert "gh release create" in draft + assert "--draft" in draft + assert "release_artifacts verify" in draft + for publisher in (pypi, galaxy): + assert "actions/download-artifact" in publisher + assert "release_artifacts verify" in publisher + assert "python -m build" not in publisher + assert "poetry build" not in publisher + assert "build-ansible-collection" not in publisher + + assert "environment: pypi" in pypi + assert "pypa/gh-action-pypi-publish" in pypi + assert "skip-existing: true" in pypi + assert "- publish-to-pypi" in galaxy + assert "environment: ansible-galaxy" in galaxy + assert "ansible-galaxy collection publish" in galaxy + assert "--import-timeout 600" in galaxy + assert "--no-wait" not in galaxy + assert "- publish-to-galaxy" in finalizer + assert "--draft=false" in finalizer diff --git a/tests/test_verify_pypi_release.py b/tests/test_verify_pypi_release.py new file mode 100644 index 00000000..1dbc242b --- /dev/null +++ b/tests/test_verify_pypi_release.py @@ -0,0 +1,320 @@ +# Copyright 2026 Cisco Systems, Inc. and its affiliates +# +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for exact PyPI release verification.""" + +from __future__ import annotations + +import hashlib +import json +from pathlib import Path +from types import TracebackType +from typing import Any +from urllib.error import HTTPError, URLError +from urllib.request import Request + +import pytest + +import cisco_sccfm_scripts.verify_pypi_release as verifier +from cisco_sccfm_scripts.release_artifacts import create_release_manifest +from cisco_sccfm_scripts.verify_pypi_release import ( + PyPIReleaseError, + PyPIReleaseNotPublishedError, + PyPIReleaseStatus, + PyPIReleaseVerification, + verify_pypi_release, +) + +_VERSION = "1.2.3" +_TAG = "v1.2.3" +_COMMIT = "a" * 40 +_WHEEL = "cisco_sccfm_devkit-1.2.3-py3-none-any.whl" +_SDIST = "cisco_sccfm_devkit-1.2.3.tar.gz" +_ARTIFACTS = { + "cisco-sccfm-1.2.3.tar.gz": b"collection", + _WHEEL: b"wheel", + _SDIST: b"sdist", +} + + +class _Response: + """Small context-managed urllib response for deterministic tests.""" + + def __init__(self, payload: bytes, url: str) -> None: + self._payload = payload + self._url = url + self.read_limit: int | None = None + + def __enter__(self) -> _Response: + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: + return None + + def geturl(self) -> str: + return self._url + + def read(self, limit: int) -> bytes: + self.read_limit = limit + return self._payload[:limit] + + +def _bundle(tmp_path: Path) -> Path: + bundle = tmp_path / "release" + bundle.mkdir() + for filename, content in _ARTIFACTS.items(): + (bundle / filename).write_bytes(content) + create_release_manifest(bundle, _VERSION, _TAG, _COMMIT) + return bundle + + +def _sha256(content: bytes) -> str: + return hashlib.sha256(content).hexdigest() + + +def _payload( + *, + wheel_hash: str | None = None, + sdist_hash: str | None = None, + files: list[dict[str, Any]] | None = None, +) -> bytes: + urls = files + if urls is None: + urls = [ + { + "filename": _WHEEL, + "digests": {"sha256": wheel_hash or _sha256(_ARTIFACTS[_WHEEL])}, + }, + { + "filename": _SDIST, + "digests": {"sha256": sdist_hash or _sha256(_ARTIFACTS[_SDIST])}, + }, + ] + return json.dumps({"info": {"version": _VERSION}, "urls": urls}).encode() + + +def _install_response( + monkeypatch: pytest.MonkeyPatch, + payload: bytes, +) -> tuple[_Response, list[tuple[str, float]]]: + calls: list[tuple[str, float]] = [] + response = _Response( + payload, + "https://pypi.org/pypi/cisco-sccfm-devkit/1.2.3/json", + ) + + def fake_urlopen(request: Request, timeout: float) -> _Response: + calls.append((request.full_url, timeout)) + return response + + monkeypatch.setattr(verifier, "urlopen", fake_urlopen) + return response, calls + + +def test_matching_release_uses_fixed_bounded_request( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + bundle = _bundle(tmp_path) + response, calls = _install_response(monkeypatch, _payload()) + + result = verify_pypi_release(bundle, _VERSION, _TAG, _COMMIT) + + assert result == PyPIReleaseVerification( + version=_VERSION, + file_count=2, + status=PyPIReleaseStatus.COMPLETE, + ) + assert calls == [ + ("https://pypi.org/pypi/cisco-sccfm-devkit/1.2.3/json", 10.0), + ] + assert response.read_limit == 1024 * 1024 + 1 + + +def test_http_404_means_not_published( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + bundle = _bundle(tmp_path) + + def not_found(request: Request, timeout: float) -> _Response: + raise HTTPError(request.full_url, 404, "sentinel", None, None) + + monkeypatch.setattr(verifier, "urlopen", not_found) + + with pytest.raises(PyPIReleaseNotPublishedError, match="not published"): + verify_pypi_release(bundle, _VERSION, _TAG, _COMMIT) + + +def test_hash_mismatch_does_not_expose_remote_content( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + bundle = _bundle(tmp_path) + sentinel = "REMOTE-SECRET-SENTINEL" + payload = json.loads(_payload(wheel_hash="b" * 64)) + payload["info"]["untrusted"] = sentinel + _install_response(monkeypatch, json.dumps(payload).encode()) + + with pytest.raises(PyPIReleaseError) as error: + verify_pypi_release(bundle, _VERSION, _TAG, _COMMIT) + + assert sentinel not in str(error.value) + + +@pytest.mark.parametrize("filename", [_WHEEL, _SDIST]) +def test_matching_nonempty_subset_is_safely_resumable( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + filename: str, +) -> None: + bundle = _bundle(tmp_path) + files = [{"filename": filename, "digests": {"sha256": _sha256(_ARTIFACTS[filename])}}] + _install_response(monkeypatch, _payload(files=files)) + + result = verify_pypi_release(bundle, _VERSION, _TAG, _COMMIT) + + assert result == PyPIReleaseVerification( + version=_VERSION, + file_count=1, + status=PyPIReleaseStatus.PARTIAL, + ) + + +@pytest.mark.parametrize( + "files", + [ + [], + [ + {"filename": _WHEEL, "digests": {"sha256": _sha256(_ARTIFACTS[_WHEEL])}}, + {"filename": _SDIST, "digests": {"sha256": _sha256(_ARTIFACTS[_SDIST])}}, + {"filename": "unexpected.zip", "digests": {"sha256": "c" * 64}}, + ], + [ + {"filename": _WHEEL, "digests": {"sha256": _sha256(_ARTIFACTS[_WHEEL])}}, + {"filename": _WHEEL, "digests": {"sha256": _sha256(_ARTIFACTS[_WHEEL])}}, + ], + ], + ids=["empty", "extra", "duplicate"], +) +def test_partial_release_rejects_unsafe_file_sets( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + files: list[dict[str, Any]], +) -> None: + bundle = _bundle(tmp_path) + _install_response(monkeypatch, _payload(files=files)) + + with pytest.raises(PyPIReleaseError, match="expected file|unexpected file"): + verify_pypi_release(bundle, _VERSION, _TAG, _COMMIT) + + +def test_partial_release_rejects_a_hash_mismatch( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + bundle = _bundle(tmp_path) + files = [{"filename": _WHEEL, "digests": {"sha256": "b" * 64}}] + _install_response(monkeypatch, _payload(files=files)) + + with pytest.raises(PyPIReleaseError, match="hashes do not match"): + verify_pypi_release(bundle, _VERSION, _TAG, _COMMIT) + + +@pytest.mark.parametrize( + "payload", + [ + b"not-json", + json.dumps({"info": {"version": _VERSION}, "urls": {}}).encode(), + json.dumps({"info": {"version": "9.9.9"}, "urls": []}).encode(), + ], + ids=["invalid-json", "invalid-files", "wrong-version"], +) +def test_malformed_response_is_rejected( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + payload: bytes, +) -> None: + bundle = _bundle(tmp_path) + _install_response(monkeypatch, payload) + + with pytest.raises(PyPIReleaseError): + verify_pypi_release(bundle, _VERSION, _TAG, _COMMIT) + + +def test_network_error_is_normalized( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + bundle = _bundle(tmp_path) + + def fail(request: Request, timeout: float) -> _Response: + raise URLError("REMOTE-SECRET-SENTINEL") + + monkeypatch.setattr(verifier, "urlopen", fail) + + with pytest.raises(PyPIReleaseError) as error: + verify_pypi_release(bundle, _VERSION, _TAG, _COMMIT) + + assert str(error.value) == "could not query PyPI" + + +@pytest.mark.parametrize( + ("outcome", "exit_code", "message"), + [ + ( + PyPIReleaseVerification(_VERSION, 2, PyPIReleaseStatus.COMPLETE), + 0, + "PyPI release verified", + ), + ( + PyPIReleaseVerification(_VERSION, 1, PyPIReleaseStatus.PARTIAL), + 3, + "partially published", + ), + (PyPIReleaseNotPublishedError("not published"), 2, "not published"), + (PyPIReleaseError("verification failed"), 1, "verification failed"), + ], +) +def test_cli_exit_codes( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + outcome: PyPIReleaseVerification | Exception, + exit_code: int, + message: str, +) -> None: + bundle = tmp_path / "release" + + def fake_verify( + directory: Path, + version: str, + tag: str, + source_commit: str, + ) -> PyPIReleaseVerification: + if isinstance(outcome, Exception): + raise outcome + return outcome + + monkeypatch.setattr(verifier, "verify_pypi_release", fake_verify) + + result = verifier.main( + [ + str(bundle), + "--version", + _VERSION, + "--tag", + _TAG, + "--source-commit", + _COMMIT, + ] + ) + + assert result == exit_code + assert message in capsys.readouterr().out diff --git a/tests/test_verify_python_artifacts.py b/tests/test_verify_python_artifacts.py index b0d9eeb9..353935a9 100644 --- a/tests/test_verify_python_artifacts.py +++ b/tests/test_verify_python_artifacts.py @@ -22,6 +22,15 @@ _DIST_INFO = f"cisco_sccfm_devkit-{_VERSION}.dist-info" _ENTRY_POINTS = b"[console_scripts]\nsccfm-cli=cisco_sccfm_cli.cli:cli\n" _DESCRIPTION = b"# Synthetic package\n\nSee [documentation](https://example.com/docs).\n" +_LICENSE = (Path(__file__).resolve().parents[1] / "LICENSE").read_bytes() +_METADATA_HEADERS = ( + b"Name: cisco-sccfm-devkit\n" + b"Version: 1.2.3\n" + b"License-Expression: Apache-2.0\n" + b"License-File: LICENSE\n" + b"License-File: LICENSES/Apache-2.0.txt\n" + b"Description-Content-Type: text/markdown\n\n" +) _REQUIRED_PROJECT_DOCUMENTS = { "CHANGELOG.md", "CONTRIBUTING.md", @@ -64,13 +73,11 @@ def _build_artifacts( wheel_files = { "cisco_sccfm_cli/__init__.py": b"", "cisco_sccfm_core/__init__.py": b"", - f"{_DIST_INFO}/METADATA": ( - b"Name: cisco-sccfm-devkit\n" - b"Version: 1.2.3\n" - b"Description-Content-Type: text/markdown\n\n" + wheel_description - ), + f"{_DIST_INFO}/METADATA": _METADATA_HEADERS + wheel_description, f"{_DIST_INFO}/WHEEL": b"Wheel-Version: 1.0\n", f"{_DIST_INFO}/entry_points.txt": entry_points, + f"{_DIST_INFO}/licenses/LICENSE": _LICENSE, + f"{_DIST_INFO}/licenses/LICENSES/Apache-2.0.txt": _LICENSE, f"{_DIST_INFO}/RECORD": b"", **(wheel_extra or {}), } @@ -81,16 +88,12 @@ def _build_artifacts( sdist = tmp_path / f"cisco_sccfm_devkit-{_VERSION}.tar.gz" prefix = f"cisco_sccfm_devkit-{_VERSION}" sdist_files = { - "LICENSE": b"Apache-2.0\n", - "LICENSES/Apache-2.0.txt": b"Apache-2.0\n", + "LICENSE": _LICENSE, + "LICENSES/Apache-2.0.txt": _LICENSE, "CHANGELOG.md": b"# Changelog\n", "CONTRIBUTING.md": b"# Contributing\n", "INSTALL.md": b"# Installation\n", - "PKG-INFO": ( - b"Name: cisco-sccfm-devkit\n" - b"Version: 1.2.3\n" - b"Description-Content-Type: text/markdown\n\n" + sdist_description - ), + "PKG-INFO": _METADATA_HEADERS + sdist_description, "README.md": sdist_description, "SECURITY.md": b"# Security\n", "cisco_sccfm_cli/__init__.py": b"", @@ -111,7 +114,7 @@ def test_verifier_accepts_public_artifact_pair(tmp_path: Path) -> None: result = verify_python_artifacts(wheel, sdist) - assert result.wheel_files == 6 + assert result.wheel_files == 8 assert result.sdist_files == 11 @@ -121,7 +124,7 @@ def test_wheel_verifier_accepts_public_wheel_without_sdist(tmp_path: Path) -> No result = verify_python_wheel(wheel) assert result.version == _VERSION - assert result.files == 6 + assert result.files == 8 @pytest.mark.parametrize( @@ -222,3 +225,49 @@ def test_verifier_rejects_relative_link_in_sdist_description(tmp_path: Path) -> with pytest.raises(PythonArtifactVerificationError, match="relative Markdown link"): verify_python_artifacts(wheel, sdist) + + +@pytest.mark.parametrize("artifact", ["wheel", "sdist"]) +def test_verifier_rejects_incomplete_license_text(tmp_path: Path, artifact: str) -> None: + wheel_license = f"{_DIST_INFO}/licenses/LICENSE" + wheel_extra = {wheel_license: b"not the license\n"} if artifact == "wheel" else None + sdist_extra = {"LICENSE": b"not the license\n"} if artifact == "sdist" else None + wheel, sdist = _build_artifacts( + tmp_path, + wheel_extra=wheel_extra, + sdist_extra=sdist_extra, + ) + + with pytest.raises(PythonArtifactVerificationError, match="Apache-2.0 text"): + verify_python_artifacts(wheel, sdist) + + +def test_verifier_rejects_incorrect_license_expression(tmp_path: Path) -> None: + metadata = (_METADATA_HEADERS + _DESCRIPTION).replace( + b"License-Expression: Apache-2.0", b"License-Expression: MIT" + ) + wheel, sdist = _build_artifacts( + tmp_path, + wheel_extra={f"{_DIST_INFO}/METADATA": metadata}, + ) + + with pytest.raises(PythonArtifactVerificationError, match="license expression"): + verify_python_artifacts(wheel, sdist) + + +@pytest.mark.parametrize("artifact", ["wheel", "sdist"]) +def test_verifier_rejects_duplicate_license_file_header(tmp_path: Path, artifact: str) -> None: + metadata = (_METADATA_HEADERS + _DESCRIPTION).replace( + b"License-File: LICENSE\n", + b"License-File: LICENSE\nLicense-File: LICENSE\n", + ) + wheel_extra = {f"{_DIST_INFO}/METADATA": metadata} if artifact == "wheel" else None + sdist_extra = {"PKG-INFO": metadata} if artifact == "sdist" else None + wheel, sdist = _build_artifacts( + tmp_path, + wheel_extra=wheel_extra, + sdist_extra=sdist_extra, + ) + + with pytest.raises(PythonArtifactVerificationError, match="license files"): + verify_python_artifacts(wheel, sdist) From 226fb9113f627ce1fea3d5800fc17d65429b269a Mon Sep 17 00:00:00 2001 From: Andrei Huides Date: Wed, 12 Aug 2026 15:27:55 +0300 Subject: [PATCH 14/19] fix(lh-102436): harden release and credential workflows --- .cz.yaml | 2 + .github/workflows/release.yml | 310 ++- README.md | 22 +- RELEASING.md | 20 +- cisco_sccfm_cli/commands/base.py | 38 +- cisco_sccfm_cli/commands/configure.py | 34 +- .../inventory/devices/asa/onboard/command.py | 17 +- .../devices/asa/smartlicense/command.py | 48 +- .../asa/user/change_password/command.py | 31 +- .../configure_manager/command.py | 80 +- .../cdfmc_managed_ftd/onboard_ztp/command.py | 19 +- cisco_sccfm_cli/commands/shared_options.py | 2 +- cisco_sccfm_cli/commands/status.py | 2 +- .../devices/asa/onboard/test_onboard.py | 40 + .../asa/smartlicense/test_token_input.py | 68 + .../devices/asa/user/test_change_password.py | 60 + .../test_configure_manager.py | 91 +- .../cdfmc_managed_ftd/test_onboard_ztp.py | 31 +- .../inventory/devices/test_devices_list.py | 57 +- .../commands/tests/test_configure.py | 34 + cisco_sccfm_cli/commands/tests/test_schema.py | 40 +- cisco_sccfm_cli/commands/tests/test_status.py | 41 + cisco_sccfm_cli/e2e/_profile.py | 17 +- cisco_sccfm_cli/option_metadata.py | 26 + cisco_sccfm_cli/schema.py | 20 +- cisco_sccfm_cli/services/config_service.py | 347 +++- .../services/tests/test_config_service.py | 416 +++- .../ftd_configure_manager_service.py | 59 +- .../test_ftd_configure_manager_service.py | 147 ++ cisco_sccfm_scripts/cli_commands.py | 18 +- cisco_sccfm_scripts/devkit_cli.py | 310 ++- .../prepare_ansible_release.py | 57 + cisco_sccfm_scripts/setup_tokens.py | 648 +++++- cisco_sccfm_scripts/token_store.py | 535 ++++- cisco_sccfm_scripts/verify_pypi_release.py | 5 +- docs/ansible/modules/add_asa_shun.md | 12 +- .../modules/add_network_group_members.md | 8 +- docs/ansible/modules/add_object_override.md | 6 +- .../apply_object_override_as_default.md | 6 +- docs/ansible/modules/asa_ha_check.md | 8 +- docs/ansible/modules/change_asa_boot_image.md | 8 +- .../modules/change_asa_local_password.md | 8 +- docs/ansible/modules/clear_asa_shun.md | 8 +- docs/ansible/modules/configure_manager.md | 4 +- docs/ansible/modules/create_access_rule.md | 6 +- docs/ansible/modules/create_network_group.md | 6 +- docs/ansible/modules/create_network_object.md | 6 +- docs/ansible/modules/delete_access_rule.md | 6 +- docs/ansible/modules/delete_network_group.md | 10 +- docs/ansible/modules/delete_network_object.md | 10 +- .../ansible/modules/delete_object_override.md | 6 +- docs/ansible/modules/deploy_cdfmc_ftd.md | 8 +- docs/ansible/modules/edit_object_override.md | 6 +- docs/ansible/modules/execute_asa_cli.md | 8 +- docs/ansible/modules/execute_ftd_cli.md | 8 +- docs/ansible/modules/get_access_group.md | 8 +- docs/ansible/modules/get_access_rule.md | 6 +- docs/ansible/modules/get_object.md | 6 +- docs/ansible/modules/list_access_groups.md | 8 +- docs/ansible/modules/list_access_rules.md | 6 +- .../ansible/modules/list_asa_boot_registry.md | 8 +- .../modules/list_asa_compatible_versions.md | 8 +- docs/ansible/modules/list_asa_disk_files.md | 8 +- docs/ansible/modules/list_asa_local_users.md | 6 +- .../modules/list_asa_not_on_version.md | 8 +- .../modules/list_cdfmc_access_policies.md | 8 +- .../modules/list_ftd_compatible_versions.md | 8 +- .../modules/list_ftd_not_on_version.md | 8 +- docs/ansible/modules/list_managers.md | 8 +- docs/ansible/modules/list_network_groups.md | 6 +- docs/ansible/modules/list_network_objects.md | 6 +- docs/ansible/modules/onboard_asa.md | 2 +- docs/ansible/modules/onboard_cdfmc_ftd.md | 8 +- docs/ansible/modules/onboard_cdfmc_ftd_ztp.md | 8 +- docs/ansible/modules/register_cdfmc_ftd.md | 4 +- docs/ansible/modules/remove_asa_shun.md | 12 +- .../modules/remove_network_group_members.md | 8 +- docs/ansible/modules/show_asa_shun.md | 8 +- docs/ansible/modules/trigger_asa_upgrade.md | 8 +- docs/ansible/modules/trigger_ftd_upgrade.md | 8 +- docs/ansible/modules/update_access_rule.md | 6 +- docs/ansible/modules/update_network_group.md | 6 +- docs/ansible/modules/update_network_object.md | 10 +- docs/ansible/modules/update_object_default.md | 10 +- ...ces-cdfmc-managed-ftd-configure-manager.md | 4 +- ...y-devices-cdfmc-managed-ftd-onboard-ztp.md | 5 +- docs/man/man1/sccfm-cli-configure.1 | 2 +- ...-inventory-devices-asa-change-boot-image.1 | 2 +- ...fm-cli-inventory-devices-asa-cli-execute.1 | 2 +- .../sccfm-cli-inventory-devices-asa-cli.1 | 2 +- ...li-inventory-devices-asa-disk-list-files.1 | 2 +- .../sccfm-cli-inventory-devices-asa-disk.1 | 2 +- ...sccfm-cli-inventory-devices-asa-ha-check.1 | 2 +- ...inventory-devices-asa-list-boot-registry.1 | 2 +- ...i-inventory-devices-asa-list-local-users.1 | 2 +- ...nventory-devices-asa-list-not-on-version.1 | 2 +- .../sccfm-cli-inventory-devices-asa-list.1 | 2 +- .../sccfm-cli-inventory-devices-asa-onboard.1 | 2 +- ...sccfm-cli-inventory-devices-asa-shun-add.1 | 2 +- ...cfm-cli-inventory-devices-asa-shun-clear.1 | 2 +- ...fm-cli-inventory-devices-asa-shun-remove.1 | 2 +- ...ccfm-cli-inventory-devices-asa-shun-show.1 | 2 +- .../sccfm-cli-inventory-devices-asa-shun.1 | 2 +- ...m-cli-inventory-devices-asa-smartlicense.1 | 2 +- ...-devices-asa-upgrade-compatible-versions.1 | 2 +- ...li-inventory-devices-asa-upgrade-trigger.1 | 2 +- .../sccfm-cli-inventory-devices-asa-upgrade.1 | 2 +- ...ventory-devices-asa-user-change-password.1 | 2 +- .../sccfm-cli-inventory-devices-asa-user.1 | 2 +- .../man1/sccfm-cli-inventory-devices-asa.1 | 2 +- ...ry-devices-cdfmc-managed-ftd-cli-execute.1 | 2 +- ...-inventory-devices-cdfmc-managed-ftd-cli.1 | 2 +- ...ices-cdfmc-managed-ftd-configure-manager.1 | 4 +- ...ventory-devices-cdfmc-managed-ftd-deploy.1 | 2 +- ...inventory-devices-cdfmc-managed-ftd-list.1 | 2 +- ...ry-devices-cdfmc-managed-ftd-onboard-ztp.1 | 4 +- ...entory-devices-cdfmc-managed-ftd-onboard.1 | 2 +- ...-cli-inventory-devices-cdfmc-managed-ftd.1 | 2 +- ...nventory-devices-ftd-list-not-on-version.1 | 2 +- .../sccfm-cli-inventory-devices-ftd-list.1 | 2 +- ...-devices-ftd-upgrade-compatible-versions.1 | 2 +- ...li-inventory-devices-ftd-upgrade-trigger.1 | 2 +- .../sccfm-cli-inventory-devices-ftd-upgrade.1 | 2 +- .../man1/sccfm-cli-inventory-devices-ftd.1 | 2 +- .../man1/sccfm-cli-inventory-devices-list.1 | 2 +- docs/man/man1/sccfm-cli-inventory-devices.1 | 2 +- ...i-inventory-manager-access-policies-list.1 | 2 +- ...fm-cli-inventory-manager-access-policies.1 | 2 +- .../man1/sccfm-cli-inventory-manager-list.1 | 2 +- docs/man/man1/sccfm-cli-inventory-manager.1 | 2 +- docs/man/man1/sccfm-cli-inventory.1 | 2 +- .../man/man1/sccfm-cli-objects-add-override.1 | 2 +- ...fm-cli-objects-apply-override-as-default.1 | 2 +- .../man1/sccfm-cli-objects-delete-override.1 | 2 +- .../man1/sccfm-cli-objects-edit-override.1 | 2 +- .../man1/sccfm-cli-objects-network-create.1 | 2 +- .../man1/sccfm-cli-objects-network-delete.1 | 2 +- ...cfm-cli-objects-network-group-add-member.1 | 2 +- .../sccfm-cli-objects-network-group-create.1 | 2 +- .../sccfm-cli-objects-network-group-delete.1 | 2 +- .../sccfm-cli-objects-network-group-list.1 | 2 +- ...-cli-objects-network-group-remove-member.1 | 2 +- .../sccfm-cli-objects-network-group-update.1 | 2 +- .../man1/sccfm-cli-objects-network-group.1 | 2 +- .../man/man1/sccfm-cli-objects-network-list.1 | 2 +- .../man1/sccfm-cli-objects-network-update.1 | 2 +- docs/man/man1/sccfm-cli-objects-network.1 | 2 +- docs/man/man1/sccfm-cli-objects-show.1 | 2 +- .../man1/sccfm-cli-objects-update-default.1 | 2 +- docs/man/man1/sccfm-cli-objects.1 | 2 +- .../sccfm-cli-policies-access-group-get.1 | 2 +- .../sccfm-cli-policies-access-group-list.1 | 2 +- .../man1/sccfm-cli-policies-access-group.1 | 2 +- .../sccfm-cli-policies-access-rule-create.1 | 2 +- .../sccfm-cli-policies-access-rule-delete.1 | 2 +- .../man1/sccfm-cli-policies-access-rule-get.1 | 2 +- .../sccfm-cli-policies-access-rule-list.1 | 2 +- .../sccfm-cli-policies-access-rule-update.1 | 2 +- .../man/man1/sccfm-cli-policies-access-rule.1 | 2 +- docs/man/man1/sccfm-cli-policies.1 | 2 +- docs/man/man1/sccfm-cli-schema-export.1 | 2 +- docs/man/man1/sccfm-cli-schema.1 | 2 +- docs/man/man1/sccfm-cli-status.1 | 2 +- docs/man/man1/sccfm-cli-transaction.1 | 2 +- docs/man/man1/sccfm-cli.1 | 2 +- sccfm-ansible/README.md | 63 +- sccfm-ansible/changelogs/changelog.yaml | 1 + .../e2e/access_rules/playbooks/cleanup.yml | 2 +- .../playbooks/create_access_rule.yml | 2 +- .../playbooks/delete_access_rule.yml | 2 +- .../playbooks/delete_idempotency.yml | 2 +- .../playbooks/get_access_group.yml | 2 +- .../playbooks/list_access_groups.yml | 2 +- .../playbooks/list_access_rules.yml | 2 +- .../playbooks/provision_access_group.yml | 2 +- .../playbooks/update_access_rule.yml | 2 +- .../playbooks/update_idempotency.yml | 2 +- .../access_rules/playbooks/verify_create.yml | 2 +- .../access_rules/playbooks/verify_delete.yml | 2 +- .../access_rules/playbooks/verify_update.yml | 2 +- sccfm-ansible/e2e/asa/playbooks/add_shun.yml | 2 +- sccfm-ansible/e2e/asa/playbooks/cleanup.yml | 2 +- .../e2e/asa/playbooks/clear_shun.yml | 2 +- .../e2e/asa/playbooks/execute_cli_read.yml | 2 +- .../playbooks/ha_check_assert_structure.yml | 2 +- .../e2e/asa/playbooks/ha_check_by_uid.yml | 2 +- .../e2e/asa/playbooks/ha_check_query.yml | 2 +- .../playbooks/ha_check_query_with_limit.yml | 2 +- .../e2e/asa/playbooks/list_boot_registry.yml | 2 +- .../playbooks/list_compatible_versions.yml | 2 +- .../list_compatible_versions_for_upgrade.yml | 2 +- .../e2e/asa/playbooks/list_disk_files.yml | 2 +- .../e2e/asa/playbooks/list_local_users.yml | 2 +- .../e2e/asa/playbooks/list_not_on_version.yml | 2 +- .../e2e/asa/playbooks/onboard_vasa.yml | 2 +- .../e2e/asa/playbooks/remove_shun.yml | 2 +- sccfm-ansible/e2e/asa/playbooks/show_shun.yml | 2 +- .../asa/playbooks/show_shun_statistics.yml | 2 +- .../asa/playbooks/trigger_upgrade_stage.yml | 2 +- .../verify_boot_registry_after_stage.yml | 2 +- .../e2e/asa/playbooks/verify_shun_cleared.yml | 2 +- .../e2e/ftd/playbooks/deploy_ftd.yml | 2 +- .../playbooks/list_compatible_versions.yml | 2 +- .../list_compatible_versions_for_upgrade.yml | 2 +- .../ftd/playbooks/list_not_on_recommended.yml | 2 +- .../e2e/ftd/playbooks/list_not_on_version.yml | 2 +- .../ftd/playbooks/trigger_upgrade_stage.yml | 2 +- .../e2e/objects/playbooks/cleanup.yml | 2 +- .../objects/playbooks/create_idempotency.yml | 2 +- .../playbooks/create_network_group.yml | 2 +- .../playbooks/create_network_objects.yml | 2 +- .../objects/playbooks/delete_idempotency.yml | 2 +- .../playbooks/delete_network_group.yml | 2 +- .../playbooks/delete_network_objects.yml | 2 +- .../objects/playbooks/update_idempotency.yml | 2 +- .../playbooks/update_network_group.yml | 2 +- .../playbooks/update_network_objects.yml | 2 +- .../e2e/objects/playbooks/verify_create.yml | 2 +- .../e2e/objects/playbooks/verify_delete.yml | 2 +- .../e2e/objects/playbooks/verify_group.yml | 2 +- .../e2e/objects/playbooks/verify_update.yml | 2 +- sccfm-ansible/examples/access_rules.yml | 7 +- .../examples/add_object_override.yml | 6 +- sccfm-ansible/examples/asa_ha_check.yml | 5 +- .../examples/change_asa_boot_image.yml | 6 +- .../examples/change_asa_local_password.yml | 7 +- .../examples/create_network_groups.yml | 7 +- .../examples/create_network_objects.yml | 7 +- .../examples/delete_network_groups.yml | 7 +- .../examples/delete_network_objects.yml | 7 +- sccfm-ansible/examples/deploy_cdfmc_ftd.yml | 5 +- sccfm-ansible/examples/execute_asa_cli.yml | 5 +- sccfm-ansible/examples/execute_ftd_cli.yml | 5 +- .../examples/group_vars/all/vars.yml | 4 +- .../examples/group_vars/all/vault.yml.example | 2 +- .../examples/list_asa_boot_registry.yml | 5 +- .../examples/list_asa_compatible_versions.yml | 5 +- .../examples/list_asa_disk_files.yml | 5 +- .../examples/list_asa_local_users.yml | 6 +- .../examples/list_asa_not_on_version.yml | 5 +- .../examples/list_ftd_compatible_versions.yml | 5 +- .../examples/list_ftd_not_on_version.yml | 5 +- .../examples/list_network_groups.yml | 8 +- .../examples/list_network_objects.yml | 8 +- sccfm-ansible/examples/manage_asa_shun.yml | 5 +- .../examples/manage_network_group_members.yml | 7 +- sccfm-ansible/examples/network_objects.yml | 7 +- sccfm-ansible/examples/onboard_asas.yml | 5 +- sccfm-ansible/examples/onboard_cdfmc_ftd.yml | 5 +- .../examples/onboard_cdfmc_ftd_ztp.yml | 5 +- .../examples/trigger_asa_upgrade.yml | 5 +- .../examples/trigger_ftd_upgrade.yml | 5 +- .../examples/update_network_groups.yml | 8 +- .../examples/update_network_objects.yml | 8 +- .../plugins/module_utils/dependencies.py | 8 +- sccfm-ansible/plugins/modules/add_asa_shun.py | 12 +- .../modules/add_network_group_members.py | 8 +- .../plugins/modules/add_object_override.py | 6 +- .../apply_object_override_as_default.py | 6 +- sccfm-ansible/plugins/modules/asa_ha_check.py | 8 +- .../plugins/modules/change_asa_boot_image.py | 8 +- .../modules/change_asa_local_password.py | 8 +- .../plugins/modules/clear_asa_shun.py | 8 +- .../plugins/modules/configure_manager.py | 4 +- .../plugins/modules/create_access_rule.py | 6 +- .../plugins/modules/create_network_group.py | 6 +- .../plugins/modules/create_network_object.py | 6 +- .../plugins/modules/delete_access_rule.py | 6 +- .../plugins/modules/delete_network_group.py | 10 +- .../plugins/modules/delete_network_object.py | 10 +- .../plugins/modules/delete_object_override.py | 6 +- .../plugins/modules/deploy_cdfmc_ftd.py | 8 +- .../plugins/modules/edit_object_override.py | 6 +- .../plugins/modules/execute_asa_cli.py | 8 +- .../plugins/modules/execute_ftd_cli.py | 8 +- .../plugins/modules/get_access_group.py | 8 +- .../plugins/modules/get_access_rule.py | 6 +- sccfm-ansible/plugins/modules/get_object.py | 6 +- .../plugins/modules/list_access_groups.py | 8 +- .../plugins/modules/list_access_rules.py | 6 +- .../plugins/modules/list_asa_boot_registry.py | 8 +- .../modules/list_asa_compatible_versions.py | 8 +- .../plugins/modules/list_asa_disk_files.py | 8 +- .../plugins/modules/list_asa_local_users.py | 6 +- .../modules/list_asa_not_on_version.py | 8 +- .../modules/list_cdfmc_access_policies.py | 8 +- .../modules/list_ftd_compatible_versions.py | 8 +- .../modules/list_ftd_not_on_version.py | 8 +- .../plugins/modules/list_managers.py | 8 +- .../plugins/modules/list_network_groups.py | 6 +- .../plugins/modules/list_network_objects.py | 6 +- sccfm-ansible/plugins/modules/onboard_asa.py | 2 +- .../plugins/modules/onboard_cdfmc_ftd.py | 8 +- .../plugins/modules/onboard_cdfmc_ftd_ztp.py | 8 +- .../plugins/modules/register_cdfmc_ftd.py | 4 +- .../plugins/modules/remove_asa_shun.py | 12 +- .../modules/remove_network_group_members.py | 8 +- .../plugins/modules/show_asa_shun.py | 8 +- .../tests/test_inventory_plugin_security.py | 188 +- .../modules/tests/test_module_utils_config.py | 4 +- .../plugins/modules/trigger_asa_upgrade.py | 8 +- .../plugins/modules/trigger_ftd_upgrade.py | 8 +- .../plugins/modules/update_access_rule.py | 6 +- .../plugins/modules/update_network_group.py | 6 +- .../plugins/modules/update_network_object.py | 10 +- .../plugins/modules/update_object_default.py | 10 +- tests/test_ansible_dependency_metadata.py | 11 + tests/test_devkit_cli.py | 190 ++ tests/test_prepare_ansible_release.py | 144 +- tests/test_release_artifacts.py | 115 +- tests/test_token_workspace.py | 1751 ++++++++++++++++- tests/test_verify_ansible_collection.py | 52 +- tests/test_verify_pypi_release.py | 8 +- 313 files changed, 6438 insertions(+), 984 deletions(-) create mode 100644 cisco_sccfm_cli/commands/tests/test_status.py create mode 100644 cisco_sccfm_cli/option_metadata.py create mode 100644 tests/test_devkit_cli.py diff --git a/.cz.yaml b/.cz.yaml index 6799643d..4896b455 100644 --- a/.cz.yaml +++ b/.cz.yaml @@ -3,3 +3,5 @@ commitizen: version_provider: pep621 tag_format: v$version update_changelog_on_bump: false + version_files: + - sccfm-ansible/plugins/module_utils/dependencies.py:_PAIRED_DEVKIT_REQUIREMENT diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5fba6e26..548dbc74 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,12 +31,13 @@ jobs: runs-on: ubuntu-latest environment: release-bot permissions: + actions: read contents: write outputs: version: ${{ steps.version.outputs.version }} tag: ${{ steps.version.outputs.tag }} - source_commit: ${{ steps.source.outputs.source_commit }} - bundle_name: ${{ steps.source.outputs.bundle_name }} + source_commit: ${{ steps.source.outputs.source_commit || steps.version.outputs.source_commit }} + bundle_name: ${{ steps.source.outputs.bundle_name || steps.version.outputs.bundle_name }} steps: - name: Checkout main uses: actions/checkout@v7 @@ -78,7 +79,16 @@ jobs: fi CURRENT_VERSION="$(poetry version -s)" - python - "${CURRENT_VERSION}" "${RELEASE_VERSION}" <<'PY' + RELEASE_TAG="v${RELEASE_VERSION}" + RESUME_RELEASE=false + if [[ "${CURRENT_VERSION}" = "${RELEASE_VERSION}" ]]; then + if [[ "${GITHUB_RUN_ATTEMPT}" -le 1 ]]; then + echo "::error::version ${RELEASE_VERSION} is already current; only a retry of the original workflow run can resume it" + exit 1 + fi + RESUME_RELEASE=true + else + python - "${CURRENT_VERSION}" "${RELEASE_VERSION}" <<'PY' import re import sys @@ -93,17 +103,43 @@ jobs: f"release version must be greater than current version {current}" ) PY + fi - RELEASE_TAG="v${RELEASE_VERSION}" - if git show-ref --verify --quiet "refs/tags/${RELEASE_TAG}"; then - echo "::error::tag ${RELEASE_TAG} already exists; retry the original workflow run" + if [[ "${RESUME_RELEASE}" = "true" ]]; then + if ! git show-ref --verify --quiet "refs/tags/${RELEASE_TAG}"; then + echo "::error::cannot resume without existing tag ${RELEASE_TAG}" + exit 1 + fi + elif git show-ref --verify --quiet "refs/tags/${RELEASE_TAG}"; then + echo "::error::tag ${RELEASE_TAG} already exists" exit 1 fi if gh release view "${RELEASE_TAG}" --repo "${GITHUB_REPOSITORY}" >/dev/null 2>&1; then - echo "::error::GitHub release ${RELEASE_TAG} already exists" - exit 1 + if [[ "${RESUME_RELEASE}" != "true" ]]; then + echo "::error::GitHub release ${RELEASE_TAG} already exists" + exit 1 + fi + RELEASE_IDENTITY="$(gh release view "${RELEASE_TAG}" \ + --repo "${GITHUB_REPOSITORY}" \ + --json isDraft,isPrerelease,tagName \ + --jq 'select(.isPrerelease == false) | .tagName')" + if [[ "${RELEASE_IDENTITY}" != "${RELEASE_TAG}" ]]; then + echo "::error::existing GitHub release is not the expected stable release for ${RELEASE_TAG}" + exit 1 + fi fi + DRAFT_RELEASE_TAGS="$(gh api --paginate \ + "repos/${GITHUB_REPOSITORY}/releases?per_page=100" \ + --jq '.[] | select(.draft == true) | .tag_name')" + while IFS= read -r draft_tag; do + [[ -z "${draft_tag}" ]] && continue + if [[ "${RESUME_RELEASE}" != "true" || "${draft_tag}" != "${RELEASE_TAG}" ]]; then + echo "::error::unresolved draft release blocks a new production release: ${draft_tag}" + exit 1 + fi + done <<< "${DRAFT_RELEASE_TAGS}" + for registry_and_url in \ "PyPI|https://pypi.org/pypi/cisco-sccfm-devkit/${RELEASE_VERSION}/json" \ "Ansible Galaxy|https://galaxy.ansible.com/api/v3/plugin/ansible/content/published/collections/index/cisco/sccfm/versions/${RELEASE_VERSION}/"; do @@ -119,8 +155,10 @@ jobs: 404) ;; 200) - echo "::error::${registry} already contains version ${RELEASE_VERSION}" - exit 1 + if [[ "${RESUME_RELEASE}" != "true" ]]; then + echo "::error::${registry} already contains version ${RELEASE_VERSION}" + exit 1 + fi ;; *) echo "::error::${registry} preflight failed with HTTP ${http_status}" @@ -129,11 +167,52 @@ jobs: esac done + if [[ "${RESUME_RELEASE}" = "true" ]]; then + SOURCE_COMMIT="$(git rev-parse "refs/tags/${RELEASE_TAG}^{commit}")" + if ! git merge-base --is-ancestor "${SOURCE_COMMIT}" HEAD; then + echo "::error::tag ${RELEASE_TAG} is not contained in checked-out main" + exit 1 + fi + + BUNDLE_PREFIX="sccfm-release-${RELEASE_VERSION}-${SOURCE_COMMIT}-attempt-" + RESUME_BUNDLES=() + while IFS= read -r artifact_name; do + if [[ "${artifact_name}" = "${BUNDLE_PREFIX}"* ]] \ + && [[ "${artifact_name#${BUNDLE_PREFIX}}" =~ ^[1-9][0-9]*$ ]]; then + RESUME_BUNDLES+=("${artifact_name}") + fi + done < <( + gh api --paginate \ + "repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/artifacts?per_page=100" \ + --jq '.artifacts[] | select(.expired == false) | .name' + ) + if [[ "${#RESUME_BUNDLES[@]}" -ne 1 ]]; then + echo "::error::expected exactly one unexpired manifest-bound bundle from this workflow run" + exit 1 + fi + BUNDLE_NAME="${RESUME_BUNDLES[0]}" + RESUME_ROOT="${RUNNER_TEMP}/release-resume-bundle" + mkdir -p "${RESUME_ROOT}" + gh run download "${GITHUB_RUN_ID}" \ + --repo "${GITHUB_REPOSITORY}" \ + --name "${BUNDLE_NAME}" \ + --dir "${RESUME_ROOT}" + poetry run python -m cisco_sccfm_scripts.release_artifacts verify \ + "${RESUME_ROOT}" \ + --version "${RELEASE_VERSION}" \ + --tag "${RELEASE_TAG}" \ + --source-commit "${SOURCE_COMMIT}" + echo "source_commit=${SOURCE_COMMIT}" >> "$GITHUB_OUTPUT" + echo "bundle_name=${BUNDLE_NAME}" >> "$GITHUB_OUTPUT" + fi + echo "version=${RELEASE_VERSION}" >> "$GITHUB_OUTPUT" echo "tag=${RELEASE_TAG}" >> "$GITHUB_OUTPUT" echo "previous_version=${CURRENT_VERSION}" >> "$GITHUB_OUTPUT" + echo "resume=${RESUME_RELEASE}" >> "$GITHUB_OUTPUT" - name: Synchronize exact release version + if: steps.version.outputs.resume != 'true' env: RELEASE_VERSION: ${{ steps.version.outputs.version }} PREVIOUS_VERSION: ${{ steps.version.outputs.previous_version }} @@ -145,6 +224,10 @@ jobs: --files-only \ --check-consistency test "$(poetry version -s)" = "${RELEASE_VERSION}" + poetry install --only-root --no-interaction + INSTALLED_VERSION="$(poetry run python -c \ + 'from importlib.metadata import version; print(version("cisco-sccfm-devkit"))')" + test "${INSTALLED_VERSION}" = "${RELEASE_VERSION}" poetry run python -m cisco_sccfm_scripts.prepare_ansible_release \ sccfm-ansible \ --previous-version "${PREVIOUS_VERSION}" \ @@ -155,6 +238,7 @@ jobs: poetry run generate-ansible-docs - name: Build release artifacts once + if: steps.version.outputs.resume != 'true' id: artifacts env: RELEASE_VERSION: ${{ steps.version.outputs.version }} @@ -189,6 +273,7 @@ jobs: echo "collection_path=${COLLECTION_PATH}" >> "$GITHUB_OUTPUT" - name: Run source gates + if: steps.version.outputs.resume != 'true' run: | set -euo pipefail poetry check --strict --lock @@ -210,6 +295,7 @@ jobs: poetry run check-doc-artifacts - name: Install pinned Gitleaks + if: steps.version.outputs.resume != 'true' run: | GITLEAKS_BIN_DIR="${RUNNER_TEMP}/gitleaks-bin" mkdir -p "${GITLEAKS_BIN_DIR}" @@ -217,6 +303,7 @@ jobs: echo "${GITLEAKS_BIN_DIR}" >> "$GITHUB_PATH" - name: Scan exact release artifacts + if: steps.version.outputs.resume != 'true' run: | set -euo pipefail WHEEL_SCAN_ROOT="$(mktemp -d "${RUNNER_TEMP}/sccfm-wheel-scan.XXXXXX")" @@ -237,6 +324,7 @@ jobs: done - name: Verify exact wheel and sdist installations + if: steps.version.outputs.resume != 'true' env: RELEASE_VERSION: ${{ steps.version.outputs.version }} run: | @@ -313,6 +401,7 @@ jobs: "${GITHUB_WORKSPACE}/${{ steps.artifacts.outputs.sdist_path }}" sdist - name: Verify exact wheel and collection pair + if: steps.version.outputs.resume != 'true' run: | poetry run python -m cisco_sccfm_scripts.verify_clean_controller \ "${{ steps.artifacts.outputs.wheel_path }}" \ @@ -320,6 +409,7 @@ jobs: --expected-version "${{ steps.version.outputs.version }}" - name: Run sanity against exact collection artifact + if: steps.version.outputs.resume != 'true' run: | set -euo pipefail VENV_PATH="$(poetry env info --path)" @@ -334,19 +424,18 @@ jobs: "${VENV_PATH}/bin/ansible-test" sanity --local --truncate 0 - name: Commit and tag verified source + if: steps.version.outputs.resume != 'true' id: source env: RELEASE_TAG: ${{ steps.version.outputs.tag }} RELEASE_VERSION: ${{ steps.version.outputs.version }} run: | set -euo pipefail - { - git diff --name-only - git ls-files --others --exclude-standard - } | sort -u | while IFS= read -r changed_path; do + while IFS= read -r changed_path; do case "${changed_path}" in CHANGELOG.md|pyproject.toml|sccfm-ansible/CHANGELOG.rst|\ sccfm-ansible/changelogs/changelog.yaml|sccfm-ansible/galaxy.yml|\ + sccfm-ansible/plugins/module_utils/dependencies.py|\ sccfm-ansible/requirements.txt|docs/cli/*|docs/man/*|docs/ansible/*) ;; *) @@ -354,7 +443,12 @@ jobs: exit 1 ;; esac - done < <(git diff --name-only) + done < <( + { + git diff --name-only + git ls-files --others --exclude-standard + } | sort -u + ) git config user.name "github-actions" git config user.email "github-actions@users.noreply.cisco.com" @@ -364,6 +458,7 @@ jobs: sccfm-ansible/CHANGELOG.rst \ sccfm-ansible/changelogs/changelog.yaml \ sccfm-ansible/galaxy.yml \ + sccfm-ansible/plugins/module_utils/dependencies.py \ sccfm-ansible/requirements.txt \ docs/cli \ docs/man \ @@ -378,6 +473,7 @@ jobs: echo "bundle_name=${BUNDLE_NAME}" >> "$GITHUB_OUTPUT" - name: Create and verify release manifest + if: steps.version.outputs.resume != 'true' id: manifest run: | poetry run python -m cisco_sccfm_scripts.release_artifacts create dist \ @@ -387,6 +483,7 @@ jobs: echo "path=dist/release-manifest.json" >> "$GITHUB_OUTPUT" - name: Preserve exact release bundle + if: steps.version.outputs.resume != 'true' uses: actions/upload-artifact@v4 with: name: ${{ steps.source.outputs.bundle_name }} @@ -400,12 +497,36 @@ jobs: retention-days: 30 - name: Push release commit and tag atomically + if: steps.version.outputs.resume != 'true' env: RELEASE_TAG: ${{ steps.version.outputs.tag }} + SOURCE_COMMIT: ${{ steps.source.outputs.source_commit }} run: | - git push --atomic origin \ + set -euo pipefail + if git push --atomic origin \ HEAD:refs/heads/main \ - "refs/tags/${RELEASE_TAG}:refs/tags/${RELEASE_TAG}" + "refs/tags/${RELEASE_TAG}:refs/tags/${RELEASE_TAG}"; then + exit 0 + fi + + for attempt in 1 2 3; do + if REMOTE_REFS="$(git ls-remote --refs origin \ + refs/heads/main "refs/tags/${RELEASE_TAG}")"; then + REMOTE_TAG_COMMIT="$(awk -v ref="refs/tags/${RELEASE_TAG}" \ + '$2 == ref { print $1 }' <<< "${REMOTE_REFS}")" + if [[ "${REMOTE_TAG_COMMIT}" = "${SOURCE_COMMIT}" ]] \ + && git fetch --no-tags origin refs/heads/main \ + && git merge-base --is-ancestor "${SOURCE_COMMIT}" FETCH_HEAD; then + echo "::warning::push reported failure, but the atomic remote update was verified" + exit 0 + fi + fi + if [[ "${attempt}" -lt 3 ]]; then + sleep 2 + fi + done + echo "::error::atomic push failed and the intended remote state could not be verified" + exit 1 create-draft-release: needs: build-release @@ -445,9 +566,20 @@ jobs: --tag "${RELEASE_TAG}" \ --source-commit "${SOURCE_COMMIT}" + RELEASE_IS_DRAFT=true if gh release view "${RELEASE_TAG}" --repo "${GITHUB_REPOSITORY}" \ - --json isDraft --jq '.isDraft' > "${RUNNER_TEMP}/release-is-draft" 2>/dev/null; then - test "$(cat "${RUNNER_TEMP}/release-is-draft")" = "true" + --json isDraft,isPrerelease,tagName \ + --jq '.tagName + "\t" + (.isDraft | tostring) + "\t" + (.isPrerelease | tostring)' \ + > "${RUNNER_TEMP}/release-identity" 2>/dev/null; then + RELEASE_IDENTITY="$(cat "${RUNNER_TEMP}/release-identity")" + case "${RELEASE_IDENTITY}" in + "${RELEASE_TAG}"$'\ttrue\tfalse') RELEASE_IS_DRAFT=true ;; + "${RELEASE_TAG}"$'\tfalse\tfalse') RELEASE_IS_DRAFT=false ;; + *) + echo "::error::existing release is not the expected stable release" + exit 1 + ;; + esac else gh release create "${RELEASE_TAG}" \ --repo "${GITHUB_REPOSITORY}" \ @@ -470,6 +602,10 @@ jobs: exit 1 } else + if [[ "${RELEASE_IS_DRAFT}" != "true" ]]; then + echo "::error::public release is missing immutable asset ${asset_name}" + exit 1 + fi gh release upload "${RELEASE_TAG}" "${local_asset}" \ --repo "${GITHUB_REPOSITORY}" fi @@ -537,17 +673,48 @@ jobs: python -m twine check --strict "${WHEEL_PATH}" "${SDIST_PATH}" set +e - python -m cisco_sccfm_scripts.verify_pypi_release "${BUNDLE_DIR}" \ + PYPI_VERIFICATION="$(python -m cisco_sccfm_scripts.verify_pypi_release "${BUNDLE_DIR}" \ --version "${RELEASE_VERSION}" \ --tag "${RELEASE_TAG}" \ - --source-commit "${SOURCE_COMMIT}" + --source-commit "${SOURCE_COMMIT}")" PYPI_STATUS=$? set -e + printf '%s\n' "${PYPI_VERIFICATION}" + + test ! -e dist + mkdir dist case "${PYPI_STATUS}" in 0) echo "publish=false" >> "$GITHUB_OUTPUT" ;; - 2|3) + 2) + cp "${WHEEL_PATH}" "${SDIST_PATH}" dist/ + echo "publish=true" >> "$GITHUB_OUTPUT" + ;; + 3) + MISSING_FILES="${PYPI_VERIFICATION##* missing=}" + if [[ "${MISSING_FILES}" = "${PYPI_VERIFICATION}" ]] \ + || [[ -z "${MISSING_FILES}" ]] \ + || [[ "${MISSING_FILES}" = *$'\n'* ]]; then + echo "::error::partial PyPI verification did not identify a missing artifact" + exit 1 + fi + IFS=',' read -r -a MISSING_ARTIFACTS <<< "${MISSING_FILES}" + for missing_artifact in "${MISSING_ARTIFACTS[@]}"; do + case "${missing_artifact}" in + "$(basename "${WHEEL_PATH}")") + cp "${WHEEL_PATH}" dist/ + ;; + "$(basename "${SDIST_PATH}")") + cp "${SDIST_PATH}" dist/ + ;; + *) + echo "::error::partial PyPI verification named an unexpected artifact" + exit 1 + ;; + esac + done + test "$(find dist -mindepth 1 -maxdepth 1 -type f | wc -l | tr -d ' ')" = "1" echo "publish=true" >> "$GITHUB_OUTPUT" ;; *) @@ -555,9 +722,6 @@ jobs: ;; esac - test ! -e dist - mkdir dist - cp "${WHEEL_PATH}" "${SDIST_PATH}" dist/ echo "packages_dir=dist/" >> "$GITHUB_OUTPUT" - name: Publish exact Python artifacts @@ -566,7 +730,6 @@ jobs: with: password: ${{ secrets.PYPI_API_TOKEN }} packages-dir: ${{ steps.pypi.outputs.packages_dir }} - skip-existing: true - name: Verify published PyPI release env: @@ -655,7 +818,10 @@ jobs: GALAXY_URL="https://galaxy.ansible.com/api/v3/plugin/ansible/content/published/collections/index/cisco/sccfm/versions/${RELEASE_VERSION}/" LOOKUP_ATTEMPTS=1 if [[ "${GITHUB_RUN_ATTEMPT}" -gt 1 ]]; then - LOOKUP_ATTEMPTS=12 + # A previous upload can be accepted while Galaxy is still importing it. + # Wait through the same ten-minute window used by collection publish before + # treating a retry-time 404 as proof that the immutable version is absent. + LOOKUP_ATTEMPTS=121 fi for attempt in $(seq 1 "${LOOKUP_ATTEMPTS}"); do HTTP_STATUS="$(curl --silent --show-error --location \ @@ -776,24 +942,102 @@ jobs: runs-on: ubuntu-latest environment: release-bot permissions: + actions: read contents: write steps: - - name: Publish verified GitHub release + - name: Checkout verified release tag + uses: actions/checkout@v7 + with: + ref: ${{ needs.build-release.outputs.tag }} + + - name: Set up Python + uses: actions/setup-python@v7 + with: + python-version: "3.12" + + - name: Download exact release bundle + uses: actions/download-artifact@v4 + with: + name: ${{ needs.build-release.outputs.bundle_name }} + path: ${{ runner.temp }}/release-bundle + + - name: Reverify assets and publish GitHub release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} RELEASE_TAG: ${{ needs.build-release.outputs.tag }} + RELEASE_VERSION: ${{ needs.build-release.outputs.version }} + SOURCE_COMMIT: ${{ needs.build-release.outputs.source_commit }} + BUNDLE_DIR: ${{ runner.temp }}/release-bundle run: | set -euo pipefail - IS_DRAFT="$(gh release view "${RELEASE_TAG}" \ + test "$(git rev-parse HEAD)" = "${SOURCE_COMMIT}" + python -m cisco_sccfm_scripts.release_artifacts verify "${BUNDLE_DIR}" \ + --version "${RELEASE_VERSION}" \ + --tag "${RELEASE_TAG}" \ + --source-commit "${SOURCE_COMMIT}" + + RELEASE_ASSETS_DIR="${RUNNER_TEMP}/final-release-assets" + mkdir -p "${RELEASE_ASSETS_DIR}" + gh release download "${RELEASE_TAG}" \ --repo "${GITHUB_REPOSITORY}" \ - --json isDraft \ - --jq '.isDraft')" - if [[ "${IS_DRAFT}" = "true" ]]; then + --dir "${RELEASE_ASSETS_DIR}" + python -m cisco_sccfm_scripts.release_artifacts verify "${RELEASE_ASSETS_DIR}" \ + --version "${RELEASE_VERSION}" \ + --tag "${RELEASE_TAG}" \ + --source-commit "${SOURCE_COMMIT}" + for local_asset in "${BUNDLE_DIR}"/*; do + asset_name="$(basename "${local_asset}")" + cmp -s "${local_asset}" "${RELEASE_ASSETS_DIR}/${asset_name}" || { + echo "::error::GitHub release asset differs from verified bundle: ${asset_name}" + exit 1 + } + done + + RELEASE_IDENTITY="$(gh release view "${RELEASE_TAG}" \ + --repo "${GITHUB_REPOSITORY}" \ + --json isDraft,isPrerelease,tagName \ + --jq '.tagName + "\t" + (.isDraft | tostring) + "\t" + (.isPrerelease | tostring)')" + RELEASE_TAG_NAME="${RELEASE_IDENTITY%%$'\t'*}" + RELEASE_FLAGS="${RELEASE_IDENTITY#*$'\t'}" + IS_DRAFT="${RELEASE_FLAGS%%$'\t'*}" + IS_PRERELEASE="${RELEASE_FLAGS#*$'\t'}" + test "${RELEASE_TAG_NAME}" = "${RELEASE_TAG}" + test "${IS_PRERELEASE}" = "false" + + PUBLIC_RELEASE_TAGS="$(gh api --paginate \ + "repos/${GITHUB_REPOSITORY}/releases?per_page=100" \ + --jq '.[] | select(.draft == false and .prerelease == false) | .tag_name')" + MAKE_LATEST="$(PUBLIC_RELEASE_TAGS="${PUBLIC_RELEASE_TAGS}" \ + python - "${RELEASE_VERSION}" <<'PY' + import os + import re + import sys + + pattern = re.compile( + r"^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$" + ) + current = tuple(int(part) for part in sys.argv[1].split(".")) + public_versions = [] + for tag in os.environ.get("PUBLIC_RELEASE_TAGS", "").splitlines(): + match = pattern.fullmatch(tag) + if match is not None: + public_versions.append(tuple(int(part) for part in match.groups())) + print("false" if any(version > current for version in public_versions) else "true") + PY + )" + if [[ "${MAKE_LATEST}" = "true" ]]; then gh release edit "${RELEASE_TAG}" \ --repo "${GITHUB_REPOSITORY}" \ --draft=false \ --latest else - test "${IS_DRAFT}" = "false" + gh release edit "${RELEASE_TAG}" \ + --repo "${GITHUB_REPOSITORY}" \ + --draft=false \ + --latest=false + fi + if [[ "${IS_DRAFT}" = "false" ]]; then echo "GitHub release ${RELEASE_TAG} is already public." + else + test "${IS_DRAFT}" = "true" fi diff --git a/README.md b/README.md index 92c75730..2639fb3f 100644 --- a/README.md +++ b/README.md @@ -46,9 +46,11 @@ Set the active profile once via the global option: `sccfm-cli --profile lab stat Every command lives in `cisco_sccfm_cli/commands/` as a concrete implementation of the command-pattern friendly `BaseCommand`, keeping files small and behavior isolated. By default, configuration is stored in `~/.sccfm-cli/config.json`. On POSIX systems the CLI -enforces mode `0700` on `~/.sccfm-cli` and `0600` on the configuration file, including existing -storage. On Windows, keep the configuration in your user profile and rely on the filesystem's -per-user access controls. Keep custom configuration paths private on every platform. +requires mode `0700` on `~/.sccfm-cli` and `0600` on the configuration file. Read-only commands +fail without changing metadata when those modes are unsafe; `sccfm-cli configure` repairs them +while updating a profile. Custom configuration files must also use mode `0600`, but the CLI does +not change an existing custom parent directory. On Windows, keep the configuration in your user +profile and rely on the filesystem's per-user access controls. Generated CLI reference docs can be previewed locally: @@ -99,11 +101,17 @@ The package root exports the supported public service classes and response model - Set up tokens interactively with `devkit` and select **change-tokens**. By default this writes `.vault_pass` and encrypted `group_vars/all/vault.yml` under `sccfm-ansible/examples`; both are Git-ignored and explicitly excluded from collection artifacts. Use `--path` to override the - examples directory when needed. + examples directory when needed. Packaged playbooks use `SCCFM_API_TOKEN` by default and prefer + the non-empty encrypted `vault_sccfm_api_token` value when the Vault file is loaded. The tool + still writes `sccfm_region` for compatibility, while packaged examples read `SCCFM_REGION`. + When migrating an older active-only Vault whose region cannot be resolved, set + `SCCFM_LEGACY_REGION` to that existing token's region; it is deliberately separate from the + new token's `--region` value. - 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. -- Configure SCCFM region (`int`, `us`, `eu`, `apj`, `au`, `uae`, `in`, or `ci`) plus - `SCCFM_API_TOKEN` in the controller environment. Never commit a plaintext token to an inventory - source. +- Configure SCCFM region (`int`, `us`, `eu`, `apj`, `au`, `uae`, `in`, or `ci`) in the controller + environment. Also set `SCCFM_API_TOKEN` unless an API playbook loads the Vault override; the + packaged inventory source always reads both values from the environment. Never commit a + plaintext token to an inventory source. - Point Ansible at an inventory file that uses the plugin, e.g. `ansible-inventory -i sccfm-ansible/examples/inventory.sccfm.yml --graph`. - The inventory plugin consumes its API token only during refresh and never exports it as a host or group variable. Do not use inventory output modes that render vars when your own diff --git a/RELEASING.md b/RELEASING.md index 88b89b43..3bb58d60 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -26,10 +26,17 @@ maintainer policy. 1. Merge all intended changes and confirm CI passes on the exact `main` commit to release. 2. Confirm the changelogs and documentation describe the intended public release. -3. Choose an unused exact version such as `0.39.0`. Enter it without a leading `v`. -4. Confirm that the version and its `v` tag do not already exist on PyPI, Ansible Galaxy, +3. Prepare the Ansible changelog history. The workflow may retarget the marked `0.38.0` seed for + the first release only; do not move or replace the seed marker afterward. For every later + release, add and review the new version entry in + `sccfm-ansible/changelogs/changelog.yaml` and its matching `v` section in + `sccfm-ansible/CHANGELOG.rst` on `main`, preserving all earlier releases. The YAML entry must + contain non-empty `changes`, a `fragments` list, and a valid `release_date`. The workflow fails + closed instead of converting the previous release entry when the requested version is absent. +4. Choose an unused exact version such as `0.39.0`. Enter it without a leading `v`. +5. Confirm that the version and its `v` tag do not already exist on PyPI, Ansible Galaxy, or GitHub Releases. -5. Confirm the PyPI account and Galaxy account still have the required namespace permissions. +6. Confirm the PyPI account and Galaxy account still have the required namespace permissions. Published registry versions are immutable. Never reuse a version for different contents. @@ -83,8 +90,12 @@ ANSIBLE_COLLECTIONS_PATH="${RELEASE_CHECK_ROOT}/collections" \ - Use **Re-run failed jobs**. Do not use **Re-run all jobs** after any registry publication may have succeeded. +- If the release commit and tag reached GitHub but the build job lost the push response, re-run + the failed job in the same workflow run. The workflow resumes only when the tag is contained in + `main` and exactly one unexpired artifact bundle from that run matches and verifies against the + tag commit. A new workflow dispatch cannot adopt an older run's artifacts. - If PyPI succeeds and Galaxy fails, retry only the failed Galaxy path. It downloads and verifies - the collection from the draft GitHub Release; it must not rebuild it. + the preserved Actions artifact from the original workflow run; it must not rebuild it. - A draft GitHub Release after a failed run is expected. Do not publish it manually while either registry is incomplete or unverified. - If a checksum, version, tag, or published-file verification fails, stop and investigate. Do not @@ -92,4 +103,3 @@ ANSIBLE_COLLECTIONS_PATH="${RELEASE_CHECK_ROOT}/collections" \ - Before starting a new workflow run after a failure, inspect the tag, draft release, PyPI, and Galaxy state. If any registry accepted the version, continue only by promoting the existing manifest-bound artifacts. - diff --git a/cisco_sccfm_cli/commands/base.py b/cisco_sccfm_cli/commands/base.py index 7cf0a960..ba1a2df0 100644 --- a/cisco_sccfm_cli/commands/base.py +++ b/cisco_sccfm_cli/commands/base.py @@ -16,6 +16,7 @@ from rich.spinner import Spinner from scc_firewall_manager_sdk import ApiException, CdoTransaction, ConnectivityState, Device +from cisco_sccfm_cli.option_metadata import is_sensitive_option from cisco_sccfm_cli.services import ConfigService from cisco_sccfm_cli.utils import print_json, redact_data, redact_text from cisco_sccfm_core import SccApiError @@ -63,6 +64,7 @@ def get_profile(self, ctx: click.Context, **kwargs: Any) -> ConfigLike: f"Profile '{profile}' not found. " f"Run 'sccfm-cli --profile {profile} configure' to set it up." ) + self._register_sensitive_value(ctx, config.api_token) return cast(ConfigLike, cast(object, config)) def build_params(self) -> Sequence[click.Parameter]: @@ -131,9 +133,9 @@ def _register_sensitive_value(self, ctx: click.Context, value: str) -> None: ctx.meta[self._SENSITIVE_VALUES_META_KEY] = (*values, value) def _register_sensitive_parameters(self, ctx: click.Context, kwargs: dict[str, Any]) -> None: - """Register values from Click options marked for hidden input.""" + """Register values from Click options explicitly marked as sensitive.""" for parameter in ctx.command.params: - if not isinstance(parameter, click.Option) or not parameter.hide_input: + if not isinstance(parameter, click.Option) or not is_sensitive_option(parameter): continue value = kwargs.get(parameter.name or "") if isinstance(value, str): @@ -146,6 +148,35 @@ def _sensitive_values(self, ctx: click.Context) -> tuple[str, ...]: return () return tuple(value for value in raw_values if isinstance(value, str) and value) + def _prompt_sensitive( + self, + text: str, + *, + default: str | None = None, + show_default: bool = True, + ) -> str: + """Prompt without echoing and immediately register the acquired secret.""" + value = cast( + str, + click.prompt( + text, + default=default, + hide_input=True, + show_default=show_default, + ), + ) + self._register_sensitive_value(click.get_current_context(), value) + return value + + def _active_sensitive_values( + self, sensitive_values: Sequence[str] | None = None + ) -> Sequence[str]: + """Resolve explicit secrets or inherit the active command registry.""" + if sensitive_values is not None: + return sensitive_values + ctx = click.get_current_context(silent=True) + return self._sensitive_values(ctx) if ctx is not None else () + @abstractmethod def handle(self, ctx: click.Context, **kwargs: Any) -> None: """Execute the command logic.""" @@ -259,8 +290,9 @@ def print_failed_transaction_details( cdo_transaction: CdoTransaction, format: str = "table", *, - sensitive_values: Sequence[str] = (), + sensitive_values: Sequence[str] | None = None, ) -> None: + sensitive_values = self._active_sensitive_values(sensitive_values) if format == "json": print_json(redact_data(cdo_transaction.to_dict(), sensitive_values)) else: diff --git a/cisco_sccfm_cli/commands/configure.py b/cisco_sccfm_cli/commands/configure.py index 42be5104..3bfa19e3 100644 --- a/cisco_sccfm_cli/commands/configure.py +++ b/cisco_sccfm_cli/commands/configure.py @@ -15,6 +15,7 @@ from cisco_sccfm_cli.commands.base import BaseCommand from cisco_sccfm_cli.models import Config +from cisco_sccfm_cli.option_metadata import sensitive_option from cisco_sccfm_cli.services import ConfigService from cisco_sccfm_core.constants import SCCFM_REGION_CHOICES, SCCFM_REGIONS, normalize_sccfm_region @@ -47,7 +48,7 @@ def build_params(self) -> Sequence[click.Parameter]: return [ GroupedOption( ["--config-path"], - type=click.Path(path_type=Path, resolve_path=True), + type=click.Path(path_type=Path, resolve_path=False), default=None, envvar="SCCFM_CONFIG", show_default=False, @@ -61,20 +62,22 @@ def build_params(self) -> Sequence[click.Parameter]: group=credential_group, required=True, ), - GroupedOption( - ["--api-token"], - type=str, - default=None, - envvar=self._API_TOKEN_ENVVAR, - show_envvar=True, - hide_input=True, - help=( - "API token for the chosen region. Passing it directly is supported for " - "compatibility but may expose it in process listings and shell history; " - f"prefer {self._API_TOKEN_ENVVAR} or the hidden prompt." + sensitive_option( + GroupedOption( + ["--api-token"], + type=str, + default=None, + envvar=self._API_TOKEN_ENVVAR, + show_envvar=True, + hide_input=True, + help=( + "API token for the chosen region. Passing it directly is supported for " + "compatibility but may expose it in process listings and shell history; " + f"prefer {self._API_TOKEN_ENVVAR} or the hidden prompt." + ), + group=credential_group, + required=False, ), - group=credential_group, - required=False, ), ] @@ -101,8 +104,7 @@ def _resolve_api_token(self, ctx: click.Context, **kwargs: Any) -> str: "An API token is required. Set " f"{self._API_TOKEN_ENVVAR} or run interactively for a hidden prompt." ) - api_token = click.prompt("API token", hide_input=True) - self._register_sensitive_value(ctx, api_token) + api_token = self._prompt_sensitive("API token") elif source is ParameterSource.COMMANDLINE: click.echo( "Warning: passing --api-token directly may expose it in process listings and " diff --git a/cisco_sccfm_cli/commands/inventory/devices/asa/onboard/command.py b/cisco_sccfm_cli/commands/inventory/devices/asa/onboard/command.py index 79b35949..a776caf7 100644 --- a/cisco_sccfm_cli/commands/inventory/devices/asa/onboard/command.py +++ b/cisco_sccfm_cli/commands/inventory/devices/asa/onboard/command.py @@ -18,6 +18,7 @@ from cisco_sccfm_cli.commands.base import BaseCommand from cisco_sccfm_cli.commands.inventory.options import config_path_option, format_option +from cisco_sccfm_cli.option_metadata import sensitive_option from cisco_sccfm_cli.utils import print_json, with_spinner from cisco_sccfm_core import ASA_ENTITY_TYPES, InventoryService, build_device_type_filter from cisco_sccfm_core.services.inventory import AsaOnboardService @@ -55,12 +56,14 @@ def build_params(self) -> Sequence[click.Parameter]: default=None, help="Username used to authenticate with the device.", ), - click.Option( - ["--password"], - required=False, - default=None, - hide_input=True, - help="Password used to authenticate with the device.", + sensitive_option( + click.Option( + ["--password"], + required=False, + default=None, + hide_input=True, + help="Password used to authenticate with the device.", + ), ), click.Option( ["--connector-type"], @@ -128,7 +131,7 @@ def handle(self, ctx: click.Context, **kwargs: Any) -> None: password = cast(str | None, kwargs.get("password")) if not password: - password = click.prompt("Password", hide_input=True) + password = self._prompt_sensitive("Password") kwargs = {**kwargs, "password": password} asa_input = self._build_asa_input(ctx, **kwargs) diff --git a/cisco_sccfm_cli/commands/inventory/devices/asa/smartlicense/command.py b/cisco_sccfm_cli/commands/inventory/devices/asa/smartlicense/command.py index d951d811..5fd09001 100644 --- a/cisco_sccfm_cli/commands/inventory/devices/asa/smartlicense/command.py +++ b/cisco_sccfm_cli/commands/inventory/devices/asa/smartlicense/command.py @@ -21,6 +21,7 @@ asa_device_filter_params, ) from cisco_sccfm_cli.commands.inventory.options import config_path_option, format_option +from cisco_sccfm_cli.option_metadata import sensitive_option from cisco_sccfm_cli.utils import with_spinner from cisco_sccfm_core import AsaCommandLineService from cisco_sccfm_core.types import ConfigLike @@ -56,6 +57,7 @@ def help_text(self) -> str: def handle(self, ctx: click.Context, **kwargs: Any) -> None: check = cast(bool, kwargs.get("check", False)) response_format = cast(str, kwargs.get("format")) + self._validate_token_sources(ctx=ctx, **kwargs) config = self.get_profile(ctx=ctx, **kwargs) targets = self._resolve_targets(ctx=ctx, kwargs=kwargs, config=config) @@ -81,7 +83,6 @@ def handle(self, ctx: click.Context, **kwargs: Any) -> None: ) token = self._resolve_token(ctx=ctx, **kwargs) - self._register_sensitive_value(ctx, token) script_commands = self._build_script(feature_tier, throughput_level, token) results = self._execute_cli( config=config, @@ -131,12 +132,6 @@ def _resolve_token(self, ctx: click.Context, **kwargs: Any) -> str: token = cast(str | None, kwargs.get("token")) token_file = cast(Path | None, kwargs.get("token_file")) - if token is not None and token_file is not None: - ctx.fail( - "Use only one Smart Licensing token source: --token, " - f"{self._TOKEN_ENVVAR}, or --token-file." - ) - if token_file is not None: token = self._read_token_file(ctx=ctx, token_file=token_file) elif token is None: @@ -146,10 +141,20 @@ def _resolve_token(self, ctx: click.Context, **kwargs: Any) -> str: f"{self._TOKEN_ENVVAR}, use --token-file, or run interactively " "for a hidden prompt." ) - token = click.prompt("Smart Licensing token", hide_input=True) + token = self._prompt_sensitive("Smart Licensing token") + self._register_sensitive_value(ctx, token) return self._validate_token(ctx=ctx, token=token) + def _validate_token_sources(self, ctx: click.Context, **kwargs: Any) -> None: + token = cast(str | None, kwargs.get("token")) + token_file = cast(Path | None, kwargs.get("token_file")) + if token is not None and token_file is not None: + ctx.fail( + "Use only one Smart Licensing token source: --token, " + f"{self._TOKEN_ENVVAR}, or --token-file." + ) + def _read_token_file(self, ctx: click.Context, token_file: Path) -> str: try: if token_file == Path("-"): @@ -240,18 +245,21 @@ def build_params(self) -> Sequence[click.Parameter]: asa_check_option(), format_option(), config_path_option(), - click.Option( - ["--token", "-t"], - type=str, - required=False, - default=None, - envvar=self._TOKEN_ENVVAR, - show_envvar=True, - hide_input=True, - help=( - "Smart Licensing token for your virtual account. Passing it directly is " - "supported for compatibility but may expose it in process listings and shell " - f"history; prefer {self._TOKEN_ENVVAR}, --token-file, or the hidden prompt." + sensitive_option( + click.Option( + ["--token", "-t"], + type=str, + required=False, + default=None, + envvar=self._TOKEN_ENVVAR, + show_envvar=True, + hide_input=True, + help=( + "Smart Licensing token for your virtual account. Passing it directly is " + "supported for compatibility but may expose it in process listings and " + f"shell history; prefer {self._TOKEN_ENVVAR}, --token-file, or the hidden " + "prompt." + ), ), ), click.Option( diff --git a/cisco_sccfm_cli/commands/inventory/devices/asa/user/change_password/command.py b/cisco_sccfm_cli/commands/inventory/devices/asa/user/change_password/command.py index 4a4c1577..78a50eb3 100644 --- a/cisco_sccfm_cli/commands/inventory/devices/asa/user/change_password/command.py +++ b/cisco_sccfm_cli/commands/inventory/devices/asa/user/change_password/command.py @@ -16,7 +16,8 @@ asa_device_filter_params, ) from cisco_sccfm_cli.commands.inventory.options import config_path_option, format_option -from cisco_sccfm_cli.utils import print_json, with_spinner +from cisco_sccfm_cli.option_metadata import sensitive_option +from cisco_sccfm_cli.utils import print_json, redact_data, redact_text, with_spinner from cisco_sccfm_core.models.asa_password_change_result import AsaPasswordChangeResult from cisco_sccfm_core.services.inventory.asa_user_password_service import ( AsaUserPasswordService, @@ -44,12 +45,14 @@ def build_params(self) -> Sequence[click.Parameter]: required=True, help="The local ASA username whose password will be changed.", ), - click.Option( - ["--new-password", "--password"], - required=False, - default=None, - hide_input=True, - help="The new password to set.", + sensitive_option( + click.Option( + ["--new-password", "--password"], + required=False, + default=None, + hide_input=True, + help="The new password to set.", + ), ), asa_check_option(), format_option(), @@ -80,7 +83,7 @@ def handle(self, ctx: click.Context, **kwargs: Any) -> None: username = cast(str, kwargs["username"]) new_password = cast(str | None, kwargs.get("new_password")) if not new_password: - new_password = click.prompt("Password", hide_input=True) + new_password = self._prompt_sensitive("Password") password_service = AsaUserPasswordService(config=config) results = password_service.change_password( @@ -115,6 +118,7 @@ def _render_json( results: dict[str, AsaPasswordChangeResult], uid_to_device: dict[str, Device], ) -> None: + sensitive_values = self._active_sensitive_values() output: list[dict[str, str]] = [] for device_uid, result in results.items(): device_name = uid_to_device[device_uid].name @@ -126,7 +130,7 @@ def _render_json( "message": result.message, } ) - print_json(output) + print_json(redact_data(output, sensitive_values)) def _render_table( self, @@ -138,14 +142,15 @@ def _render_table( table.add_column("Device UID") table.add_column("Status") table.add_column("Message") + sensitive_values = self._active_sensitive_values() for device_uid, result in results.items(): device_name = uid_to_device[device_uid].name status_display = self._colorize_status(result.status) table.add_row( - device_name, - device_uid, - status_display, - result.message, + redact_text(device_name, sensitive_values), + redact_text(device_uid, sensitive_values), + redact_text(status_display, sensitive_values), + redact_text(result.message, sensitive_values), ) self.console.print(table) diff --git a/cisco_sccfm_cli/commands/inventory/devices/cdfmc_managed_ftd/configure_manager/command.py b/cisco_sccfm_cli/commands/inventory/devices/cdfmc_managed_ftd/configure_manager/command.py index 720767dc..c9c1fbd9 100644 --- a/cisco_sccfm_cli/commands/inventory/devices/cdfmc_managed_ftd/configure_manager/command.py +++ b/cisco_sccfm_cli/commands/inventory/devices/cdfmc_managed_ftd/configure_manager/command.py @@ -13,6 +13,7 @@ from cisco_sccfm_cli.commands.base import BaseCommand from cisco_sccfm_cli.commands.inventory.options import format_option +from cisco_sccfm_cli.option_metadata import sensitive_option from cisco_sccfm_cli.utils import print_json, with_spinner from cisco_sccfm_core.services.inventory import ( FtdConfigureManagerError, @@ -58,16 +59,28 @@ def build_params(self) -> Sequence[click.Parameter]: required=True, help="SSH username for the FTD VM.", ), - click.Option( - ["--ftd-password"], - default=None, - envvar="SCCFM_FTD_PASSWORD", - help="SSH password for the FTD VM (or set SCCFM_FTD_PASSWORD; prompted if needed).", + sensitive_option( + click.Option( + ["--ftd-password"], + default=None, + envvar="SCCFM_FTD_PASSWORD", + help=( + "SSH password for the FTD VM (or set SCCFM_FTD_PASSWORD; prompted if " + "needed)." + ), + ), ), - click.Option( - ["--cli-key"], - required=True, - help="The full 'configure manager add ...' string returned by 'onboard'.", + sensitive_option( + click.Option( + ["--cli-key"], + default=None, + envvar="SCCFM_CLI_KEY", + show_envvar=True, + help=( + "The full 'configure manager add ...' string returned by 'onboard' " + "(or set SCCFM_CLI_KEY). Required unless --check is set." + ), + ), ), click.Option( ["--jump-host"], @@ -78,13 +91,15 @@ def build_params(self) -> Sequence[click.Parameter]: "IP must be on the FTD ssh-access-list." ), ), - click.Option( - ["--jump-password"], - default=None, - envvar="SCCFM_JUMP_PASSWORD", - help=( - "Password for the jump host (or set SCCFM_JUMP_PASSWORD). " - "Prompted if omitted; leave blank to use SSH key/agent auth." + sensitive_option( + click.Option( + ["--jump-password"], + default=None, + envvar="SCCFM_JUMP_PASSWORD", + help=( + "Password for the jump host (or set SCCFM_JUMP_PASSWORD). " + "Prompted if omitted; leave blank to use SSH key/agent auth." + ), ), ), click.Option( @@ -113,7 +128,6 @@ def handle(self, ctx: click.Context, **kwargs: Any) -> None: # Resolve credentials before the spinner starts; prompting under a live # spinner garbles the terminal. jump = self._build_jump_spec(**kwargs) - password = "" if check else self._resolve_ftd_password(**kwargs) # This command talks to the device purely over SSH and never calls the # SCCFM API, so it deliberately does not require a configured profile. @@ -123,7 +137,19 @@ def handle(self, ctx: click.Context, **kwargs: Any) -> None: self._handle_check(service, host, port, timeout, output_format, jump) return - self._execute(service, host, port, timeout, output_format, jump, password, **kwargs) + cli_key = self._require_cli_key(**kwargs) + password = self._resolve_ftd_password(**kwargs) + self._execute( + service, + host, + port, + timeout, + output_format, + jump, + password, + cli_key, + **kwargs, + ) @with_spinner("Configuring manager on FTD via SSH...") def _execute( @@ -135,17 +161,17 @@ def _execute( output_format: str, jump: JumpHostSpec | None, password: str, + manager_command: str, **kwargs: Any, ) -> None: username = cast(str, kwargs.get("ftd_user")) - cli_key = cast(str, kwargs.get("cli_key")) try: result = service.configure_manager( host=host, port=port, username=username, password=password, - cli_key=cli_key, + cli_key=manager_command, timeout=timeout, jump=jump, ) @@ -168,7 +194,7 @@ def _resolve_ftd_password(self, **kwargs: Any) -> str: if password: return password try: - return cast(str, click.prompt("FTD password", hide_input=True)) + return self._prompt_sensitive("FTD password") except click.Abort: # click.prompt raises Abort for both Ctrl-C and EOF. On a real # terminal it's an intentional Ctrl-C, so let it propagate to the @@ -181,6 +207,15 @@ def _resolve_ftd_password(self, **kwargs: Any) -> str: "SCCFM_FTD_PASSWORD when running non-interactively." ) + def _require_cli_key(self, **kwargs: Any) -> str: + cli_key = cast("str | None", kwargs.get("cli_key")) + if cli_key and cli_key.strip(): + return cli_key + raise click.ClickException( + "--cli-key is required unless --check is set. Set SCCFM_CLI_KEY when running " + "non-interactively." + ) + def _build_jump_spec(self, **kwargs: Any) -> JumpHostSpec | None: jump_host = cast("str | None", kwargs.get("jump_host")) if not jump_host: @@ -194,9 +229,8 @@ def _build_jump_spec(self, **kwargs: Any) -> JumpHostSpec | None: jump_password = cast("str | None", kwargs.get("jump_password")) if jump_password is None: - jump_password = click.prompt( + jump_password = self._prompt_sensitive( "Jump host password (leave blank for key/agent auth)", - hide_input=True, default="", show_default=False, ) diff --git a/cisco_sccfm_cli/commands/inventory/devices/cdfmc_managed_ftd/onboard_ztp/command.py b/cisco_sccfm_cli/commands/inventory/devices/cdfmc_managed_ftd/onboard_ztp/command.py index 24787148..a7c6b65a 100644 --- a/cisco_sccfm_cli/commands/inventory/devices/cdfmc_managed_ftd/onboard_ztp/command.py +++ b/cisco_sccfm_cli/commands/inventory/devices/cdfmc_managed_ftd/onboard_ztp/command.py @@ -4,7 +4,6 @@ from __future__ import annotations -import json from dataclasses import dataclass from typing import Any, Sequence, cast @@ -19,6 +18,7 @@ from cisco_sccfm_cli.commands.base import BaseCommand from cisco_sccfm_cli.commands.inventory.options import config_path_option, format_option +from cisco_sccfm_cli.option_metadata import sensitive_option from cisco_sccfm_cli.utils import print_json, with_spinner from cisco_sccfm_core import FTD_LICENSES, InventoryService from cisco_sccfm_core.services.inventory import FtdZtpOnboardService @@ -72,12 +72,17 @@ def build_params(self) -> Sequence[click.Parameter]: required=True, help="UUID of the FMC access policy to apply to this device.", ), - click.Option( - ["--admin-password"], - default=None, - help=( - "Initial provisioning password for the device. " - "Required for setup if a password has not already been set on the device." + sensitive_option( + click.Option( + ["--admin-password"], + default=None, + envvar="SCCFM_FTD_ADMIN_PASSWORD", + show_envvar=True, + help=( + "Initial provisioning password for the device. Required for setup if a " + "password has not already been set on the device. For secure " + "non-interactive use, set SCCFM_FTD_ADMIN_PASSWORD." + ), ), ), click.Option( diff --git a/cisco_sccfm_cli/commands/shared_options.py b/cisco_sccfm_cli/commands/shared_options.py index f85911e4..877fbf23 100644 --- a/cisco_sccfm_cli/commands/shared_options.py +++ b/cisco_sccfm_cli/commands/shared_options.py @@ -26,7 +26,7 @@ def config_path_option() -> click.Option: """Reusable --config-path option.""" return click.Option( ["--config-path"], - type=click.Path(path_type=Path, resolve_path=True), + type=click.Path(path_type=Path, resolve_path=False), default=None, envvar="SCCFM_CONFIG", show_default=False, diff --git a/cisco_sccfm_cli/commands/status.py b/cisco_sccfm_cli/commands/status.py index c004389c..974f78d2 100644 --- a/cisco_sccfm_cli/commands/status.py +++ b/cisco_sccfm_cli/commands/status.py @@ -36,7 +36,7 @@ def build_params(self) -> Sequence[click.Parameter]: return [ GroupedOption( ["--config-path"], - type=click.Path(path_type=Path, resolve_path=True), + type=click.Path(path_type=Path, resolve_path=False), default=None, envvar="SCCFM_CONFIG", show_default=False, diff --git a/cisco_sccfm_cli/commands/tests/inventory/devices/asa/onboard/test_onboard.py b/cisco_sccfm_cli/commands/tests/inventory/devices/asa/onboard/test_onboard.py index f28c42eb..3cb9a471 100644 --- a/cisco_sccfm_cli/commands/tests/inventory/devices/asa/onboard/test_onboard.py +++ b/cisco_sccfm_cli/commands/tests/inventory/devices/asa/onboard/test_onboard.py @@ -102,6 +102,46 @@ def test_should_onboard_asa( assert payload["name"] == "test-asa" +def test_should_redact_prompted_password_from_post_prompt_failures( + cli_runner: CliRunner, + default_config: Config, + mock_inventory_service: None, + monkeypatch: MonkeyPatch, +) -> None: + """A prompted ASA password should be registered before subsequent work can fail.""" + password = "prompted-asa-password-sentinel" + + def fail_with_password( + self: InventoryService, *, limit: int, offset: int, query: str | None = None + ) -> DevicePage: + raise RuntimeError(f"backend echoed {password}") + + monkeypatch.setattr(InventoryService, "get_devices", fail_with_password) + result = cli_runner.invoke( + cli, + [ + "inventory", + "devices", + "asa", + "onboard", + "--name", + "test-asa", + "--device-address", + "192.168.1.1:443", + "--username", + "admin", + "--connector-type", + "CDG", + ], + input=f"{password}\n", + ) + + assert result.exit_code != 0 + assert "" in result.output + assert password not in result.output + assert password not in repr(result.exception) + + def test_should_fail_if_connector_name_not_specified_and_connector_type_sdc( cli_runner: CliRunner, default_config: Config, diff --git a/cisco_sccfm_cli/commands/tests/inventory/devices/asa/smartlicense/test_token_input.py b/cisco_sccfm_cli/commands/tests/inventory/devices/asa/smartlicense/test_token_input.py index f89e4b44..fad3929d 100644 --- a/cisco_sccfm_cli/commands/tests/inventory/devices/asa/smartlicense/test_token_input.py +++ b/cisco_sccfm_cli/commands/tests/inventory/devices/asa/smartlicense/test_token_input.py @@ -118,6 +118,49 @@ def test_should_prompt_for_smart_license_token_without_echoing_it( _assert_not_exposed(result, caplog.text, token) +def test_should_redact_prompted_token_from_post_prompt_failure( + cli_runner: CliRunner, + default_config: Config, + mock_inventory_service: None, + monkeypatch: MonkeyPatch, + sample_devices: list[Device], + caplog: pytest.LogCaptureFixture, +) -> None: + """A hidden-prompt token must be registered before downstream execution.""" + token = _sentinel("prompt-failure") + monkeypatch.setattr(SmartlicenseCommand, "_can_prompt", lambda self: True) + + def fake_get_devices( + self: InventoryService, + *, + limit: int, + offset: int, + query: str | None = None, + ) -> DevicePage: + return DevicePage(count=len(sample_devices), items=sample_devices) + + def stub_cli_init(self: AsaCommandLineService, config: Any) -> None: + return None + + def fail_execute( + self: AsaCommandLineService, + *, + device_uids: list[str], + asa_commands: list[str], + ) -> list[CdoCliResult]: + raise RuntimeError(f"execution echoed {token}") + + monkeypatch.setattr(InventoryService, "get_devices", fake_get_devices) + monkeypatch.setattr(AsaCommandLineService, "__init__", stub_cli_init) + monkeypatch.setattr(AsaCommandLineService, "execute_cli", fail_execute) + + result = cli_runner.invoke(cli, _command_args(), input=f"{token}\n") + + assert result.exit_code != 0 + assert "" in result.output + _assert_not_exposed(result, caplog.text, token) + + def test_should_fail_noninteractively_without_smart_license_token( cli_runner: CliRunner, default_config: Config, @@ -165,6 +208,31 @@ def test_should_reject_multiple_smart_license_token_sources_without_exposing_the _assert_not_exposed(result, caplog.text, environment_token, file_token) +def test_check_should_reject_multiple_smart_license_token_sources( + cli_runner: CliRunner, + default_config: Config, + mock_inventory_service: None, + monkeypatch: MonkeyPatch, + sample_devices: list[Device], + sample_cli_results: list[CdoCliResult], + tmp_path: Path, +) -> None: + """Preflight should enforce the same token-source constraints as execution.""" + token_file = tmp_path / "smart-license-token" + token_file.write_text(_sentinel("file-check-conflict"), encoding="utf-8") + captured = _stub_execution(monkeypatch, sample_devices, sample_cli_results) + + result = cli_runner.invoke( + cli, + _command_args("--token-file", str(token_file), "--check"), + env={_TOKEN_ENVVAR: _sentinel("environment-check-conflict")}, + ) + + assert result.exit_code != 0 + assert "only one Smart Licensing token source" in result.output + assert "asa_commands" not in captured + + @pytest.mark.parametrize( "token", [ diff --git a/cisco_sccfm_cli/commands/tests/inventory/devices/asa/user/test_change_password.py b/cisco_sccfm_cli/commands/tests/inventory/devices/asa/user/test_change_password.py index 64f4949f..4bac6fba 100644 --- a/cisco_sccfm_cli/commands/tests/inventory/devices/asa/user/test_change_password.py +++ b/cisco_sccfm_cli/commands/tests/inventory/devices/asa/user/test_change_password.py @@ -7,6 +7,7 @@ import json from typing import Any +import pytest from _pytest.monkeypatch import MonkeyPatch from click.testing import CliRunner from scc_firewall_manager_sdk import CdoTransaction, Device, DevicePage, EntityType @@ -411,3 +412,62 @@ def fake_change_password( assert result.exit_code != 0 assert '"transactionUid": "tx-123"' in result.output assert '"cdoTransactionStatus": "ERROR"' in result.output + + +@pytest.mark.parametrize("output_format", ["table", "json"]) +def test_should_redact_prompted_password_from_returned_results( + cli_runner: CliRunner, + default_config: Config, + mock_inventory_service: None, + monkeypatch: MonkeyPatch, + sample_devices: list[Device], + output_format: str, +) -> None: + """Returned messages must inherit secrets registered by the prompt helper.""" + password = "prompted-password-result-sentinel" + + def fake_get_devices( + self: InventoryService, *, limit: int, offset: int, query: str | None = None + ) -> DevicePage: + return DevicePage(count=len(sample_devices), items=sample_devices) + + def fake_change_password( + self: AsaUserPasswordService, + *, + device_uids: list[str], + username: str, + new_password: str, + ) -> dict[str, AsaPasswordChangeResult]: + return { + device_uids[0]: AsaPasswordChangeResult( + device_uid=device_uids[0], + status="failed", + message=f"device echoed {password}", + ) + } + + monkeypatch.setattr(InventoryService, "get_devices", fake_get_devices) + monkeypatch.setattr(AsaUserPasswordService, "change_password", fake_change_password) + _stub_password_service(monkeypatch) + + result = cli_runner.invoke( + cli, + [ + "inventory", + "devices", + "asa", + "user", + "change-password", + "-u", + "uid-1", + "--username", + "admin", + "--format", + output_format, + ], + input=f"{password}\n", + ) + + assert result.exit_code == 0, result.output + assert "" in result.output + assert password not in result.output diff --git a/cisco_sccfm_cli/commands/tests/inventory/devices/cdfmc_managed_ftd/test_configure_manager.py b/cisco_sccfm_cli/commands/tests/inventory/devices/cdfmc_managed_ftd/test_configure_manager.py index 3aec3157..f30e3953 100644 --- a/cisco_sccfm_cli/commands/tests/inventory/devices/cdfmc_managed_ftd/test_configure_manager.py +++ b/cisco_sccfm_cli/commands/tests/inventory/devices/cdfmc_managed_ftd/test_configure_manager.py @@ -143,6 +143,33 @@ def fake_configure( class TestFailure: + def test_should_require_cli_key_outside_check( + self, + cli_runner: CliRunner, + default_config: Config, + monkeypatch: MonkeyPatch, + ) -> None: + monkeypatch.delenv("SCCFM_CLI_KEY", raising=False) + + result = cli_runner.invoke( + cli, + [ + "inventory", + "devices", + "cdfmc-managed-ftd", + "configure-manager", + "--ftd-host", + "10.0.0.5", + "--ftd-user", + "admin", + "--ftd-password", + "s3cr3t", + ], + ) + + assert result.exit_code != 0 + assert "--cli-key is required unless --check is set" in result.output + def test_should_fail_when_ftd_rejects( self, cli_runner: CliRunner, @@ -190,9 +217,55 @@ def fake_configure( assert result.exit_code != 0 assert "configure manager add" in result.output + def test_should_redact_all_credential_sources_from_service_failure( + self, + cli_runner: CliRunner, + default_config: Config, + monkeypatch: MonkeyPatch, + ) -> None: + """FTD, jump, and manager credentials must be redacted after acquisition.""" + ftd_password = "prompted-ftd-password-sentinel" + jump_password = "prompted-jump-password-sentinel" + cli_key = "configure manager add secret-manager-key-sentinel" + monkeypatch.setattr(FtdConfigureManagerService, "__init__", _stub_service_init) + + def fake_configure( + self: FtdConfigureManagerService, **kwargs: Any + ) -> ConfigureManagerResult: + jump = kwargs["jump"] + raise FtdConfigureManagerError( + f"credentials: {kwargs['password']} {kwargs['cli_key']} {jump.password}", + output=f"device echoed {kwargs['password']} and {jump.password}", + ) + + monkeypatch.setattr(FtdConfigureManagerService, "configure_manager", fake_configure) + result = cli_runner.invoke( + cli, + [ + "inventory", + "devices", + "cdfmc-managed-ftd", + "configure-manager", + "--ftd-host", + "10.0.0.5", + "--ftd-user", + "admin", + "--jump-host", + "jump.example.test", + ], + input=f"{jump_password}\n{ftd_password}\n", + env={"SCCFM_CLI_KEY": cli_key}, + ) + + assert result.exit_code != 0 + assert "" in result.output + for secret in (ftd_password, jump_password, cli_key): + assert secret not in result.output + assert secret not in repr(result.exception) + class TestCheckMode: - def test_check_reachable( + def test_check_reachable_without_cli_key( self, cli_runner: CliRunner, default_config: Config, @@ -206,8 +279,22 @@ def __exit__(self, *args: Any) -> None: return None monkeypatch.setattr(socket, "create_connection", lambda *a, **k: _FakeConn()) + monkeypatch.delenv("SCCFM_CLI_KEY", raising=False) - result = cli_runner.invoke(cli, _BASE_ARGS + ["--check"]) + result = cli_runner.invoke( + cli, + [ + "inventory", + "devices", + "cdfmc-managed-ftd", + "configure-manager", + "--ftd-host", + "10.0.0.5", + "--ftd-user", + "admin", + "--check", + ], + ) assert result.exit_code == 0, f"Command failed: {result.output}" assert "reachable" in result.output diff --git a/cisco_sccfm_cli/commands/tests/inventory/devices/cdfmc_managed_ftd/test_onboard_ztp.py b/cisco_sccfm_cli/commands/tests/inventory/devices/cdfmc_managed_ftd/test_onboard_ztp.py index 45170395..b8573f19 100644 --- a/cisco_sccfm_cli/commands/tests/inventory/devices/cdfmc_managed_ftd/test_onboard_ztp.py +++ b/cisco_sccfm_cli/commands/tests/inventory/devices/cdfmc_managed_ftd/test_onboard_ztp.py @@ -9,7 +9,6 @@ import json from typing import Any -import pytest from _pytest.monkeypatch import MonkeyPatch from click.testing import CliRunner from scc_firewall_manager_sdk import Device, DevicePage, EntityType, ZtpOnboardingInput @@ -153,6 +152,36 @@ def fake_onboard( assert captured["input"].admin_password == "s3cr3t" assert captured["input"].device_group_uid == "group-uid-xyz" + def test_should_read_admin_password_from_environment( + self, + cli_runner: CliRunner, + default_config: Config, + mock_inventory_service: None, + monkeypatch: MonkeyPatch, + ) -> None: + """ZTP should provide a schema-approved non-argv credential source.""" + admin_password = "environment-admin-password-sentinel" + monkeypatch.setattr(InventoryService, "get_devices", _empty_device_page) + monkeypatch.setattr(FtdZtpOnboardService, "__init__", _stub_ztp_service_init) + captured: dict[str, ZtpOnboardingInput] = {} + + def fake_onboard( + self: FtdZtpOnboardService, ztp_onboarding_input: ZtpOnboardingInput + ) -> Device: + captured["input"] = ztp_onboarding_input + return _fake_device() + + monkeypatch.setattr(FtdZtpOnboardService, "onboard_ftd_ztp", fake_onboard) + result = cli_runner.invoke( + cli, + _BASE_ARGS, + env={"SCCFM_FTD_ADMIN_PASSWORD": admin_password}, + ) + + assert result.exit_code == 0, result.output + assert captured["input"].admin_password == admin_password + assert admin_password not in result.output + def test_should_support_multiple_licenses( self, cli_runner: CliRunner, diff --git a/cisco_sccfm_cli/commands/tests/inventory/devices/test_devices_list.py b/cisco_sccfm_cli/commands/tests/inventory/devices/test_devices_list.py index 4e1fcca2..6f6a5ae0 100644 --- a/cisco_sccfm_cli/commands/tests/inventory/devices/test_devices_list.py +++ b/cisco_sccfm_cli/commands/tests/inventory/devices/test_devices_list.py @@ -5,16 +5,25 @@ from __future__ import annotations import json +import os +import stat +from pathlib import Path from typing import Any +import pytest from _pytest.monkeypatch import MonkeyPatch from click.testing import CliRunner -from scc_firewall_manager_sdk import Device, DevicePage +from scc_firewall_manager_sdk import ApiException, Device, DevicePage from cisco_sccfm_cli.cli import cli from cisco_sccfm_cli.models import Config from cisco_sccfm_core.services import InventoryService +POSIX_ONLY = pytest.mark.skipif( + os.name != "posix", + reason="POSIX permission bits are not portable to this platform", +) + def test_should_return_devices_as_json( cli_runner: CliRunner, @@ -88,3 +97,49 @@ def fake_get_devices( assert "Page:" in result.output for sample_device in sample_devices: assert sample_device.name in result.output + + +@POSIX_ONLY +def test_readonly_inventory_rejects_unsafe_profile_without_changing_permissions( + cli_runner: CliRunner, + default_config: Config, + config_path: Path, +) -> None: + """Readonly business commands must not repair unsafe profile storage.""" + config_path.chmod(0o640) + parent_mode = stat.S_IMODE(config_path.parent.stat().st_mode) + + result = cli_runner.invoke(cli, ["inventory", "devices", "list"]) + + assert result.exit_code != 0 + assert "expected 0600, found 0640" in result.output + assert default_config.api_token not in result.output + assert default_config.api_token not in repr(result.exception) + assert stat.S_IMODE(config_path.stat().st_mode) == 0o640 + assert stat.S_IMODE(config_path.parent.stat().st_mode) == parent_mode + + +def test_should_redact_stored_api_token_from_api_errors( + cli_runner: CliRunner, + default_config: Config, + mock_inventory_service: None, + monkeypatch: MonkeyPatch, +) -> None: + """Profile credentials should enter redaction as soon as the profile is loaded.""" + + def fail_with_token( + self: InventoryService, *, limit: int, offset: int, query: str | None = None + ) -> DevicePage: + raise ApiException( + status=400, + body=json.dumps({"errorMsg": f"server echoed {default_config.api_token}"}), + ) + + monkeypatch.setattr(InventoryService, "get_devices", fail_with_token) + + result = cli_runner.invoke(cli, ["inventory", "devices", "list", "--format", "json"]) + + assert result.exit_code != 0 + assert "" in result.output + assert default_config.api_token not in result.output + assert default_config.api_token not in repr(result.exception) diff --git a/cisco_sccfm_cli/commands/tests/test_configure.py b/cisco_sccfm_cli/commands/tests/test_configure.py index b9d5053a..4d999afd 100644 --- a/cisco_sccfm_cli/commands/tests/test_configure.py +++ b/cisco_sccfm_cli/commands/tests/test_configure.py @@ -5,8 +5,11 @@ from __future__ import annotations import hmac +import os +import stat from pathlib import Path +import pytest from _pytest.monkeypatch import MonkeyPatch from click.testing import CliRunner @@ -16,6 +19,10 @@ from cisco_sccfm_cli.services import ConfigService _API_TOKEN_ENVVAR = "SCCFM_API_TOKEN" +POSIX_ONLY = pytest.mark.skipif( + os.name != "posix", + reason="POSIX permission bits are not portable to this platform", +) def test_should_create_new_profile(cli_runner: CliRunner, config_path: Path) -> None: @@ -64,6 +71,33 @@ def test_should_read_api_token_from_environment(cli_runner: CliRunner, config_pa _assert_same_secret(stored.api_token, api_token) +@POSIX_ONLY +def test_configure_repairs_unsafe_file_and_preserves_other_profiles( + cli_runner: CliRunner, + config_path: Path, +) -> None: + """The explicit local-write command may repair storage before updating it.""" + existing = Config(profile="existing", region="us", api_token="existing-example-token") + service = ConfigService(path=config_path) + service.save(existing) + config_path.chmod(0o644) + parent_mode = stat.S_IMODE(config_path.parent.stat().st_mode) + + result = cli_runner.invoke( + cli, + ["--profile", "added", "configure", "--region", "eu"], + env={_API_TOKEN_ENVVAR: "added-example-token"}, + ) + + assert result.exit_code == 0, result.output + assert stat.S_IMODE(config_path.stat().st_mode) == 0o600 + assert stat.S_IMODE(config_path.parent.stat().st_mode) == parent_mode + assert service.load(existing.profile) == existing + assert service.load("added") == Config( + profile="added", region="eu", api_token="added-example-token" + ) + + def test_should_prompt_for_api_token_without_echoing_it( cli_runner: CliRunner, config_path: Path, diff --git a/cisco_sccfm_cli/commands/tests/test_schema.py b/cisco_sccfm_cli/commands/tests/test_schema.py index 88272353..e02a2f4c 100644 --- a/cisco_sccfm_cli/commands/tests/test_schema.py +++ b/cisco_sccfm_cli/commands/tests/test_schema.py @@ -89,7 +89,11 @@ def test_schema_export_should_emit_machine_readable_command_tree( assert _option(payload["global_options"], "profile")["placement"] == "before_command_path" commands = _commands_by_name(payload) - assert "sccfm-cli schema export" in commands + schema_export = commands["sccfm-cli schema export"] + assert schema_export["readonly"] is True + assert schema_export["side_effects"] == [ + "May write or overwrite the local file specified by --output." + ] assert "sccfm-cli inventory devices asa upgrade trigger" in commands assert "sccfm-cli configure" in commands assert any(command["kind"] == "group" for command in payload["command_tree"]) @@ -109,7 +113,7 @@ def test_schema_export_should_describe_options_and_auth_requirements( assert configure["readonly"] is True assert configure["side_effects"] == [ - "Writes the selected profile to the local sccfm-cli configuration file." + "Writes the selected profile and repairs local POSIX configuration permissions." ] assert configure["auth"]["mode"] == "none" assert configure["auth"]["requires_profile"] is False @@ -126,6 +130,8 @@ def test_schema_export_should_describe_options_and_auth_requirements( assert status["auth"]["mode"] == "sccfm_profile" assert status["auth"]["requires_profile"] is True assert status["auth"]["requires_api_token"] is True + assert status["readonly"] is True + assert status["side_effects"] == [] def test_schema_export_should_include_mutation_and_handler_constraints( @@ -137,7 +143,9 @@ def test_schema_export_should_include_mutation_and_handler_constraints( commands = _commands_by_name(json.loads(result.output)) asa_cli = commands["sccfm-cli inventory devices asa cli execute"] ftd_cli = commands["sccfm-cli inventory devices cdfmc-managed-ftd cli execute"] + configure_manager = commands["sccfm-cli inventory devices cdfmc-managed-ftd configure-manager"] ftd_onboard = commands["sccfm-cli inventory devices cdfmc-managed-ftd onboard"] + ftd_onboard_ztp = commands["sccfm-cli inventory devices cdfmc-managed-ftd onboard-ztp"] network_update = commands["sccfm-cli objects network update"] smartlicense = commands["sccfm-cli inventory devices asa smartlicense"] @@ -202,6 +210,34 @@ def test_schema_export_should_include_mutation_and_handler_constraints( assert "--token" not in smartlicense["examples"][1] assert "--token-file" not in smartlicense["examples"][1] assert "--feature-tier standard" in smartlicense["examples"][1] + configure_manager_credentials = { + name: _option(configure_manager["options"], name) + for name in ("ftd_password", "cli_key", "jump_password") + } + assert all(option["sensitive"] is True for option in configure_manager_credentials.values()) + assert configure_manager_credentials["ftd_password"]["envvar"] == "SCCFM_FTD_PASSWORD" + assert configure_manager_credentials["jump_password"]["envvar"] == "SCCFM_JUMP_PASSWORD" + assert configure_manager_credentials["cli_key"]["envvar"] == "SCCFM_CLI_KEY" + assert configure_manager_credentials["cli_key"]["required"] is False + assert _constraint_for_options( + configure_manager["constraints"], "required_unless", ["cli_key"] + ) == { + "type": "required_unless", + "options": ["cli_key"], + "unless": "check", + "description": "Required unless --check is set.", + } + assert "--cli-key" not in configure_manager["examples"][1] + assert configure_manager["auth"]["mode"] == "none" + assert configure_manager["auth"]["requires_profile"] is False + assert configure_manager["auth"]["requires_api_token"] is False + assert configure_manager["readonly"] is False + assert configure_manager["side_effects"] == [ + "May change state in SCC Firewall Manager or on managed devices." + ] + admin_password = _option(ftd_onboard_ztp["options"], "admin_password") + assert admin_password["sensitive"] is True + assert admin_password["envvar"] == "SCCFM_FTD_ADMIN_PASSWORD" ftd_virtual_dependency = _constraint(ftd_onboard["constraints"], "depends_on") assert ftd_virtual_dependency["option"] == "virtual" assert ftd_virtual_dependency["requires"] == "performance_tier" diff --git a/cisco_sccfm_cli/commands/tests/test_status.py b/cisco_sccfm_cli/commands/tests/test_status.py new file mode 100644 index 00000000..4d85279b --- /dev/null +++ b/cisco_sccfm_cli/commands/tests/test_status.py @@ -0,0 +1,41 @@ +# Copyright 2026 Cisco Systems, Inc. and its affiliates +# +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import os +import stat +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from cisco_sccfm_cli.cli import cli +from cisco_sccfm_cli.models import Config + +POSIX_ONLY = pytest.mark.skipif( + os.name != "posix", + reason="POSIX permission bits are not portable to this platform", +) + + +@POSIX_ONLY +def test_status_rejects_unsafe_profile_without_changing_permissions( + cli_runner: CliRunner, + default_config: Config, + config_path: Path, +) -> None: + """Readonly profile checks must fail closed without repairing local metadata.""" + config_path.chmod(0o644) + parent_mode = stat.S_IMODE(config_path.parent.stat().st_mode) + + result = cli_runner.invoke(cli, ["status"]) + + assert result.exit_code != 0 + assert "expected 0600, found 0644" in result.output + assert "sccfm-cli configure" in result.output + assert default_config.api_token not in result.output + assert default_config.api_token not in repr(result.exception) + assert stat.S_IMODE(config_path.stat().st_mode) == 0o644 + assert stat.S_IMODE(config_path.parent.stat().st_mode) == parent_mode diff --git a/cisco_sccfm_cli/e2e/_profile.py b/cisco_sccfm_cli/e2e/_profile.py index 3004b1af..1759deac 100644 --- a/cisco_sccfm_cli/e2e/_profile.py +++ b/cisco_sccfm_cli/e2e/_profile.py @@ -29,6 +29,10 @@ from cisco_sccfm_cli.services import ConfigService E2E_PROFILE_NAME = "e2e" +_REGION_ENV_LOOKUPS = { + "{{ lookup('env', 'SCCFM_REGION') }}", + '{{ lookup("env", "SCCFM_REGION") }}', +} @dataclass(frozen=True) @@ -67,9 +71,8 @@ def _decode_vault(vault_file: Path, vault_pass: Path) -> dict[str, Any]: completed = subprocess.run(cmd, capture_output=True, text=True, check=False) if completed.returncode != 0: raise RuntimeError( - f"ansible-vault view failed (rc={completed.returncode}):\n" - f"--- stdout ---\n{completed.stdout}\n" - f"--- stderr ---\n{completed.stderr}" + f"ansible-vault could not decrypt the E2E credential Vault " + f"(exit {completed.returncode})" ) parsed = yaml.safe_load(completed.stdout) or {} if not isinstance(parsed, dict): @@ -111,11 +114,15 @@ def bootstrap_profile(config_dir: Path) -> ProfileContext: vault_vars = _decode_vault(vault_file, vault_pass) region = plain_vars.get("sccfm_region") - api_token = vault_vars.get("sccfm_api_token") + if region in _REGION_ENV_LOOKUPS: + region = os.environ.get("SCCFM_REGION") + api_token = vault_vars.get("vault_sccfm_api_token") or vault_vars.get("sccfm_api_token") if not region: raise RuntimeError(f"sccfm_region missing from {vars_file}") if not api_token: - raise RuntimeError(f"sccfm_api_token missing from {vault_file}") + raise RuntimeError( + f"vault_sccfm_api_token (or legacy sccfm_api_token) missing from {vault_file}" + ) config_dir.mkdir(parents=True, exist_ok=True) config_path = config_dir / "config.json" diff --git a/cisco_sccfm_cli/option_metadata.py b/cisco_sccfm_cli/option_metadata.py new file mode 100644 index 00000000..8e71ce4c --- /dev/null +++ b/cisco_sccfm_cli/option_metadata.py @@ -0,0 +1,26 @@ +# Copyright 2026 Cisco Systems, Inc. and its affiliates +# +# SPDX-License-Identifier: Apache-2.0 + +"""Metadata helpers for Click options exposed through the CLI schema.""" + +from __future__ import annotations + +from typing import TypeVar + +import click + +_SENSITIVE_ATTRIBUTE = "_sccfm_sensitive" + +_OptionT = TypeVar("_OptionT", bound=click.Option) + + +def sensitive_option(option: _OptionT) -> _OptionT: + """Mark an option value as sensitive without changing its input behavior.""" + setattr(option, _SENSITIVE_ATTRIBUTE, True) + return option + + +def is_sensitive_option(option: click.Option) -> bool: + """Return whether an option contains a credential or other secret value.""" + return bool(getattr(option, _SENSITIVE_ATTRIBUTE, False) or option.hide_input) diff --git a/cisco_sccfm_cli/schema.py b/cisco_sccfm_cli/schema.py index e1d49333..606689ef 100644 --- a/cisco_sccfm_cli/schema.py +++ b/cisco_sccfm_cli/schema.py @@ -16,6 +16,8 @@ import click from scc_firewall_manager_sdk import ConfigState, ConnectivityState, EntityType +from cisco_sccfm_cli.option_metadata import is_sensitive_option + SCHEMA_VERSION = "1.0" _DISTRIBUTION_NAME = "cisco-sccfm-devkit" @@ -25,8 +27,16 @@ ("schema", "export"), } +_NO_PROFILE_COMMANDS = { + *_SCCFM_FREE_COMMANDS, + ("inventory", "devices", "cdfmc-managed-ftd", "configure-manager"), +} + _LOCAL_SIDE_EFFECT_COMMANDS: dict[tuple[str, ...], str] = { - ("configure",): "Writes the selected profile to the local sccfm-cli configuration file.", + ("configure",): ( + "Writes the selected profile and repairs local POSIX configuration permissions." + ), + ("schema", "export"): "May write or overwrite the local file specified by --output.", } _SCCFM_READONLY_LEAF_NAMES = { @@ -315,7 +325,7 @@ def _auth(*, path: tuple[str, ...], is_group: bool) -> dict[str, Any]: def _auth_requirements(*, path: tuple[str, ...], is_group: bool) -> dict[str, Any]: - if is_group or path in _SCCFM_FREE_COMMANDS: + if is_group or path in _NO_PROFILE_COMMANDS: return { "requires_profile": False, "requires_api_token": False, @@ -349,7 +359,7 @@ def _option_schema(option: click.Option, *, scope: str) -> dict[str, Any]: "nargs": option.nargs, "is_flag": bool(option.is_flag), "is_bool_flag": bool(getattr(option, "is_bool_flag", False)), - "sensitive": bool(option.hide_input), + "sensitive": is_sensitive_option(option), "envvar": _envvar(option.envvar), "metavar": option.metavar, } @@ -562,6 +572,8 @@ def _path_specific_constraints( "description": "--command must be 'show' or start with 'show '.", } ) + if path == ("inventory", "devices", "cdfmc-managed-ftd", "configure-manager"): + constraints.append(_required_unless("cli_key", unless="check")) if path == ("inventory", "devices", "asa", "onboard"): constraints.append( _required_unless("device_address", "username", "connector_type", unless="check") @@ -836,7 +848,7 @@ def _example_option_parts( parts: list[str] = [] for option_name in option_names: option = option_by_name.get(option_name) - if option is None or option.hide_input: + if option is None or is_sensitive_option(option): continue flag = _preferred_flag(option) if option.is_flag: diff --git a/cisco_sccfm_cli/services/config_service.py b/cisco_sccfm_cli/services/config_service.py index 741d0b8a..8549b7c6 100644 --- a/cisco_sccfm_cli/services/config_service.py +++ b/cisco_sccfm_cli/services/config_service.py @@ -6,8 +6,10 @@ import json import os +import stat +from errno import ELOOP, ENOTDIR from pathlib import Path -from typing import Any, Dict, Mapping, TextIO +from typing import Any, Dict, Mapping, TextIO, cast from cisco_sccfm_cli.models import Config @@ -34,12 +36,17 @@ def load(self, profile: str) -> Config | None: ) def save(self, config: Config) -> None: - profiles = self._load_profiles() - profiles[config.profile] = { - "region": config.region, - "api_token": config.api_token, - } - self._persist({"profiles": profiles}) + self._validate_storage_path() + self._ensure_parent_directory() + self._validate_storage_path() + handle, created = self._open_directly_for_update() + with handle: + profiles = {} if created else self._read_profiles_for_update(handle) + profiles[config.profile] = { + "region": config.region, + "api_token": config.api_token, + } + self._rewrite(handle, {"profiles": profiles}) def list_profiles(self) -> list[Config]: profiles = self._load_profiles() @@ -49,20 +56,30 @@ def list_profiles(self) -> list[Config]: ] def _load_profiles(self) -> Dict[str, Dict[str, Any]]: - self._harden_existing_storage() - if not self._path.exists(): + self._validate_storage_path() + self._validate_read_permissions() + self._prepare_default_directory_permissions(repair=False) + try: + handle = self._open_directly_for_read() + except FileNotFoundError: return {} - with self._path.open("r", encoding="utf-8") as handle: + with handle: data = json.load(handle) return dict(data.get("profiles", {})) - def _persist(self, payload: Mapping[str, Any]) -> None: - self._ensure_parent_directory() - self._harden_existing_file() - with self._open_directly_for_write() as handle: - json.dump(payload, handle, indent=2) + def _read_profiles_for_update(self, handle: TextIO) -> Dict[str, Dict[str, Any]]: + data = json.load(handle) + return dict(data.get("profiles", {})) + + def _rewrite(self, handle: TextIO, payload: Mapping[str, Any]) -> None: + descriptor = handle.fileno() + self._ensure_regular_descriptor(descriptor) + handle.seek(0) + os.ftruncate(descriptor, 0) + json.dump(payload, handle, indent=2) def _ensure_parent_directory(self) -> None: + self._validate_parent_directory() created = False try: self._path.parent.mkdir(parents=True, mode=_CONFIG_DIR_MODE) @@ -71,36 +88,304 @@ def _ensure_parent_directory(self) -> None: if not self._path.parent.is_dir(): raise - if self._supports_posix_permissions() and (created or self._uses_default_path): - self._path.parent.chmod(_CONFIG_DIR_MODE) + self._validate_parent_directory() + if not self._supports_posix_permissions(): + return + if self._uses_default_path: + self._prepare_default_directory_permissions(repair=True) + elif created: + self._harden_parent_directory() - def _harden_existing_storage(self) -> None: + def _prepare_default_directory_permissions(self, *, repair: bool) -> None: + if not self._supports_posix_permissions() or not self._uses_default_path: + return + try: + descriptor = self._open_validated_parent_directory() + except FileNotFoundError: + return + try: + if repair: + os.fchmod(descriptor, _CONFIG_DIR_MODE) + else: + self._require_descriptor_mode( + descriptor, + expected=_CONFIG_DIR_MODE, + label="default configuration directory", + ) + finally: + os.close(descriptor) + + def _validate_storage_path(self) -> None: + """Reject path types that must never be opened or permission-hardened.""" + self._validate_parent_directory() + self._validate_configuration_file() + + def _validate_parent_directory(self) -> None: + for parent in (self._path.parent, *self._path.parent.parents): + try: + mode = parent.lstat().st_mode + except FileNotFoundError: + continue + if stat.S_ISLNK(mode): + raise ValueError( + f"Configuration directory path must not contain symbolic links: {parent}" + ) + + try: + mode = self._path.parent.lstat().st_mode + except FileNotFoundError: + return + if not stat.S_ISDIR(mode): + raise ValueError(f"Configuration parent must be a directory: {self._path.parent}") + + def _validate_configuration_file(self) -> None: + try: + mode = self._path.lstat().st_mode + except FileNotFoundError: + return + if stat.S_ISLNK(mode): + raise ValueError(f"Configuration file must not be a symbolic link: {self._path}") + if not stat.S_ISREG(mode): + raise ValueError(f"Configuration path must be a regular file: {self._path}") + + def _validate_read_permissions(self) -> None: + """Fail closed before opening storage whose mode may prevent a useful error.""" if not self._supports_posix_permissions(): return - if self._uses_default_path and self._path.parent.exists(): - self._path.parent.chmod(_CONFIG_DIR_MODE) - self._harden_existing_file() + if self._uses_default_path: + self._require_path_mode_if_present( + self._path.parent, + expected=_CONFIG_DIR_MODE, + label="default configuration directory", + ) + self._require_path_mode_if_present( + self._path, + expected=_CONFIG_FILE_MODE, + label="configuration file", + ) + + def _require_path_mode_if_present(self, path: Path, *, expected: int, label: str) -> None: + try: + actual = stat.S_IMODE(path.lstat().st_mode) + except FileNotFoundError: + return + self._require_mode(actual=actual, expected=expected, label=label) + + def _open_directly_for_read(self) -> TextIO: + descriptor = self._open_read_descriptor() + try: + if self._supports_posix_permissions(): + self._require_descriptor_mode( + descriptor, + expected=_CONFIG_FILE_MODE, + label="configuration file", + ) + return cast(TextIO, os.fdopen(descriptor, "r", encoding="utf-8")) + except BaseException: + os.close(descriptor) + raise + + def _open_directly_for_update(self) -> tuple[TextIO, bool]: + descriptor, created = self._open_update_descriptor() + try: + if self._supports_posix_permissions(): + os.fchmod(descriptor, _CONFIG_FILE_MODE) + handle = cast(TextIO, os.fdopen(descriptor, "r+", encoding="utf-8")) + return handle, created + except BaseException: + os.close(descriptor) + raise - def _harden_existing_file(self) -> None: - if self._supports_posix_permissions() and self._path.exists(): - self._path.chmod(_CONFIG_FILE_MODE) + def _open_read_descriptor(self) -> int: + flags = os.O_RDONLY | self._safe_open_flags() + if not self._supports_posix_permissions(): + descriptor = os.open(self._path, flags) + try: + self._ensure_regular_descriptor(descriptor) + return descriptor + except BaseException: + os.close(descriptor) + raise + + parent_descriptor = self._open_validated_parent_directory() + try: + return self._open_relative_descriptor(parent_descriptor, flags=flags) + finally: + os.close(parent_descriptor) - def _open_directly_for_write(self) -> TextIO: + def _open_update_descriptor(self) -> tuple[int, bool]: + flags = os.O_RDWR | self._safe_open_flags() if not self._supports_posix_permissions(): - return self._path.open("w", encoding="utf-8") + return self._open_update_descriptor_without_dir_fd(flags) + parent_descriptor = self._open_validated_parent_directory() + try: + try: + descriptor = self._open_relative_descriptor( + parent_descriptor, + flags=flags, + ) + return descriptor, False + except FileNotFoundError: + descriptor = self._open_relative_descriptor( + parent_descriptor, + flags=flags | os.O_CREAT | os.O_EXCL, + mode=_CONFIG_FILE_MODE, + ) + return descriptor, True + finally: + os.close(parent_descriptor) + + def _open_update_descriptor_without_dir_fd(self, flags: int) -> tuple[int, bool]: + try: + descriptor = os.open(self._path, flags) + except FileNotFoundError: + descriptor = os.open( + self._path, + flags | os.O_CREAT | os.O_EXCL, + _CONFIG_FILE_MODE, + ) + created = True + else: + created = False + try: + self._ensure_regular_descriptor(descriptor) + return descriptor, created + except BaseException: + os.close(descriptor) + raise + + def _open_relative_descriptor( + self, + parent_descriptor: int, + *, + flags: int, + mode: int = 0o777, + ) -> int: descriptor = os.open( - self._path, - os.O_WRONLY | os.O_CREAT | os.O_TRUNC, - _CONFIG_FILE_MODE, + self._path.name, + flags, + mode, + dir_fd=parent_descriptor, ) try: - os.fchmod(descriptor, _CONFIG_FILE_MODE) - return os.fdopen(descriptor, "w", encoding="utf-8") + self._ensure_regular_descriptor( + descriptor, + parent_descriptor=parent_descriptor, + ) + self._ensure_parent_descriptor_matches_path(parent_descriptor) + return descriptor except BaseException: os.close(descriptor) raise + def _ensure_regular_descriptor( + self, + descriptor: int, + *, + parent_descriptor: int | None = None, + ) -> None: + descriptor_stat = os.fstat(descriptor) + if not stat.S_ISREG(descriptor_stat.st_mode): + raise ValueError(f"Configuration path must be a regular file: {self._path}") + try: + path_stat = self._configuration_path_stat(parent_descriptor) + except FileNotFoundError as exc: + raise ValueError( + f"Configuration path changed while being opened: {self._path}" + ) from exc + if stat.S_ISLNK(path_stat.st_mode) or not os.path.samestat(descriptor_stat, path_stat): + raise ValueError(f"Configuration path changed while being opened: {self._path}") + + def _configuration_path_stat(self, parent_descriptor: int | None) -> os.stat_result: + if parent_descriptor is None: + return self._path.lstat() + return os.stat( + self._path.name, + dir_fd=parent_descriptor, + follow_symlinks=False, + ) + + def _harden_parent_directory(self) -> None: + descriptor = self._open_validated_parent_directory() + try: + os.fchmod(descriptor, _CONFIG_DIR_MODE) + finally: + os.close(descriptor) + + def _open_validated_parent_directory(self) -> int: + parent = self._path.parent.absolute() + flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | self._safe_open_flags() + descriptor = os.open(parent.anchor, flags) + try: + for component in parent.parts[1:]: + child_descriptor = self._open_child_directory(descriptor, component, flags) + os.close(descriptor) + descriptor = child_descriptor + self._ensure_parent_descriptor_matches_path(descriptor) + return descriptor + except BaseException: + os.close(descriptor) + raise + + def _open_child_directory(self, parent_descriptor: int, name: str, flags: int) -> int: + try: + descriptor = os.open(name, flags, dir_fd=parent_descriptor) + except OSError as exc: + if exc.errno in (ELOOP, ENOTDIR): + raise self._directory_changed_error() from exc + raise + try: + descriptor_stat = os.fstat(descriptor) + path_stat = os.stat( + name, + dir_fd=parent_descriptor, + follow_symlinks=False, + ) + if ( + not stat.S_ISDIR(descriptor_stat.st_mode) + or stat.S_ISLNK(path_stat.st_mode) + or not os.path.samestat(descriptor_stat, path_stat) + ): + raise self._directory_changed_error() + return descriptor + except BaseException: + os.close(descriptor) + raise + + def _ensure_parent_descriptor_matches_path(self, descriptor: int) -> None: + descriptor_stat = os.fstat(descriptor) + try: + path_stat = self._path.parent.lstat() + except FileNotFoundError as exc: + raise self._directory_changed_error() from exc + if stat.S_ISLNK(path_stat.st_mode) or not os.path.samestat(descriptor_stat, path_stat): + raise self._directory_changed_error() + + def _directory_changed_error(self) -> ValueError: + return ValueError( + f"Configuration directory changed or contains a symbolic link: {self._path.parent}" + ) + + @staticmethod + def _require_descriptor_mode(descriptor: int, *, expected: int, label: str) -> None: + actual = stat.S_IMODE(os.fstat(descriptor).st_mode) + ConfigService._require_mode(actual=actual, expected=expected, label=label) + + @staticmethod + def _require_mode(*, actual: int, expected: int, label: str) -> None: + if actual == expected: + return + raise PermissionError( + f"Unsafe {label} permissions: expected {expected:04o}, found {actual:04o}. " + "Fix the mode with chmod or rerun 'sccfm-cli configure' with the profile settings " + "to repair it." + ) + + @staticmethod + def _safe_open_flags() -> int: + return getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0) + @staticmethod def _supports_posix_permissions() -> bool: return os.name == "posix" diff --git a/cisco_sccfm_cli/services/tests/test_config_service.py b/cisco_sccfm_cli/services/tests/test_config_service.py index 43a21193..8a2ee9be 100644 --- a/cisco_sccfm_cli/services/tests/test_config_service.py +++ b/cisco_sccfm_cli/services/tests/test_config_service.py @@ -73,6 +73,353 @@ def test_should_list_all_profiles(tmp_path: Path) -> None: assert profiles == [expected] +def test_load_rejects_directory_without_changing_it(tmp_path: Path) -> None: + """A directory passed as the config path must be rejected before hardening.""" + config_path = tmp_path / "config.json" + config_path.mkdir() + original_mode = _mode(config_path) + + with pytest.raises(ValueError, match="regular file"): + ConfigService(path=config_path).load("default") + + assert _mode(config_path) == original_mode + + +def test_load_rejects_configuration_file_symlink(tmp_path: Path) -> None: + """Loading must not follow or chmod a symlink supplied as the config path.""" + target_path = tmp_path / "target.json" + expected = _write_config(target_path) + config_path = tmp_path / "config.json" + config_path.symlink_to(target_path) + original_mode = _mode(target_path) + + with pytest.raises(ValueError, match="symbolic link"): + ConfigService(path=config_path).load(expected.profile) + + assert _mode(target_path) == original_mode + + +def test_save_rejects_configuration_directory_symlink(tmp_path: Path) -> None: + """Saving must not follow or chmod a symlink supplied as the config directory.""" + target_directory = tmp_path / "target" + target_directory.mkdir() + config_directory = tmp_path / "linked" + config_directory.symlink_to(target_directory, target_is_directory=True) + original_mode = _mode(target_directory) + + with pytest.raises(ValueError, match="must not contain symbolic links"): + ConfigService(path=config_directory / "config.json").save( + Config(profile="default", region="us", api_token="example-token") + ) + + assert not (target_directory / "config.json").exists() + assert _mode(target_directory) == original_mode + + +def test_write_validates_opened_file_before_truncating( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A descriptor rejected after open must retain its existing payload.""" + config_path = tmp_path / "config.json" + original_payload = "must-not-be-truncated" + config_path.write_text(original_payload, encoding="utf-8") + service = ConfigService(path=config_path) + + def reject_descriptor( + descriptor: int, + *, + parent_descriptor: int | None = None, + ) -> None: + raise ValueError("synthetic non-regular descriptor") + + monkeypatch.setattr(service, "_ensure_regular_descriptor", reject_descriptor) + + with pytest.raises(ValueError, match="synthetic non-regular"): + service._open_directly_for_update() + + assert config_path.read_text(encoding="utf-8") == original_payload + + +@pytest.mark.parametrize("payload", ["", "{malformed-json"]) +def test_save_preserves_invalid_existing_payload_before_rewrite( + tmp_path: Path, + payload: str, +) -> None: + """Empty or malformed existing storage must not be mistaken for a new file.""" + config_path = tmp_path / "config.json" + config_path.write_text(payload, encoding="utf-8") + if os.name == "posix": + config_path.chmod(0o644) + + with pytest.raises(json.JSONDecodeError): + ConfigService(path=config_path).save( + Config(profile="default", region="us", api_token="example-token") + ) + + assert config_path.read_text(encoding="utf-8") == payload + if os.name == "posix": + assert _mode(config_path) == 0o600 + + +@POSIX_ONLY +def test_write_rejects_path_swap_before_truncating( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Replacing a path after open must not truncate either regular file.""" + config_path = tmp_path / "config.json" + opened_path = tmp_path / "opened.json" + replacement_path = tmp_path / "replacement.json" + original_payload = "opened-file-payload" + replacement_payload = "replacement-file-payload" + config_path.write_text(original_payload, encoding="utf-8") + replacement_path.write_text(replacement_payload, encoding="utf-8") + real_open = os.open + swapped = False + + def swap_after_open( + path: str | os.PathLike[str], + flags: int, + mode: int = 0o777, + *, + dir_fd: int | None = None, + ) -> int: + nonlocal swapped + descriptor = real_open(path, flags, mode, dir_fd=dir_fd) + is_config_open = Path(path) in (config_path, Path(config_path.name)) + if is_config_open and flags & os.O_RDWR and not swapped: + config_path.rename(opened_path) + replacement_path.rename(config_path) + swapped = True + return descriptor + + monkeypatch.setattr(config_service_module.os, "open", swap_after_open) + + with pytest.raises(ValueError, match="changed while being opened"): + ConfigService(path=config_path)._open_directly_for_update() + + assert opened_path.read_text(encoding="utf-8") == original_payload + assert config_path.read_text(encoding="utf-8") == replacement_payload + + +@POSIX_ONLY +def test_read_rejects_parent_swap_without_reading_attacker_file( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A parent replacement must not redirect a readonly profile open.""" + config_parent = tmp_path / "config-parent" + config_parent.mkdir() + config_path = config_parent / "config.json" + original = _write_config(config_path) + config_path.chmod(0o600) + original_payload = config_path.read_text(encoding="utf-8") + + attacker_parent = tmp_path / "attacker-parent" + attacker_parent.mkdir() + attacker_path = attacker_parent / "config.json" + attacker_secret = "attacker-profile-secret" + attacker_path.write_text(attacker_secret, encoding="utf-8") + attacker_path.chmod(0o600) + moved_parent = tmp_path / "original-parent" + real_open = os.open + swapped = False + + def swap_parent_before_relative_open( + path: str | os.PathLike[str], + flags: int, + mode: int = 0o777, + *, + dir_fd: int | None = None, + ) -> int: + nonlocal swapped + if Path(path) == Path(config_path.name) and dir_fd is not None and not swapped: + config_parent.rename(moved_parent) + config_parent.symlink_to(attacker_parent, target_is_directory=True) + swapped = True + return real_open(path, flags, mode, dir_fd=dir_fd) + + monkeypatch.setattr(config_service_module.os, "open", swap_parent_before_relative_open) + + with pytest.raises(ValueError, match="directory changed") as excinfo: + ConfigService(path=config_path).load(original.profile) + + assert original.api_token not in str(excinfo.value) + assert attacker_secret not in str(excinfo.value) + assert (moved_parent / "config.json").read_text(encoding="utf-8") == original_payload + assert attacker_path.read_text(encoding="utf-8") == attacker_secret + + +@POSIX_ONLY +def test_save_rejects_parent_swap_without_redirecting_secret( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A parent replacement must not redirect an explicit profile update.""" + config_parent = tmp_path / "config-parent" + config_parent.mkdir() + config_path = config_parent / "config.json" + _write_config(config_path, profile="existing") + config_path.chmod(0o600) + original_payload = config_path.read_text(encoding="utf-8") + + attacker_parent = tmp_path / "attacker-parent" + attacker_parent.mkdir() + attacker_path = attacker_parent / "config.json" + attacker_payload = "attacker-owned-payload" + attacker_path.write_text(attacker_payload, encoding="utf-8") + attacker_path.chmod(0o600) + moved_parent = tmp_path / "original-parent" + new_secret = "must-not-be-redirected" + real_open = os.open + swapped = False + + def swap_parent_before_relative_open( + path: str | os.PathLike[str], + flags: int, + mode: int = 0o777, + *, + dir_fd: int | None = None, + ) -> int: + nonlocal swapped + if Path(path) == Path(config_path.name) and dir_fd is not None and not swapped: + config_parent.rename(moved_parent) + config_parent.symlink_to(attacker_parent, target_is_directory=True) + swapped = True + return real_open(path, flags, mode, dir_fd=dir_fd) + + monkeypatch.setattr(config_service_module.os, "open", swap_parent_before_relative_open) + + with pytest.raises(ValueError, match="directory changed") as excinfo: + ConfigService(path=config_path).save( + Config(profile="added", region="eu", api_token=new_secret) + ) + + assert new_secret not in str(excinfo.value) + assert new_secret not in (moved_parent / "config.json").read_text(encoding="utf-8") + assert (moved_parent / "config.json").read_text(encoding="utf-8") == original_payload + assert attacker_path.read_text(encoding="utf-8") == attacker_payload + + +@POSIX_ONLY +def test_read_rejects_intermediate_ancestor_swap( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A validated ancestor cannot be replaced before the readonly open.""" + trusted_root = tmp_path / "trusted" + config_parent = trusted_root / "config-parent" + config_parent.mkdir(parents=True) + config_path = config_parent / "config.json" + original = _write_config(config_path) + config_path.chmod(0o600) + original_payload = config_path.read_text(encoding="utf-8") + + attacker_root = tmp_path / "attacker-root" + attacker_parent = attacker_root / "config-parent" + attacker_parent.mkdir(parents=True) + attacker_path = attacker_parent / "config.json" + attacker = _write_config(attacker_path, profile="attacker") + attacker_path.chmod(0o600) + attacker_payload = attacker_path.read_text(encoding="utf-8") + moved_root = tmp_path / "original-trusted" + service = ConfigService(path=config_path) + original_validate = service._validate_configuration_file + swapped = False + + def validate_then_swap_ancestor() -> None: + nonlocal swapped + original_validate() + if not swapped: + trusted_root.rename(moved_root) + trusted_root.symlink_to(attacker_root, target_is_directory=True) + swapped = True + + monkeypatch.setattr(service, "_validate_configuration_file", validate_then_swap_ancestor) + + with pytest.raises(ValueError, match="changed|symbolic") as excinfo: + service.load(original.profile) + + assert original.api_token not in str(excinfo.value) + assert attacker.api_token not in str(excinfo.value) + assert (moved_root / "config-parent" / "config.json").read_text( + encoding="utf-8" + ) == original_payload + assert attacker_path.read_text(encoding="utf-8") == attacker_payload + + +@POSIX_ONLY +def test_save_rejects_intermediate_ancestor_swap( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A validated ancestor cannot redirect an explicit profile update.""" + trusted_root = tmp_path / "trusted" + config_parent = trusted_root / "config-parent" + config_parent.mkdir(parents=True) + config_path = config_parent / "config.json" + _write_config(config_path, profile="existing") + config_path.chmod(0o600) + original_payload = config_path.read_text(encoding="utf-8") + + attacker_root = tmp_path / "attacker-root" + attacker_parent = attacker_root / "config-parent" + attacker_parent.mkdir(parents=True) + attacker_path = attacker_parent / "config.json" + _write_config(attacker_path, profile="attacker") + attacker_path.chmod(0o600) + attacker_payload = attacker_path.read_text(encoding="utf-8") + moved_root = tmp_path / "original-trusted" + new_secret = "must-not-reach-either-file" + service = ConfigService(path=config_path) + original_validate = service._validate_configuration_file + validations = 0 + + def validate_then_swap_ancestor() -> None: + nonlocal validations + original_validate() + validations += 1 + if validations == 2: + trusted_root.rename(moved_root) + trusted_root.symlink_to(attacker_root, target_is_directory=True) + + monkeypatch.setattr(service, "_validate_configuration_file", validate_then_swap_ancestor) + + with pytest.raises(ValueError, match="changed|symbolic") as excinfo: + service.save(Config(profile="added", region="eu", api_token=new_secret)) + + original_after = (moved_root / "config-parent" / "config.json").read_text(encoding="utf-8") + assert new_secret not in str(excinfo.value) + assert new_secret not in original_after + assert new_secret not in attacker_path.read_text(encoding="utf-8") + assert original_after == original_payload + assert attacker_path.read_text(encoding="utf-8") == attacker_payload + + +@POSIX_ONLY +def test_load_does_not_write_file_or_directory_metadata( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A readonly load must not call chmod even when modes are already safe.""" + config_path = tmp_path / ".sccfm-cli" / "config.json" + config_path.parent.mkdir() + config_path.parent.chmod(0o700) + expected = _write_config(config_path) + config_path.chmod(0o600) + _use_default_path(monkeypatch, config_path) + + def reject_fchmod(descriptor: int, mode: int) -> None: + pytest.fail(f"os.fchmod was called for descriptor {descriptor} with mode {mode:o}") + + monkeypatch.setattr(config_service_module.os, "fchmod", reject_fchmod) + + assert ConfigService().load(expected.profile) == expected + assert _mode(config_path.parent) == 0o700 + assert _mode(config_path) == 0o600 + + @POSIX_ONLY def test_new_custom_storage_is_private_before_payload_is_written( tmp_path: Path, @@ -114,25 +461,34 @@ def test_new_default_storage_is_private( @POSIX_ONLY -def test_load_hardens_existing_custom_file_without_changing_parent(tmp_path: Path) -> None: - """Loading should harden the file but respect an existing custom directory.""" +@pytest.mark.parametrize("unsafe_mode", [0o000, 0o400, 0o640, 0o644, 0o660, 0o700]) +def test_load_rejects_unsafe_custom_file_without_changing_modes( + tmp_path: Path, + unsafe_mode: int, +) -> None: + """Custom profile reads require 0600 and must not repair the file or parent.""" custom_parent = tmp_path / "shared-config" custom_parent.mkdir() custom_parent.chmod(0o750) config_path = custom_parent / "config.json" expected = _write_config(config_path) - config_path.chmod(0o644) + config_path.chmod(unsafe_mode) - loaded = ConfigService(path=config_path).load(expected.profile) + expected_mode = f"{unsafe_mode:04o}" + with pytest.raises(PermissionError, match=f"expected 0600, found {expected_mode}") as excinfo: + ConfigService(path=config_path).load(expected.profile) - assert loaded == expected - assert _mode(config_path) == 0o600 + assert expected.api_token not in str(excinfo.value) + assert "sccfm-cli configure" in str(excinfo.value) + assert _mode(config_path) == unsafe_mode assert _mode(custom_parent) == 0o750 @POSIX_ONLY -def test_save_hardens_existing_file_without_replacing_it(tmp_path: Path) -> None: - """Saving should remain a direct write while hardening existing storage.""" +def test_save_repairs_custom_file_and_preserves_profiles_without_changing_parent( + tmp_path: Path, +) -> None: + """Explicit save may repair a custom file while preserving its other profiles.""" custom_parent = tmp_path / "shared-config" custom_parent.mkdir() custom_parent.chmod(0o750) @@ -153,42 +509,66 @@ def test_save_hardens_existing_file_without_replacing_it(tmp_path: Path) -> None @POSIX_ONLY -def test_load_hardens_existing_default_storage( +def test_load_rejects_unsafe_default_directory_without_changing_modes( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - """Loading from the default location should harden its directory and file.""" + """Default profile reads require 0700 on the directory and never repair it.""" config_path = tmp_path / ".sccfm-cli" / "config.json" config_path.parent.mkdir() config_path.parent.chmod(0o755) expected = _write_config(config_path) - config_path.chmod(0o644) + config_path.chmod(0o600) _use_default_path(monkeypatch, config_path) - loaded = ConfigService().load(expected.profile) + with pytest.raises(PermissionError, match="expected 0700, found 0755"): + ConfigService().load(expected.profile) - assert loaded == expected - assert _mode(config_path.parent) == 0o700 + assert _mode(config_path.parent) == 0o755 assert _mode(config_path) == 0o600 @POSIX_ONLY -def test_save_hardens_existing_default_storage( +def test_load_rejects_unsafe_default_file_without_changing_modes( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Default profile reads require 0600 on the file and never repair it.""" + config_path = tmp_path / ".sccfm-cli" / "config.json" + config_path.parent.mkdir() + config_path.parent.chmod(0o700) + expected = _write_config(config_path) + config_path.chmod(0o640) + _use_default_path(monkeypatch, config_path) + + with pytest.raises(PermissionError, match="expected 0600, found 0640"): + ConfigService().load(expected.profile) + + assert _mode(config_path.parent) == 0o700 + assert _mode(config_path) == 0o640 + + +@POSIX_ONLY +def test_save_repairs_default_storage_and_preserves_existing_profiles( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - """Saving to the default location should harden its directory and file.""" + """Explicit save repairs default modes without discarding existing profiles.""" config_path = tmp_path / ".sccfm-cli" / "config.json" config_path.parent.mkdir() config_path.parent.chmod(0o755) - _write_config(config_path) + existing = _write_config(config_path, profile="existing") config_path.chmod(0o644) _use_default_path(monkeypatch, config_path) - ConfigService().save(Config(profile="added", region="eu", api_token="example-token-2")) + added = Config(profile="added", region="eu", api_token="example-token-2") + service = ConfigService() + service.save(added) assert _mode(config_path.parent) == 0o700 assert _mode(config_path) == 0o600 + assert service.load(existing.profile) == existing + assert service.load(added.profile) == added def test_non_posix_fallback_preserves_save_and_load_behavior( diff --git a/cisco_sccfm_core/services/inventory/ftd_configure_manager_service.py b/cisco_sccfm_core/services/inventory/ftd_configure_manager_service.py index b5efb762..56dabba0 100644 --- a/cisco_sccfm_core/services/inventory/ftd_configure_manager_service.py +++ b/cisco_sccfm_core/services/inventory/ftd_configure_manager_service.py @@ -311,13 +311,60 @@ def _validate_cli_key(cli_key: str) -> str: def _sanitize_manager_command_echo(output: str, command: str) -> str: - command_marker = command.casefold() - sanitized_lines: list[str] = [] - for line in output.splitlines(): - if command_marker in line.strip().casefold(): + lines = output.splitlines() + echo_start = _manager_command_echo_range(lines, command) + if echo_start is None: + return output.strip() + start, end = echo_start + return "\n".join((*lines[:start], *lines[end:])).strip() + + +def _manager_command_echo_range(lines: list[str], command: str) -> tuple[int, int] | None: + """Locate a complete or secret-bearing partial command echo.""" + expected = _normalize_command_echo_fragment(command) + command_prefix = "configure manager add" + for start, line in enumerate(lines): + first_fragment = _normalize_command_echo_fragment(line) + candidate = _command_echo_candidate(first_fragment, command_prefix) + if candidate is None or not expected.startswith(candidate): continue - sanitized_lines.append(line) - return "\n".join(sanitized_lines).strip() + candidates = {candidate} + end = start + 1 + partial_end = end if candidate != command_prefix else None + while expected not in candidates and candidates and end < len(lines): + fragment = _normalize_command_echo_fragment(lines[end]) + if not fragment: + break + continued_candidates = { + joined + for candidate in candidates + for joined in (candidate + fragment, f"{candidate} {fragment}") + if expected.startswith(joined) + } + if not continued_candidates: + break + candidates = continued_candidates + end += 1 + partial_end = end + if expected in candidates: + return start, end + if partial_end is not None: + return start, partial_end + return None + + +def _command_echo_candidate(fragment: str, command_prefix: str) -> str | None: + marker_offset = fragment.find(command_prefix) + if marker_offset == 0: + return fragment + if marker_offset < 0: + return None + prompt = fragment[:marker_offset].rstrip() + return fragment[marker_offset:] if prompt.endswith((">", "#")) else None + + +def _normalize_command_echo_fragment(value: str) -> str: + return " ".join(value.casefold().split()) def _read_until_prompt(channel: paramiko.Channel, timeout: int) -> str: diff --git a/cisco_sccfm_core/tests/test_ftd_configure_manager_service.py b/cisco_sccfm_core/tests/test_ftd_configure_manager_service.py index de0f3db4..d4fc5594 100644 --- a/cisco_sccfm_core/tests/test_ftd_configure_manager_service.py +++ b/cisco_sccfm_core/tests/test_ftd_configure_manager_service.py @@ -155,6 +155,48 @@ def test_success_output_removes_echoed_cli_key(monkeypatch: MonkeyPatch) -> None assert "natid456" not in result.output +@pytest.mark.parametrize( + "wrapped_echo", + [ + # PTY wrap in the middle of a secret token. + (b"configure manager add DONTRESOLVE registration-secret-\r\n" b"abcdef natid456\r\n"), + # PTY wrap exactly between arguments, without retaining the separating space. + (b"configure manager add DONTRESOLVE\r\n" b"registration-secret-abcdef natid456\r\n"), + # PTY wrap exactly between arguments, retaining the space on the first line. + (b"configure manager add DONTRESOLVE \r\n" b"registration-secret-abcdef natid456\r\n"), + # Some terminal implementations retain it on the continuation line instead. + (b"configure manager add DONTRESOLVE\r\n" b" registration-secret-abcdef natid456\r\n"), + ], +) +def test_success_output_removes_wrapped_cli_key( + monkeypatch: MonkeyPatch, + wrapped_echo: bytes, +) -> None: + cli_key = "configure manager add DONTRESOLVE registration-secret-abcdef natid456" + channel = _FakeChannel( + [ + b"\r\n> ", + wrapped_echo + b"Manager fmc.example.com successfully configured.\r\n> ", + ] + ) + _patch_client(monkeypatch, _FakeClient(channel)) + + result = _service().configure_manager( + host="10.0.0.5", + port=22, + username="admin", + password="pw", + cli_key=cli_key, + timeout=5, + ) + + assert result.success is True + assert "Manager fmc.example.com successfully configured." in result.output + assert "registration-secret-" not in result.output + assert "abcdef" not in result.output + assert "natid456" not in result.output + + def test_license_confirmation_prompt_is_answered_yes(monkeypatch: MonkeyPatch) -> None: channel = _FakeChannel( [ @@ -290,6 +332,111 @@ def test_error_output_removes_echoed_cli_key(monkeypatch: MonkeyPatch) -> None: assert "natid456" not in excinfo.value.output +def test_error_output_removes_wrapped_cli_key(monkeypatch: MonkeyPatch) -> None: + cli_key = "configure manager add DONTRESOLVE registration-secret-abcdef natid456" + channel = _FakeChannel( + [ + b"\r\n> ", + ( + b"configure manager add DONTRESOLVE registration-secret-\r\n" + b"abcdef natid456\r\nManager already configured.\r\n> " + ), + ] + ) + _patch_client(monkeypatch, _FakeClient(channel)) + + with pytest.raises(FtdConfigureManagerError) as excinfo: + _service().configure_manager( + host="10.0.0.5", + port=22, + username="admin", + password="pw", + cli_key=cli_key, + timeout=5, + ) + + assert "Manager already configured." in excinfo.value.output + assert "registration-secret-" not in excinfo.value.output + assert "abcdef" not in excinfo.value.output + assert "natid456" not in excinfo.value.output + + +def test_error_output_removes_partial_cli_key_echo(monkeypatch: MonkeyPatch) -> None: + cli_key = "configure manager add DONTRESOLVE registration-secret-abcdef natid456" + channel = _FakeChannel( + [ + b"\r\n> ", + ( + b"configure manager add DONTRESOLVE registration-secret-\r\n" + b"abcdef\r\nManager already configured.\r\n> " + ), + ] + ) + _patch_client(monkeypatch, _FakeClient(channel)) + + with pytest.raises(FtdConfigureManagerError) as excinfo: + _service().configure_manager( + host="10.0.0.5", + port=22, + username="admin", + password="pw", + cli_key=cli_key, + timeout=5, + ) + + assert "Manager already configured." in excinfo.value.output + assert "registration-secret-" not in excinfo.value.output + assert "abcdef" not in excinfo.value.output + + +@pytest.mark.parametrize( + "partial_echo", + [ + "configure manager add DONTRESOLVE registration-secret-", + "configure manager add DONTRESOLVE\nregistration-secret-\nabcdef", + ], +) +def test_timeout_output_removes_partial_cli_key_echo( + monkeypatch: MonkeyPatch, + partial_echo: str, +) -> None: + cli_key = "configure manager add DONTRESOLVE registration-secret-abcdef natid456" + reads = 0 + + def fake_read_until_prompt(channel: paramiko.Channel, timeout: int) -> str: + nonlocal reads + reads += 1 + if reads == 1: + return ">" + raise FtdConfigureManagerError( + "Timed out waiting for the FTD CLI prompt.", + output=f"{partial_echo}\nManager response remains visible.", + ) + + monkeypatch.setattr(svc_mod, "_read_until_prompt", fake_read_until_prompt) + _patch_client(monkeypatch, _FakeClient(_FakeChannel([]))) + + with pytest.raises(FtdConfigureManagerError, match="Timed out") as excinfo: + _service().configure_manager( + host="10.0.0.5", + port=22, + username="admin", + password="pw", + cli_key=cli_key, + timeout=5, + ) + + assert "Manager response remains visible." in excinfo.value.output + assert "registration-secret-" not in excinfo.value.output + assert "abcdef" not in excinfo.value.output + + +def test_sanitizer_preserves_ordinary_response_that_mentions_command() -> None: + output = "Error: configure manager add DONTRESOLVE was rejected by policy." + + assert svc_mod._sanitize_manager_command_echo(output, _CLI_KEY) == output + + def test_authentication_failure_maps_to_error(monkeypatch: MonkeyPatch) -> None: client = _FakeClient(_FakeChannel([])) diff --git a/cisco_sccfm_scripts/cli_commands.py b/cisco_sccfm_scripts/cli_commands.py index 718f680d..80604651 100644 --- a/cisco_sccfm_scripts/cli_commands.py +++ b/cisco_sccfm_scripts/cli_commands.py @@ -13,10 +13,13 @@ from __future__ import annotations +from collections.abc import Sequence from dataclasses import dataclass, field import click +from cisco_sccfm_cli.option_metadata import is_sensitive_option + # Options that are wired up at the infrastructure level and should not be # prompted for interactively. _SKIP_PARAMS = {"help", "config_path"} @@ -29,6 +32,9 @@ class CliParam: required: bool is_flag: bool = False # True for boolean toggle options (e.g. --check) multiple: bool = False # True for repeatable options (e.g. --labels, --tags) + sensitive: bool = False # True when the option value must be handled as a secret + envvar: str | tuple[str, ...] | None = None + envvar_list_splitter: str | None = None @dataclass @@ -53,6 +59,13 @@ def _first_line(text: str | None) -> str: return text.strip().splitlines()[0].rstrip(".") +def _envvar_metadata(envvar: str | Sequence[str] | None) -> str | tuple[str, ...] | None: + """Return immutable Click envvar metadata for the interactive runner.""" + if envvar is None or isinstance(envvar, str): + return envvar + return tuple(envvar) + + def _build_tree(group: click.Group, args_prefix: list[str]) -> list[CliGroup | CliCommand]: """Recursively build a CliGroup/CliCommand tree from a Click group.""" result: list[CliGroup | CliCommand] = [] @@ -66,7 +79,7 @@ def _build_tree(group: click.Group, args_prefix: list[str]) -> list[CliGroup | C children=children, ) ) - elif isinstance(cmd, click.BaseCommand): + elif isinstance(cmd, click.Command): params: list[CliParam] = [] for param in cmd.params: if not isinstance(param, click.Option): @@ -83,6 +96,9 @@ def _build_tree(group: click.Group, args_prefix: list[str]) -> list[CliGroup | C required=bool(param.required), is_flag=bool(param.is_flag), multiple=bool(param.multiple), + sensitive=is_sensitive_option(param), + envvar=_envvar_metadata(param.envvar), + envvar_list_splitter=param.type.envvar_list_splitter, ) ) result.append( diff --git a/cisco_sccfm_scripts/devkit_cli.py b/cisco_sccfm_scripts/devkit_cli.py index bea17d71..26743ad2 100644 --- a/cisco_sccfm_scripts/devkit_cli.py +++ b/cisco_sccfm_scripts/devkit_cli.py @@ -12,11 +12,12 @@ from __future__ import annotations +import os import shlex import subprocess import sys from pathlib import Path -from typing import Callable +from typing import TYPE_CHECKING, Callable import questionary from rich.console import Console @@ -24,6 +25,9 @@ console = Console() +if TYPE_CHECKING: + from cisco_sccfm_scripts.token_store import SavedToken, VaultTokenStore + # ── Helpers ────────────────────────────────────────────────────── @@ -171,6 +175,47 @@ def _run_e2e() -> None: # ── Run CLI commands ────────────────────────────────────────────── +def _prompt_cli_value(message: str, *, sensitive: bool) -> str | None: + """Prompt for one CLI option value without echoing sensitive input.""" + prompt = questionary.password(message) if sensitive else questionary.text(message) + answer: object = prompt.unsafe_ask() + return answer if isinstance(answer, str) else None + + +def _primary_envvar(envvar: str | tuple[str, ...] | None) -> str | None: + """Return the first environment variable Click will inspect for an option.""" + if isinstance(envvar, str): + return envvar or None + if envvar: + return envvar[0] or None + return None + + +def _repeatable_envvar_value(values: list[str], splitter: str | None) -> str: + """Encode repeatable values exactly as Click's parameter type will split them.""" + if splitter == "": + raise ValueError("the option declares an empty environment-variable splitter") + if splitter is None: + if any(any(character.isspace() for character in value) for value in values): + raise ValueError("a repeatable value contains whitespace") + return " ".join(values) + if any(splitter in value for value in values): + raise ValueError("a repeatable value contains its environment-variable splitter") + return splitter.join(values) + + +def _with_child_secret( + child_env: dict[str, str] | None, + envvar: str, + value: str, +) -> dict[str, str]: + """Return a child-only environment containing one prompted secret.""" + if child_env is None: + child_env = dict(os.environ) + child_env[envvar] = value + return child_env + + def _execute_cli_command(cmd: object) -> None: """Prompt for params and run an sccfm-cli leaf command.""" from cisco_sccfm_scripts.cli_commands import CliCommand, CliParam @@ -178,6 +223,7 @@ def _execute_cli_command(cmd: object) -> None: if not isinstance(cmd, CliCommand): return argv: list[str] = ["sccfm-cli", *cmd.args] + child_env: dict[str, str] | None = None for param in cmd.params: if not isinstance(param, CliParam): continue @@ -185,31 +231,59 @@ def _execute_cli_command(cmd: object) -> None: confirmed = questionary.confirm(f"{param.label}?", default=False).unsafe_ask() if confirmed: argv.append(param.flag) - elif param.multiple: + continue + + secret_envvar = _primary_envvar(param.envvar) if param.sensitive else None + if param.sensitive and secret_envvar is None: + console.print( + f"[dim]{param.label} will be requested by sccfm-cli's hidden prompt " + "if needed.[/dim]" + ) + continue + + if param.multiple: console.print(f"[dim]{param.label} — enter one value per line, blank to finish:[/dim]") - has_value = False + values: list[str] = [] while True: - value: str | None = questionary.text(f" {param.flag}").unsafe_ask() + value = _prompt_cli_value(f" {param.flag}", sensitive=param.sensitive) normalized_value = (value or "").strip() if not normalized_value: break - argv.extend([param.flag, normalized_value]) - has_value = True - if param.required and not has_value: + values.append(normalized_value) + if param.required and not values: console.print(f"[red]{param.label} is required.[/red]") return + if not values: + continue + if secret_envvar is not None: + try: + encoded = _repeatable_envvar_value(values, param.envvar_list_splitter) + except ValueError as exc: + console.print(f"[red]{param.label} cannot be passed securely: {exc}.[/red]") + return + child_env = _with_child_secret(child_env, secret_envvar, encoded) + else: + for normalized_value in values: + argv.extend([param.flag, normalized_value]) else: prompt = f"{param.label}{'' if param.required else ' (leave blank to skip)'}" - single: str | None = questionary.text(prompt).unsafe_ask() + single = _prompt_cli_value(prompt, sensitive=param.sensitive) normalized_value = (single or "").strip() if normalized_value: - argv.extend([param.flag, normalized_value]) + if secret_envvar is not None: + child_env = _with_child_secret( + child_env, + secret_envvar, + normalized_value, + ) + else: + argv.extend([param.flag, normalized_value]) elif param.required: console.print(f"[red]{param.label} is required.[/red]") return console.print(f"[bold cyan]$ {shlex.join(argv)}[/bold cyan]") - subprocess.call(argv, cwd=_project_root()) + subprocess.call(argv, cwd=_project_root(), env=child_env) def _navigate_cli(children: list[object], title: str) -> None: @@ -291,10 +365,100 @@ def _run_ansible_examples() -> None: # ── Manage tokens ───────────────────────────────────────────────── +def _load_managed_tokens( + examples_path: Path, +) -> tuple[VaultTokenStore, SavedToken | None, list[SavedToken]]: + """Load the store, prompting for compatibility region when migration needs one.""" + from cisco_sccfm_scripts.setup_tokens import _prompt_region, _verify_ansible_vault + from cisco_sccfm_scripts.token_store import ( + ActiveTokenRegionRequired, + VaultTokenStore, + ) + + _verify_ansible_vault() + store = VaultTokenStore(examples_path) + try: + active = store.active_token() + tokens = store.list_tokens() + except ActiveTokenRegionRequired: + console.print( + "[yellow]The existing active-only Vault token needs its SCCFM region before it " + "can be managed.[/yellow]" + ) + store = VaultTokenStore(examples_path, migration_region=_prompt_region()) + active = store.active_token() + tokens = store.list_tokens() + return store, active, tokens + + +def _matching_cli_profiles(token: SavedToken | None) -> list[str]: + """Return CLI profiles currently associated with an active Vault token.""" + from cisco_sccfm_cli.services import ConfigService + + if token is None: + return [] + config_path_value = os.environ.get("SCCFM_CONFIG") + config_path = Path(config_path_value).expanduser() if config_path_value else None + return [ + profile.profile + for profile in ConfigService(path=config_path).list_profiles() + if profile.api_token == token.token + ] + + +def _token_choices(tokens: list[SavedToken]) -> list[questionary.Choice]: + """Build opaque menu values so legacy token labels cannot collide with actions.""" + return [ + questionary.Choice( + title=f"{token.name} ({token.region})", + value=f"token:{index}", + ) + for index, token in enumerate(tokens) + ] + + +def _selected_token(tokens: list[SavedToken], answer: str) -> SavedToken | None: + """Resolve an opaque menu value to its token without trusting a user label.""" + if not answer.startswith("token:"): + return None + try: + return tokens[int(answer.removeprefix("token:"))] + except (ValueError, IndexError): + return None + + +def _sync_active_token( + examples_path: Path, + token: SavedToken, + profiles: list[str], +) -> None: + """Synchronize every local credential surface after the active token changes.""" + from cisco_sccfm_scripts.setup_tokens import ( + _update_cli_config, + _update_vars_region, + _write_env_file, + ) + + root = _project_root() + _write_env_file(root, token.region, token.token) + _update_vars_region(examples_path, token.region) + for profile in profiles: + _update_cli_config(token.region, token.token, profile=profile) + if not profiles: + console.print( + "[yellow]No CLI profile used the previous active token; CLI profiles were left " + "unchanged.[/yellow]" + ) + + def _update_token() -> None: """Prompt for a new API token for an existing named token.""" - from cisco_sccfm_scripts.setup_tokens import _resolve_examples_path - from cisco_sccfm_scripts.token_store import SavedToken, VaultTokenStore + from cisco_sccfm_scripts.setup_tokens import ( + _credential_transaction, + _credential_transaction_paths, + _resolve_examples_path, + ) + from cisco_sccfm_scripts.token_store import SavedToken try: examples_path = _resolve_examples_path(None) @@ -302,35 +466,30 @@ def _update_token() -> None: console.print(f"[red]{exc}[/red]") return - store = VaultTokenStore(examples_path) - tokens = store.list_tokens() + store, active_before, tokens = _load_managed_tokens(examples_path) if not tokens: console.print("[yellow]No saved tokens found in vault.[/yellow]") return - token_choices: list[questionary.Choice | str] = [ - questionary.Choice( - title=f"{t.name} ({t.region})", - value=t.name, - ) - for t in tokens - ] - token_choices.append("back") + token_choices: list[questionary.Choice | str] = [*_token_choices(tokens), "back"] answer = _ask(token_choices, "Select a token to update:") if answer is None or answer == "back": return - token_to_update = next((t for t in tokens if t.name == answer), None) + token_to_update = _selected_token(tokens, answer) if token_to_update is None: console.print("[red]Token not found.[/red]") return - new_token_value = questionary.password( + new_token_answer = questionary.password( f"Paste new API token for '{token_to_update.name}':", ).unsafe_ask() - new_token_value = new_token_value.strip() + if not isinstance(new_token_answer, str): + console.print("[dim]Cancelled.[/dim]") + return + new_token_value = new_token_answer.strip() if not new_token_value: console.print("[red]Token cannot be empty.[/red]") return @@ -341,15 +500,42 @@ def _update_token() -> None: token=new_token_value, ) all_tokens = [updated if t.name == updated.name else t for t in tokens] - vault_path = store.save_active_and_tokens(updated, all_tokens) + active_after = ( + updated if active_before is None or active_before.name == updated.name else active_before + ) + changed_profiles = ( + _matching_cli_profiles(active_before) + if active_before is not None and active_after.token != active_before.token + else [] + ) + active_changed = active_before is None or active_after.token != active_before.token + transaction_paths = ( + _credential_transaction_paths(_project_root(), examples_path) + if active_changed + else [ + examples_path / "group_vars" / "all" / "vault.yml", + examples_path / ".vault_pass", + ] + ) + with _credential_transaction(transaction_paths): + vault_path = store.save_active_and_tokens( + active_after, + all_tokens, + preserve_omitted_active=False, + ) + if active_changed: + _sync_active_token(examples_path, active_after, changed_profiles) console.print(f"[green]Updated token '{updated.name}'.[/green]") console.print(f"[dim]Vault updated: {vault_path}[/dim]") def _remove_token() -> None: """Remove a saved token from the Ansible vault store.""" - from cisco_sccfm_scripts.setup_tokens import _resolve_examples_path - from cisco_sccfm_scripts.token_store import VaultTokenStore + from cisco_sccfm_scripts.setup_tokens import ( + _credential_transaction, + _credential_transaction_paths, + _resolve_examples_path, + ) try: examples_path = _resolve_examples_path(None) @@ -357,8 +543,7 @@ def _remove_token() -> None: console.print(f"[red]{exc}[/red]") return - store = VaultTokenStore(examples_path) - tokens = store.list_tokens() + store, active_before, tokens = _load_managed_tokens(examples_path) if not tokens: console.print("[yellow]No saved tokens found in vault.[/yellow]") @@ -369,24 +554,42 @@ def _remove_token() -> None: return # Use Choice so the display shows region context but the value is just the name. - token_choices: list[questionary.Choice | str] = [ - questionary.Choice( - title=f"{t.name} ({t.region})", - value=t.name, - ) - for t in tokens - ] - token_choices.append("back") + token_choices: list[questionary.Choice | str] = [*_token_choices(tokens), "back"] answer = _ask(token_choices, "Select a token to remove:") if answer is None or answer == "back": return - token_to_remove = next((t for t in tokens if t.name == answer), None) + token_to_remove = _selected_token(tokens, answer) if token_to_remove is None: console.print("[red]Token not found.[/red]") return + remaining = [t for t in tokens if t.name != token_to_remove.name] + if active_before is None or active_before.name == token_to_remove.name: + if len(remaining) == 1: + new_active = remaining[0] + else: + replacement_choices: list[questionary.Choice | str] = [ + *_token_choices(remaining), + "back", + ] + replacement_name = _ask( + replacement_choices, + "Select the replacement active token:", + ) + if replacement_name is None or replacement_name == "back": + console.print("[dim]Cancelled.[/dim]") + return + replacement = _selected_token(remaining, replacement_name) + if replacement is None: + console.print("[red]Replacement token not found.[/red]") + return + new_active = replacement + active_changed = True + else: + new_active = active_before + active_changed = False confirmed = questionary.confirm( f"Remove token '{token_to_remove.name}' (region={token_to_remove.region})?", default=True, @@ -394,14 +597,29 @@ def _remove_token() -> None: if not confirmed: console.print("[dim]Cancelled.[/dim]") return - - remaining = [t for t in tokens if t.name != token_to_remove.name] - new_active = remaining[0] - vault_path = store.save_active_and_tokens(new_active, remaining) - console.print(f"[green]Removed token '{token_to_remove.name}'.[/green]") - console.print( - f"[green]Active token is now '{new_active.name}' (region={new_active.region}).[/green]" + changed_profiles = _matching_cli_profiles(active_before) if active_changed else [] + transaction_paths = ( + _credential_transaction_paths(_project_root(), examples_path) + if active_changed + else [ + examples_path / "group_vars" / "all" / "vault.yml", + examples_path / ".vault_pass", + ] ) + with _credential_transaction(transaction_paths): + vault_path = store.save_active_and_tokens( + new_active, + remaining, + preserve_omitted_active=False, + ) + if active_changed: + _sync_active_token(examples_path, new_active, changed_profiles) + console.print(f"[green]Removed token '{token_to_remove.name}'.[/green]") + if active_changed: + console.print( + f"[green]Active token is now '{new_active.name}' " + f"(region={new_active.region}).[/green]" + ) console.print(f"[dim]Vault updated: {vault_path}[/dim]") diff --git a/cisco_sccfm_scripts/prepare_ansible_release.py b/cisco_sccfm_scripts/prepare_ansible_release.py index 6fa1b91b..41fad7d2 100644 --- a/cisco_sccfm_scripts/prepare_ansible_release.py +++ b/cisco_sccfm_scripts/prepare_ansible_release.py @@ -24,6 +24,11 @@ _RST_VERSION = re.compile( r"^v(?P(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*))$" ) +_INITIAL_SEED = re.compile( + r"^# sccfm-release-retarget-seed: " + r"(?P(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*))$" +) +_IMMUTABLE_INITIAL_SEED_VERSION = "0.38.0" _MAINTAINER_GUIDANCE = "prepare the Ansible changelog in source before releasing" @@ -95,6 +100,12 @@ def _validate_version(value: str, label: str) -> None: raise AnsibleReleaseError(f"{label} must be a canonical stable semantic version") +def _version_tuple(value: str) -> tuple[int, int, int]: + """Return comparable components for an already validated stable version.""" + major, minor, patch = value.split(".") + return int(major), int(minor), int(patch) + + def _resolved_date(value: str | None) -> str: """Return a validated ISO date, defaulting to the current UTC date.""" resolved = value or datetime.now(timezone.utc).date().isoformat() @@ -188,6 +199,24 @@ def _release_blocks(lines: list[str]) -> dict[str, _ReleaseBlock]: return blocks +def _initial_seed_version(lines: list[str]) -> str: + """Return the one release version explicitly marked as the retargetable seed.""" + versions = [ + match.group("version") + for line in lines + if (match := _INITIAL_SEED.fullmatch(line.rstrip("\r\n"))) is not None + ] + if len(versions) != 1: + raise AnsibleReleaseError( + f"initial release seed is not marked safely; {_MAINTAINER_GUIDANCE}" + ) + if versions[0] != _IMMUTABLE_INITIAL_SEED_VERSION: + raise AnsibleReleaseError( + f"initial release seed marker is not immutable; {_MAINTAINER_GUIDANCE}" + ) + return versions[0] + + def _replace_release_date(lines: list[str], block: _ReleaseBlock, release_date: str) -> None: """Replace the one simple release_date scalar in a release block.""" candidates = [ @@ -284,6 +313,8 @@ def prepare_ansible_release( """Align existing collection changelogs with one manually selected version.""" _validate_version(previous_version, "previous version") _validate_version(release_version, "release version") + if _version_tuple(release_version) <= _version_tuple(previous_version): + raise AnsibleReleaseError("release version must be greater than previous version") resolved_date = _resolved_date(release_date) if collection_root.is_symlink() or not collection_root.is_dir(): raise AnsibleReleaseError("collection root must be a regular directory") @@ -293,12 +324,20 @@ def prepare_ansible_release( original_yaml = _read_regular_file(yaml_path) original_rst = _read_regular_file(rst_path) releases = _load_releases(original_yaml) + if any(_version_tuple(version) > _version_tuple(release_version) for version in releases): + raise AnsibleReleaseError( + f"release version must remain the newest changelog entry; {_MAINTAINER_GUIDANCE}" + ) yaml_lines = original_yaml.splitlines(keepends=True) rst_lines = original_rst.splitlines(keepends=True) blocks = _release_blocks(yaml_lines) headings = _rst_headings(rst_lines) if set(blocks) != set(releases): raise AnsibleReleaseError(f"release blocks cannot be edited safely; {_MAINTAINER_GUIDANCE}") + if set(releases) != set(headings): + raise AnsibleReleaseError( + f"changelog files disagree on release history; {_MAINTAINER_GUIDANCE}" + ) yaml_has_target = release_version in releases rst_has_target = release_version in headings @@ -308,6 +347,20 @@ def prepare_ansible_release( ) if yaml_has_target: + seed_version = _initial_seed_version(yaml_lines) + if release_version != previous_version: + previous_is_present = previous_version in releases + consumed_initial_seed = ( + set(releases) == {release_version} and seed_version == previous_version + ) + if previous_is_present and seed_version == previous_version: + raise AnsibleReleaseError( + f"initial release seed was not retargeted; {_MAINTAINER_GUIDANCE}" + ) + if not previous_is_present and not consumed_initial_seed: + raise AnsibleReleaseError( + f"previous release is missing from changelog history; {_MAINTAINER_GUIDANCE}" + ) _validate_entry(releases[release_version], release_version) _replace_release_date(yaml_lines, blocks[release_version], resolved_date) else: @@ -315,6 +368,10 @@ def prepare_ansible_release( raise AnsibleReleaseError( f"only a single initial release can be retargeted; {_MAINTAINER_GUIDANCE}" ) + if _initial_seed_version(yaml_lines) != previous_version: + raise AnsibleReleaseError( + f"initial release seed was already retargeted; {_MAINTAINER_GUIDANCE}" + ) entry = _validate_entry(releases[previous_version], previous_version) block = blocks[previous_version] _replace_release_date(yaml_lines, block, resolved_date) diff --git a/cisco_sccfm_scripts/setup_tokens.py b/cisco_sccfm_scripts/setup_tokens.py index 96985e4b..eb75e87a 100644 --- a/cisco_sccfm_scripts/setup_tokens.py +++ b/cisco_sccfm_scripts/setup_tokens.py @@ -8,15 +8,18 @@ Runs **interactively** by default (prompts for region, token, etc.). Supply ``--region`` with ``SCCFM_API_TOKEN`` to run **headless** — suitable -for CI pipelines and scripted workflows. The legacy ``--api-token`` option -remains available but can expose the token in shell history and process listings. +for CI pipelines and scripted workflows. When a private ``.vault_pass`` does +not exist yet, supply ``SCCFM_VAULT_PASSWORD`` as well. The legacy +``--api-token`` and ``--vault-password`` options remain available but can expose +secrets in shell history and process listings. -Manages a local token store so tokens can be reused across setups. +Manages a local token store so tokens can be reused across setups. Existing +vaults with the legacy ``sccfm_api_token`` field are migrated on the next save. Creates / updates: - .env (SCCFM_REGION, SCCFM_API_TOKEN) - .vault_pass (vault password file) - group_vars/all/vars.yml (sccfm_region) - - group_vars/all/vault.yml (encrypted sccfm_api_token) + - group_vars/all/vault.yml (encrypted vault_sccfm_api_token) - ~/.sccfm-cli/config.json (CLI profile) Examples:: @@ -24,7 +27,7 @@ # Interactive (default) python cisco_sccfm_scripts/setup_tokens.py - # Headless — SCCFM_API_TOKEN is injected by the CI secret environment + # Headless — secrets are injected by the CI environment python cisco_sccfm_scripts/setup_tokens.py --region us # Headless — optional non-secret settings @@ -34,12 +37,19 @@ from __future__ import annotations +import json import os import re +import shlex import stat import subprocess +import sys import tempfile +from contextlib import contextmanager +from dataclasses import dataclass, field +from errno import ELOOP, ENOTDIR from pathlib import Path +from typing import Iterator import click import questionary @@ -49,7 +59,12 @@ from rich.table import Table from cisco_sccfm_core.constants import SCCFM_REGIONS -from cisco_sccfm_scripts.token_store import SavedToken, VaultTokenStore +from cisco_sccfm_scripts.token_store import ( + ActiveTokenRegionRequired, + SavedToken, + VaultTokenStore, + validate_user_token_name, +) _REGION_DESCRIPTIONS: dict[str, str] = { "int": "Internal (Staging)", @@ -69,6 +84,62 @@ console = Console() +@dataclass(frozen=True) +class _FileSnapshot: + """Recoverable state for one coordinated credential file.""" + + path: Path + content: bytes | None = field(repr=False) + mode: int | None + + +@dataclass(frozen=True) +class _PosixFileSnapshot: + """Recoverable state anchored to an already-open parent directory.""" + + path: Path + parent_descriptor: int = field(repr=False) + name: str + content: bytes | None = field(repr=False) + mode: int | None + + +class _PosixCredentialTransaction: + """Descriptor-anchored credential snapshot and I/O boundary.""" + + def __init__(self, snapshots: list[_PosixFileSnapshot]) -> None: + self._snapshots = snapshots + self._by_path = {snapshot.path: snapshot for snapshot in snapshots} + + def _snapshot(self, path: Path) -> _PosixFileSnapshot | None: + normalized = _platform_normalized_path(path) + return self._by_path.get(normalized) + + def manages(self, path: Path) -> bool: + return self._snapshot(path) is not None + + def read_bytes(self, path: Path) -> bytes | None: + snapshot = self._snapshot(path) + if snapshot is None: + return None + captured = _read_regular_relative(path, snapshot.parent_descriptor) + return None if captured is None else captured[0] + + def write_bytes(self, path: Path, content: bytes, *, mode: int) -> bool: + snapshot = self._snapshot(path) + if snapshot is None: + return False + _write_relative_bytes(snapshot, content, mode=mode) + return True + + def parent_descriptor(self, path: Path) -> int | None: + snapshot = self._snapshot(path) + return None if snapshot is None else snapshot.parent_descriptor + + +_active_credential_transaction: _PosixCredentialTransaction | None = None + + def _project_root() -> Path: """Return the repository root (parent of cisco_sccfm_scripts/).""" return Path(__file__).resolve().parent.parent @@ -111,6 +182,16 @@ def _secure_directory(path: Path) -> None: def _write_private_text(path: Path, content: str) -> None: """Atomically write UTF-8 text with mode 0600.""" + _write_private_bytes(path, content.encode("utf-8"), mode=0o600) + + +def _write_private_bytes(path: Path, content: bytes, *, mode: int) -> None: + """Atomically write private bytes without following a final symlink.""" + if _active_credential_transaction is not None and _active_credential_transaction.write_bytes( + path, content, mode=mode + ): + return + _validate_path_ancestors(path) if path.parent.is_symlink(): raise click.ClickException( f"Refusing to write through a symlinked directory: {path.parent}" @@ -120,16 +201,330 @@ def _write_private_text(path: Path, content: str) -> None: temporary_path = Path(temporary_name) try: os.fchmod(descriptor, stat.S_IRUSR | stat.S_IWUSR) - with os.fdopen(descriptor, "w", encoding="utf-8") as handle: + with os.fdopen(descriptor, "wb") as handle: handle.write(content) handle.flush() os.fsync(handle.fileno()) os.replace(temporary_path, path) - path.chmod(stat.S_IRUSR | stat.S_IWUSR) + path.chmod(mode) finally: temporary_path.unlink(missing_ok=True) +def _capture_file(path: Path) -> _FileSnapshot: + """Capture a regular file before a coordinated credential update.""" + _validate_path_ancestors(path) + if path.is_symlink(): + raise click.ClickException(f"Refusing to snapshot a symlinked credential file: {path}") + if not path.exists(): + return _FileSnapshot(path=path, content=None, mode=None) + if not path.is_file(): + raise click.ClickException(f"Credential path is not a regular file: {path}") + return _FileSnapshot( + path=path, + content=path.read_bytes(), + mode=stat.S_IMODE(path.stat().st_mode), + ) + + +def _restore_file(snapshot: _FileSnapshot) -> None: + """Restore one credential snapshot after a failed coordinated update.""" + if snapshot.content is None: + if snapshot.path.is_symlink(): + raise RuntimeError("credential rollback encountered a symlink") + snapshot.path.unlink(missing_ok=True) + return + _write_private_bytes( + snapshot.path, + snapshot.content, + mode=snapshot.mode or 0o600, + ) + + +def _safe_open_flags() -> int: + """Return flags that refuse symlinks and blocking special files.""" + return getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0) + + +def _credential_path_changed(path: Path) -> click.ClickException: + return click.ClickException(f"Credential path changed or contains a symbolic link: {path}") + + +def _open_posix_parent(path: Path) -> int: + """Open every existing ancestor without following symlinks. + + Missing suffix components are created descriptor-relatively. Holding the + returned descriptor pins the parent used for the whole transaction, so a + later pathname swap cannot redirect either snapshot or rollback. + """ + parent = path.parent + flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | _safe_open_flags() + descriptor = os.open(parent.anchor, flags) + try: + for component in parent.parts[1:]: + try: + child = os.open(component, flags, dir_fd=descriptor) + except FileNotFoundError: + try: + os.mkdir(component, mode=stat.S_IRWXU, dir_fd=descriptor) + except FileExistsError: + pass + child = os.open(component, flags, dir_fd=descriptor) + except OSError as exc: + if exc.errno in (ELOOP, ENOTDIR): + raise _credential_path_changed(path) from exc + raise + try: + child_stat = os.fstat(child) + entry_stat = os.stat( + component, + dir_fd=descriptor, + follow_symlinks=False, + ) + if ( + not stat.S_ISDIR(child_stat.st_mode) + or stat.S_ISLNK(entry_stat.st_mode) + or not os.path.samestat(child_stat, entry_stat) + ): + raise _credential_path_changed(path) + except BaseException: + os.close(child) + raise + os.close(descriptor) + descriptor = child + return descriptor + except BaseException: + os.close(descriptor) + raise + + +def _read_regular_relative(path: Path, parent_descriptor: int) -> tuple[bytes, int] | None: + """Read one credential file relative to a pinned parent directory.""" + flags = os.O_RDONLY | _safe_open_flags() + try: + descriptor = os.open(path.name, flags, dir_fd=parent_descriptor) + except FileNotFoundError: + return None + except OSError as exc: + if exc.errno in (ELOOP, ENOTDIR): + raise _credential_path_changed(path) from exc + raise + try: + descriptor_stat = os.fstat(descriptor) + entry_stat = os.stat( + path.name, + dir_fd=parent_descriptor, + follow_symlinks=False, + ) + if ( + not stat.S_ISREG(descriptor_stat.st_mode) + or stat.S_ISLNK(entry_stat.st_mode) + or not os.path.samestat(descriptor_stat, entry_stat) + ): + raise _credential_path_changed(path) + chunks: list[bytes] = [] + while chunk := os.read(descriptor, 1024 * 1024): + chunks.append(chunk) + return b"".join(chunks), stat.S_IMODE(descriptor_stat.st_mode) + finally: + os.close(descriptor) + + +def _capture_posix_file(path: Path) -> _PosixFileSnapshot: + """Capture a credential file through a pinned, descriptor-walked parent.""" + parent_descriptor = _open_posix_parent(path) + try: + captured = _read_regular_relative(path, parent_descriptor) + if captured is None: + return _PosixFileSnapshot(path, parent_descriptor, path.name, None, None) + content, mode = captured + return _PosixFileSnapshot(path, parent_descriptor, path.name, content, mode) + except BaseException: + os.close(parent_descriptor) + raise + + +def _restore_posix_file(snapshot: _PosixFileSnapshot) -> None: + """Restore through the parent descriptor captured before the update.""" + if snapshot.content is None: + try: + entry_stat = os.stat( + snapshot.name, + dir_fd=snapshot.parent_descriptor, + follow_symlinks=False, + ) + except FileNotFoundError: + return + if stat.S_ISLNK(entry_stat.st_mode) or not stat.S_ISREG(entry_stat.st_mode): + raise RuntimeError("credential rollback encountered an unsafe path") + os.unlink(snapshot.name, dir_fd=snapshot.parent_descriptor) + return + + _write_relative_bytes(snapshot, snapshot.content, mode=snapshot.mode or 0o600) + + +def _write_relative_bytes( + snapshot: _PosixFileSnapshot, + content: bytes, + *, + mode: int, +) -> None: + """Atomically replace one file relative to its pinned parent descriptor.""" + temporary_name = f".{snapshot.name}.update-{os.getpid()}-{id(content):x}" + descriptor = os.open( + temporary_name, + os.O_WRONLY | os.O_CREAT | os.O_EXCL | _safe_open_flags(), + mode, + dir_fd=snapshot.parent_descriptor, + ) + try: + os.fchmod(descriptor, mode) + view = memoryview(content) + while view: + written = os.write(descriptor, view) + view = view[written:] + os.fsync(descriptor) + os.replace( + temporary_name, + snapshot.name, + src_dir_fd=snapshot.parent_descriptor, + dst_dir_fd=snapshot.parent_descriptor, + ) + finally: + os.close(descriptor) + try: + os.unlink(temporary_name, dir_fd=snapshot.parent_descriptor) + except FileNotFoundError: + pass + + +def _close_posix_snapshots(snapshots: list[_PosixFileSnapshot]) -> None: + for snapshot in snapshots: + os.close(snapshot.parent_descriptor) + + +def _capture_posix_files(paths: list[Path]) -> list[_PosixFileSnapshot]: + """Capture all files, closing already-open parents if preflight fails.""" + snapshots: list[_PosixFileSnapshot] = [] + try: + for path in paths: + snapshots.append(_capture_posix_file(path)) + return snapshots + except BaseException: + _close_posix_snapshots(snapshots) + raise + + +def _platform_normalized_path(path: Path) -> Path: + """Normalize only macOS's fixed root aliases, never user-controlled links.""" + absolute = path.expanduser().absolute() + if sys.platform == "darwin" and absolute.parts[:2] == ("/", "var"): + return Path("/private").joinpath(*absolute.parts[1:]) + return absolute + + +def _read_transaction_text(path: Path) -> str | None: + """Read UTF-8 content through the active transaction when managed by it.""" + if _active_credential_transaction is None: + return None + content = _active_credential_transaction.read_bytes(path) + return None if content is None else content.decode("utf-8") + + +def _transaction_manages(path: Path) -> bool: + return _active_credential_transaction is not None and _active_credential_transaction.manages( + path + ) + + +def _validate_path_ancestors(path: Path) -> None: + """Reject symlinked or non-directory ancestors of a credential path.""" + absolute = path.expanduser().absolute() + current = Path(absolute.anchor) + for part in absolute.parts[1:-1]: + current /= part + if current.is_symlink(): + raise click.ClickException( + f"Refusing to use a credential path through symlinked directory: {current}" + ) + if current.exists() and not current.is_dir(): + raise click.ClickException(f"Credential path ancestor is not a directory: {current}") + + +@contextmanager +def _credential_transaction(paths: list[Path]) -> Iterator[None]: + """Roll back every listed credential file if a coordinated update fails.""" + global _active_credential_transaction + + unique_paths = list(dict.fromkeys(_platform_normalized_path(path) for path in paths)) + if _active_credential_transaction is not None: + raise RuntimeError("Nested credential transactions are not supported") + if os.name != "posix": + snapshots = [_capture_file(path) for path in unique_paths] + try: + yield + except BaseException: + rollback_failed = False + for file_snapshot in reversed(snapshots): + try: + _restore_file(file_snapshot) + except (OSError, RuntimeError, click.ClickException): + rollback_failed = True + if rollback_failed: + raise RuntimeError( + "Credential update failed and its previous state could not be fully restored" + ) from None + raise + return + + posix_snapshots: list[_PosixFileSnapshot] = [] + try: + posix_snapshots = _capture_posix_files(unique_paths) + _active_credential_transaction = _PosixCredentialTransaction(posix_snapshots) + try: + yield + except BaseException: + rollback_failed = False + for posix_snapshot in reversed(posix_snapshots): + try: + _restore_posix_file(posix_snapshot) + except (OSError, RuntimeError, click.ClickException): + rollback_failed = True + if rollback_failed: + raise RuntimeError( + "Credential update failed and its previous state could not be fully restored" + ) from None + raise + finally: + _active_credential_transaction = None + _close_posix_snapshots(posix_snapshots) + + +def _credential_state_paths(root: Path, examples_path: Path) -> list[Path]: + """Return every file updated when the active credential changes.""" + return [ + root / ".env", + examples_path / "group_vars" / "all" / "vars.yml", + examples_path / "group_vars" / "all" / "vault.yml", + _resolved_config_path(), + ] + + +def _credential_transaction_paths(root: Path, examples_path: Path) -> list[Path]: + """Include the read-only Vault password input in transaction preflight.""" + return [*_credential_state_paths(root, examples_path), examples_path / ".vault_pass"] + + +def _resolved_config_path() -> Path: + """Return the same CLI config path selected by SCCFM_CONFIG/ConfigService.""" + config_value = os.environ.get("SCCFM_CONFIG") + return ( + Path(config_value).expanduser() + if config_value + else Path.home() / ".sccfm-cli" / "config.json" + ) + + # ── Ansible-vault availability ─────────────────────────────────── @@ -168,11 +563,11 @@ def _choose_from_saved_or_new( choices: list[questionary.Choice] = [ questionary.Choice( title=f"{t.name:<20} region={t.region}", - value=t.name, + value=f"token:{index}", ) - for t in saved + for index, t in enumerate(saved) ] - choices.append(questionary.Choice(title="+ Add a new token", value="_new")) + choices.append(questionary.Choice(title="+ Add a new token", value="action:new")) answer: str | None = questionary.select( "Select a saved token or add a new one:", @@ -182,15 +577,18 @@ def _choose_from_saved_or_new( if answer is None: raise click.Abort() - if answer == "_new": + if answer == "action:new": new_token = _prompt_new_token() + if any(token.name != new_token.name and token.token == new_token.token for token in saved): + raise click.ClickException("That API token is already saved under a different name.") all_tokens = [t for t in saved if t.name != new_token.name] all_tokens.append(new_token) return new_token, all_tokens - selected = next((t for t in saved if t.name == answer), None) - if selected is None: - raise click.ClickException(f"Token '{answer}' not found in vault.") + try: + selected = saved[int(answer.removeprefix("token:"))] + except (ValueError, IndexError): + raise click.ClickException("Selected token was not found in the Vault.") from None return selected, saved @@ -199,7 +597,19 @@ def _prompt_new_token() -> SavedToken: region = _prompt_region() api_token = _prompt_token() name = _prompt_token_name() - return SavedToken(name=name, region=region, token=api_token) + return _saved_token(name=name, region=region, token=api_token) + + +def _saved_token(*, name: str, region: str, token: str) -> SavedToken: + """Build a validated token and normalize validation for Click callers.""" + try: + return SavedToken( + name=validate_user_token_name(name), + region=region, + token=token, + ) + except ValueError as exc: + raise click.ClickException(str(exc)) from None # ── Interactive prompts ────────────────────────────────────────── @@ -247,11 +657,15 @@ def _prompt_token() -> str: def _prompt_token_name() -> str: """Ask for a label to identify this token.""" - name: str = click.prompt( - "\nName for this token (for your reference)", - default="default", - ) - return name.strip() + while True: + name: str = click.prompt( + "\nName for this token (for your reference)", + default="default", + ) + try: + return validate_user_token_name(name) + except ValueError as exc: + console.print(f"[red]{exc}[/red]") # ── .env file management ──────────────────────────────────────── @@ -259,9 +673,26 @@ def _prompt_token_name() -> str: def _upsert_env_var(content: str, var: str, value: str) -> str: """Replace ``export VAR=…`` in *content*, or append if not present.""" - pattern = rf"^(export\s+){var}=.*$" + pattern = rf"^[ \t]*(?:export[ \t]+)?{var}[ \t]*=.*$" replacement = f"export {var}={value}" - updated, count = re.subn(pattern, replacement, content, flags=re.MULTILINE) + updated, count = re.subn( + pattern, + lambda _match: replacement, + content, + flags=re.MULTILINE, + ) + if count > 1: + lines = updated.splitlines(keepends=True) + seen = False + deduplicated: list[str] = [] + assignment = re.compile(pattern) + for line in lines: + if assignment.fullmatch(line.rstrip("\r\n")): + if seen: + continue + seen = True + deduplicated.append(line) + updated = "".join(deduplicated) if count == 0: updated = updated.rstrip() + f"\n{replacement}\n" return updated @@ -276,11 +707,20 @@ def _write_env_file(root: Path, region: str, api_token: str) -> Path: """ env_path = root / ".env" example_path = root / _ENV_EXAMPLE - if env_path.is_symlink(): - raise click.ClickException(f"Refusing to update a symlinked credential file: {env_path}") + transaction_content = _read_transaction_text(env_path) + if _transaction_manages(env_path): + content = transaction_content + else: + if env_path.is_symlink(): + raise click.ClickException( + f"Refusing to update a symlinked credential file: {env_path}" + ) + if env_path.exists() and not env_path.is_file(): + raise click.ClickException(f"Credential path is not a regular file: {env_path}") + content = env_path.read_text() if env_path.exists() else None - if env_path.exists(): - content = env_path.read_text() + if content is not None: + pass elif example_path.exists(): content = example_path.read_text() else: @@ -289,8 +729,8 @@ def _write_env_file(root: Path, region: str, api_token: str) -> Path: "# The .env file is gitignored and loaded automatically by direnv\n\n" ) - content = _upsert_env_var(content, "SCCFM_REGION", region) - content = _upsert_env_var(content, "SCCFM_API_TOKEN", f'"{api_token}"') + content = _upsert_env_var(content, "SCCFM_REGION", shlex.quote(region)) + content = _upsert_env_var(content, "SCCFM_API_TOKEN", shlex.quote(api_token)) _write_private_text(env_path, content) console.print(f"[green]Updated .env file:[/green] {env_path}") return env_path @@ -305,10 +745,37 @@ def _update_cli_config(region: str, api_token: str, profile: str = "default") -> from cisco_sccfm_cli.services import ConfigService config = Config(profile=profile, region=region, api_token=api_token) - ConfigService().save(config) + config_path = _resolved_config_path() + if _transaction_manages(config_path): + if _is_default_config_path(config_path) and _active_credential_transaction is not None: + parent_descriptor = _active_credential_transaction.parent_descriptor(config_path) + if parent_descriptor is not None: + os.fchmod(parent_descriptor, 0o700) + content = _read_transaction_text(config_path) + try: + payload = {} if content is None else json.loads(content) + except json.JSONDecodeError: + raise + profiles = payload.get("profiles", {}) + if not isinstance(profiles, dict): + profiles = {} + profiles[profile] = {"region": region, "api_token": api_token} + _write_private_text( + config_path, + json.dumps({"profiles": profiles}, indent=2), + ) + else: + ConfigService(path=config_path).save(config) console.print(f"[green]Updated CLI config profile '{profile}'[/green]") +def _is_default_config_path(config_path: Path) -> bool: + """Return whether the configured location is the CLI's default private path.""" + return _platform_normalized_path(config_path) == _platform_normalized_path( + Path.home() / ".sccfm-cli" / "config.json" + ) + + # ── Vault password management ──────────────────────────────────── @@ -321,6 +788,10 @@ def _ensure_vault_pass(examples_path: Path) -> Path: f"Refusing to use a symlinked credential file: {vault_pass_path}" ) if vault_pass_path.exists(): + if not vault_pass_path.is_file(): + raise click.ClickException( + f"Vault password path is not a regular file: {vault_pass_path}" + ) vault_pass_path.chmod(stat.S_IRUSR | stat.S_IWUSR) console.print(f"\n[dim]Using existing vault password file: {vault_pass_path}[/dim]") return vault_pass_path @@ -350,13 +821,18 @@ def _ensure_vault_pass_headless(examples_path: Path, vault_password: str | None) f"Refusing to use a symlinked credential file: {vault_pass_path}" ) if vault_pass_path.exists(): + if not vault_pass_path.is_file(): + raise click.ClickException( + f"Vault password path is not a regular file: {vault_pass_path}" + ) vault_pass_path.chmod(stat.S_IRUSR | stat.S_IWUSR) console.print(f"[dim]Using existing vault password file: {vault_pass_path}[/dim]") return vault_pass_path - if not vault_password: + if not vault_password or not vault_password.strip(): raise click.ClickException( - "No vault password file found and --vault-password was not supplied." + "No vault password found. Set SCCFM_VAULT_PASSWORD, or create a private " + ".vault_pass file in the examples directory before retrying." ) _write_private_text(vault_pass_path, vault_password.strip() + "\n") @@ -367,6 +843,8 @@ def _ensure_vault_pass_headless(examples_path: Path, vault_password: str | None) def _merge_token(store: VaultTokenStore, token: SavedToken) -> list[SavedToken]: """Merge *token* into the existing saved list, replacing by name.""" existing = store.list_tokens() + if any(saved.name != token.name and saved.token == token.token for saved in existing): + raise click.ClickException("That API token is already saved under a different name.") merged = [t for t in existing if t.name != token.name] merged.append(token) return merged @@ -378,13 +856,21 @@ def _merge_token(store: VaultTokenStore, token: SavedToken) -> list[SavedToken]: def _update_vars_region(examples_path: Path, region: str) -> None: """Set sccfm_region in group_vars/all/vars.yml, preserving other content.""" vars_path = examples_path / "group_vars" / "all" / "vars.yml" - if vars_path.is_symlink(): - raise click.ClickException(f"Refusing to update a symlinked workspace file: {vars_path}") - _secure_directory(examples_path / "group_vars") - _secure_directory(vars_path.parent) + transaction_content = _read_transaction_text(vars_path) + if _transaction_manages(vars_path): + content = transaction_content + else: + if vars_path.is_symlink(): + raise click.ClickException( + f"Refusing to update a symlinked workspace file: {vars_path}" + ) + if vars_path.exists() and not vars_path.is_file(): + raise click.ClickException(f"Workspace path is not a regular file: {vars_path}") + _secure_directory(examples_path / "group_vars") + _secure_directory(vars_path.parent) + content = vars_path.read_text() if vars_path.exists() else None - if vars_path.exists(): - content = vars_path.read_text() + if content is not None: if re.search(r"^sccfm_region:.*$", content, flags=re.MULTILINE): updated = re.sub( r"^sccfm_region:.*$", @@ -418,6 +904,7 @@ def _run_headless( name: str, profile: str, vault_password: str | None, + legacy_region: str | None, path: Path | None, ) -> None: """Execute the full setup without any interactive prompts.""" @@ -427,22 +914,29 @@ def _run_headless( _verify_ansible_vault() + # ── Build token ────────────────────────────────────────────── + selected = _saved_token(name=name, region=region, token=api_token) + # ── Vault password ─────────────────────────────────────────── vault_pass_path = _ensure_vault_pass_headless(examples_path, vault_password) - # ── Build token ────────────────────────────────────────────── - selected = SavedToken(name=name, region=region, token=api_token) - # ── Merge with existing saved tokens ───────────────────────── - store = VaultTokenStore(examples_path) - all_tokens = _merge_token(store, selected) + store = VaultTokenStore(examples_path, migration_region=legacy_region) + try: + all_tokens = _merge_token(store, selected) + except ActiveTokenRegionRequired as exc: + raise click.ClickException( + f"{exc}. Set SCCFM_LEGACY_REGION or pass --legacy-region with the region of the " + "existing active-only Vault token." + ) from None # ── Write files ────────────────────────────────────────────── - env_path = _write_env_file(root, region, api_token) - _update_vars_region(examples_path, region) - vault_path = store.save_active_and_tokens(selected, all_tokens) - console.print(f"[green]Encrypted vault file updated:[/green] {vault_path}") - _update_cli_config(region, api_token, profile=profile) + with _credential_transaction(_credential_transaction_paths(root, examples_path)): + env_path = _write_env_file(root, region, api_token) + _update_vars_region(examples_path, region) + vault_path = store.save_active_and_tokens(selected, all_tokens) + console.print(f"[green]Encrypted vault file updated:[/green] {vault_path}") + _update_cli_config(region, api_token, profile=profile) # ── Summary ────────────────────────────────────────────────── summary = Table(title="Setup Complete", show_header=False, border_style="green") @@ -454,7 +948,7 @@ def _run_headless( summary.add_row(".env file", str(env_path)) summary.add_row("Vault file", str(vault_path)) summary.add_row("Vault password", str(vault_pass_path)) - summary.add_row("CLI config", "~/.sccfm-cli/config.json") + summary.add_row("CLI config", str(_resolved_config_path())) console.print() console.print(summary) @@ -506,7 +1000,26 @@ def _run_headless( @click.option( "--vault-password", default=None, - help="Vault password — used only when .vault_pass doesn't exist yet (headless only).", + envvar="SCCFM_VAULT_PASSWORD", + show_envvar=True, + hide_input=True, + help=( + "Vault password used only when a private .vault_pass file does not exist. Passing " + "--vault-password directly is supported for compatibility but may expose it in process " + "listings and shell history; prefer SCCFM_VAULT_PASSWORD or an existing private " + ".vault_pass file." + ), +) +@click.option( + "--legacy-region", + default=None, + envvar="SCCFM_LEGACY_REGION", + show_envvar=True, + type=click.Choice(_VALID_REGIONS, case_sensitive=False), + help=( + "Region of an existing active-only Vault token when its region cannot be resolved. " + "This is distinct from --region, which belongs to the newly selected token." + ), ) @click.option( "--path", @@ -527,6 +1040,7 @@ def main( name: str, profile: str, vault_password: str | None, + legacy_region: str | None, path: Path | None, ) -> None: """Setup tokens — auto-detects interactive vs headless mode.""" @@ -540,6 +1054,15 @@ def main( "shell history; prefer SCCFM_API_TOKEN.", err=True, ) + if ( + vault_password is not None + and ctx.get_parameter_source("vault_password") is ParameterSource.COMMANDLINE + ): + click.echo( + "Warning: passing --vault-password directly may expose it in process listings and " + "shell history; prefer SCCFM_VAULT_PASSWORD or an existing private .vault_pass file.", + err=True, + ) headless = region is not None or api_token is not None @@ -555,6 +1078,7 @@ def main( name=name, profile=profile, vault_password=vault_password, + legacy_region=legacy_region, path=path, ) else: @@ -586,18 +1110,28 @@ def _run_setup(path: Path | None) -> None: # ── Token selection ────────────────────────────────────────── store = VaultTokenStore(examples_path) - selected, all_tokens = _select_or_create_token(store) + try: + selected, all_tokens = _select_or_create_token(store) + except ActiveTokenRegionRequired: + console.print( + "[yellow]The existing active-only Vault token needs its SCCFM region before it " + "can be preserved.[/yellow]" + ) + migration_region = _prompt_region() + store = VaultTokenStore(examples_path, migration_region=migration_region) + selected, all_tokens = _select_or_create_token(store) region = selected.region api_token = selected.token token_name = selected.name # ── Write files ────────────────────────────────────────────── - env_path = _write_env_file(root, region, api_token) - _update_vars_region(examples_path, region) - vault_path = store.save_active_and_tokens(selected, all_tokens) - console.print(f"[green]Encrypted vault file updated:[/green] {vault_path}") - _update_cli_config(region, api_token) + with _credential_transaction(_credential_transaction_paths(root, examples_path)): + env_path = _write_env_file(root, region, api_token) + _update_vars_region(examples_path, region) + vault_path = store.save_active_and_tokens(selected, all_tokens) + console.print(f"[green]Encrypted vault file updated:[/green] {vault_path}") + _update_cli_config(region, api_token) # ── Summary ────────────────────────────────────────────────── summary = Table(title="Setup Complete", show_header=False, border_style="green") @@ -608,7 +1142,7 @@ def _run_setup(path: Path | None) -> None: summary.add_row(".env file", str(env_path)) summary.add_row("Vault file", str(vault_path)) summary.add_row("Vault password", str(vault_pass_path)) - summary.add_row("CLI config", "~/.sccfm-cli/config.json") + summary.add_row("CLI config", str(_resolved_config_path())) console.print() console.print(summary) diff --git a/cisco_sccfm_scripts/token_store.py b/cisco_sccfm_scripts/token_store.py index d1544638..af804f97 100644 --- a/cisco_sccfm_scripts/token_store.py +++ b/cisco_sccfm_scripts/token_store.py @@ -5,14 +5,15 @@ """Token storage backed by the encrypted Ansible Vault file. Tokens are kept inside ``group_vars/all/vault.yml`` alongside the -active ``sccfm_api_token``. The vault is decrypted on read and -re-encrypted on write using ``ansible-vault`` + the ``.vault_pass`` -password file. +active ``vault_sccfm_api_token``. Vaults using the legacy +``sccfm_api_token`` field remain readable and are migrated the next +time they are saved. The vault is decrypted on read and re-encrypted +on write using ``ansible-vault`` + the ``.vault_pass`` password file. Vault structure (plaintext):: --- - sccfm_api_token: "" + vault_sccfm_api_token: "" sccfm_saved_tokens: - name: prod region: us @@ -28,12 +29,93 @@ import stat import subprocess import tempfile +from collections.abc import Callable from dataclasses import dataclass, field from pathlib import Path -from typing import cast +from typing import Any import yaml +from cisco_sccfm_core.constants import SCCFM_REGIONS, normalize_sccfm_region + +_CURRENT_TOKEN_NAME = "vault-active" +_LEGACY_TOKEN_NAME = "legacy-active" +_RESERVED_TOKEN_NAMES = {"_new", "back"} +_RESERVED_TOKEN_PREFIXES = (_CURRENT_TOKEN_NAME, _LEGACY_TOKEN_NAME) +_INVALID_YAML = object() + + +class _TransactionVaultAbsent: + """Marker for a Vault proven absent through the pinned transaction.""" + + +_TRANSACTION_VAULT_ABSENT = _TransactionVaultAbsent() + + +class _UniqueKeyLoader(yaml.SafeLoader): + """YAML loader that rejects ambiguous duplicate mapping keys.""" + + +def _construct_unique_mapping( + loader: _UniqueKeyLoader, + node: yaml.nodes.MappingNode, + deep: bool = False, +) -> dict[object, object]: + """Reject duplicate explicit keys while preserving standard YAML merge precedence.""" + explicit_keys: set[object] = set() + for key_node, value_node in node.value: + del value_node + if key_node.tag == "tag:yaml.org,2002:merge": + continue + key = loader.construct_object(key_node, deep=deep) + try: + duplicate = key in explicit_keys + except TypeError: + raise yaml.constructor.ConstructorError( + "while constructing a mapping", + node.start_mark, + "found an unhashable key", + key_node.start_mark, + ) from None + if duplicate: + raise yaml.constructor.ConstructorError( + "while constructing a mapping", + node.start_mark, + "found a duplicate key", + key_node.start_mark, + ) + explicit_keys.add(key) + + loader.flatten_mapping(node) + result: dict[object, object] = {} + for key_node, value_node in node.value: + key = loader.construct_object(key_node, deep=deep) + result[key] = loader.construct_object(value_node, deep=deep) + return result + + +_UniqueKeyLoader.add_constructor( + yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, + _construct_unique_mapping, +) + + +class ActiveTokenRegionRequired(RuntimeError): + """Raised when an active-only token needs a region for safe preservation.""" + + +def validate_user_token_name(name: str) -> str: + """Normalize a user-supplied label and reject token-manager sentinel names.""" + normalized = name.strip() + if not normalized: + raise ValueError("token name must not be empty") + if normalized in _RESERVED_TOKEN_NAMES or any( + normalized == prefix or normalized.startswith(f"{prefix}-") + for prefix in _RESERVED_TOKEN_PREFIXES + ): + raise ValueError("token name is reserved by the interactive token manager") + return normalized + @dataclass(frozen=True) class SavedToken: @@ -43,13 +125,32 @@ class SavedToken: region: str token: str = field(repr=False) + def __post_init__(self) -> None: + """Normalize and validate values before they can reach encrypted storage.""" + name = self.name.strip() + token = self.token.strip() + region = normalize_sccfm_region(self.region) + if not name: + raise ValueError("token name must not be empty") + if not token: + raise ValueError("API token must not be empty") + if region not in SCCFM_REGIONS: + raise ValueError("token region must be a supported SCCFM region") + object.__setattr__(self, "name", name) + object.__setattr__(self, "region", region) + object.__setattr__(self, "token", token) + class VaultTokenStore: """Read/write helper for tokens stored in an encrypted vault file.""" - def __init__(self, examples_path: Path) -> None: + def __init__(self, examples_path: Path, migration_region: str | None = None) -> None: self._vault_path = examples_path / "group_vars" / "all" / "vault.yml" self._vault_pass_path = examples_path / ".vault_pass" + normalized_region = normalize_sccfm_region(migration_region) + if normalized_region is not None and normalized_region not in SCCFM_REGIONS: + raise ValueError("migration region must be a supported SCCFM region") + self._migration_region = normalized_region # ── Public API ─────────────────────────────────────────────── @@ -68,68 +169,349 @@ def list_tokens(self) -> list[SavedToken]: data = self._decrypt_vault() if data is None: return [] - raw_tokens = cast(list[dict[str, str]], data.get("sccfm_saved_tokens", [])) - tokens = [SavedToken(**entry) for entry in raw_tokens] - return sorted(tokens, key=lambda t: t.name) + tokens = self._load_saved_tokens(data) + tokens.extend(self._load_unsaved_active_tokens(data, tokens)) + return self._validate_token_set(tokens) + + def active_token(self) -> SavedToken | None: + """Return the token currently selected in the Vault, if one exists.""" + data = self._decrypt_vault() + if data is None: + return None + tokens = self._load_saved_tokens(data) + tokens.extend(self._load_unsaved_active_tokens(data, tokens)) + tokens = self._validate_token_set(tokens) + raw_active = data.get("vault_sccfm_api_token", data.get("sccfm_api_token")) + if raw_active is None: + return None + if not isinstance(raw_active, str) or not raw_active.strip(): + raise RuntimeError("active SCCFM API token must be a non-empty string") + matches = [token for token in tokens if token.token == raw_active.strip()] + if len(matches) != 1: + raise RuntimeError("active SCCFM API token is not represented uniquely") + return matches[0] def save_active_and_tokens( self, active: SavedToken, all_tokens: list[SavedToken], + *, + preserve_omitted_active: bool = True, ) -> Path: - """Write the active token + full saved list, then encrypt.""" - payload: dict[str, object] = { - "sccfm_api_token": active.token, - "sccfm_saved_tokens": [ - {"name": t.name, "region": t.region, "token": t.token} - for t in sorted(all_tokens, key=lambda t: t.name) - ], - } + """Update managed token fields without discarding unrelated vault data.""" + payload = self._decrypt_vault() or {} + if preserve_omitted_active: + saved_tokens = self._merge_unsaved_active_tokens(payload, all_tokens) + else: + stored_tokens = self._load_saved_tokens(payload) + self._load_unsaved_active_tokens(payload, stored_tokens) + saved_tokens = list(all_tokens) + saved_tokens = self._validate_token_set(saved_tokens) + if active not in saved_tokens: + raise ValueError("active token must be present exactly in the saved token list") + payload.pop("sccfm_api_token", None) + payload["vault_sccfm_api_token"] = active.token + payload["sccfm_saved_tokens"] = [ + {"name": t.name, "region": t.region, "token": t.token} + for t in sorted(saved_tokens, key=lambda t: t.name) + ] return self._encrypt_vault(payload) # ── Private helpers ────────────────────────────────────────── def _decrypt_vault(self) -> dict[str, object] | None: - """Decrypt vault.yml and return parsed YAML, or None.""" - if not self._vault_path.exists() or not self._vault_pass_path.exists(): + """Decrypt vault.yml, returning None only when the vault is absent.""" + transaction_inputs = self._transaction_decrypt_inputs() + if transaction_inputs is _TRANSACTION_VAULT_ABSENT: return None - if self._vault_path.is_symlink() or self._vault_pass_path.is_symlink(): + if transaction_inputs is not None: + vault_input, password_input, cleanup = transaction_inputs + try: + return self._decrypt_vault_paths(vault_input, password_input) + finally: + cleanup() + if self._vault_path.is_symlink(): raise RuntimeError("Refusing to read a symlinked vault credential file") + if not self._vault_path.exists(): + return None + self._validate_vault_password_file() + if not self._vault_path.is_file(): + raise RuntimeError("Vault path must be a regular file") + + return self._decrypt_vault_paths(self._vault_path, self._vault_pass_path) + + def _decrypt_vault_paths( + self, + vault_path: Path, + vault_pass_path: Path, + ) -> dict[str, object] | None: + """Decrypt validated input paths and parse their managed payload.""" + if not vault_path.exists(): + return None result = subprocess.run( [ "ansible-vault", "view", - str(self._vault_path), + str(vault_path), "--vault-password-file", - str(self._vault_pass_path), + str(vault_pass_path), ], capture_output=True, text=True, ) if result.returncode != 0: - return None + raise RuntimeError( + "ansible-vault could not decrypt the existing vault; refusing to overwrite it" + ) - data: dict[str, object] = yaml.safe_load(result.stdout) or {} + try: + data: Any = yaml.load(result.stdout, Loader=_UniqueKeyLoader) + except yaml.YAMLError: + data = _INVALID_YAML + if data is _INVALID_YAML: + raise RuntimeError( + "Decrypted vault does not contain valid YAML; refusing to overwrite it" + ) + if data is None: + return {} + if not isinstance(data, dict): + raise RuntimeError("Decrypted vault must contain a mapping; refusing to overwrite it") return data + def _transaction_decrypt_inputs( + self, + ) -> tuple[Path, Path, Callable[[], None]] | _TransactionVaultAbsent | None: + """Stage pinned Vault/password bytes for pathname-based ansible-vault.""" + from cisco_sccfm_scripts import setup_tokens + + transaction = setup_tokens._active_credential_transaction + if transaction is None or not transaction.manages(self._vault_path): + return None + vault_bytes = transaction.read_bytes(self._vault_path) + if vault_bytes is None: + if transaction.read_bytes(self._vault_pass_path) is None: + raise RuntimeError("Vault password file is required") + return _TRANSACTION_VAULT_ABSENT + password_bytes = transaction.read_bytes(self._vault_pass_path) + if password_bytes is None: + raise RuntimeError("Vault password file is required") + directory = Path(tempfile.mkdtemp(prefix="sccfm-vault-read-")) + directory.chmod(0o700) + vault_path = directory / "vault.yml" + password_path = directory / ".vault_pass" + vault_path.write_bytes(vault_bytes) + password_path.write_bytes(password_bytes) + vault_path.chmod(0o600) + password_path.chmod(0o600) + + def cleanup() -> None: + vault_path.unlink(missing_ok=True) + password_path.unlink(missing_ok=True) + directory.rmdir() + + return vault_path, password_path, cleanup + + def _validate_vault_password_file(self) -> None: + """Require a private regular password file for every Vault operation.""" + if self._vault_pass_path.is_symlink(): + raise RuntimeError("Refusing to read a symlinked vault credential file") + if not self._vault_pass_path.exists(): + raise RuntimeError(f"Vault password file is required: {self._vault_pass_path}") + if not self._vault_pass_path.is_file(): + raise RuntimeError("Vault password path must be a regular file") + if os.name == "posix" and stat.S_IMODE(self._vault_pass_path.stat().st_mode) != 0o600: + raise RuntimeError("Vault password file must have POSIX mode 0600") + + def _load_saved_tokens(self, data: dict[str, object]) -> list[SavedToken]: + """Validate and return saved tokens from a decrypted vault mapping.""" + raw_tokens = data.get("sccfm_saved_tokens", []) + if not isinstance(raw_tokens, list): + raise RuntimeError("sccfm_saved_tokens must be a list") + + tokens: list[SavedToken] = [] + for raw_token in raw_tokens: + if not isinstance(raw_token, dict): + raise RuntimeError("Each sccfm_saved_tokens entry must be a mapping") + fields = {name: raw_token.get(name) for name in ("name", "region", "token")} + if not all(isinstance(value, str) and value for value in fields.values()): + raise RuntimeError( + "Each sccfm_saved_tokens entry requires non-empty name, region, and token" + ) + try: + tokens.append( + SavedToken( + name=fields["name"], + region=fields["region"], + token=fields["token"], + ) + ) + except ValueError: + raise RuntimeError("sccfm_saved_tokens contains an invalid token entry") from None + return self._validate_token_set(tokens) + + @staticmethod + def _validate_token_set(tokens: list[SavedToken]) -> list[SavedToken]: + """Require a deterministic, unambiguous set of named credentials.""" + names = [token.name for token in tokens] + values = [token.token for token in tokens] + if len(names) != len(set(names)): + raise ValueError("saved token names must be unique") + if len(values) != len(set(values)): + raise ValueError("saved API token values must be unique") + return sorted(tokens, key=lambda token: token.name) + + def _load_unsaved_active_tokens( + self, + data: dict[str, object], + saved_tokens: list[SavedToken], + ) -> list[SavedToken]: + """Represent active-only current and legacy values so saves retain them.""" + unsaved: list[SavedToken] = [] + represented = list(saved_tokens) + active_values = { + value + for key in ("vault_sccfm_api_token", "sccfm_api_token") + if isinstance((value := data.get(key)), str) and value + } + represented_values = {token.token for token in represented} + if len(active_values) > 1 and not active_values.issubset(represented_values): + raise RuntimeError( + "Vault contains distinct current and legacy active tokens without per-token " + "regions; migrate them manually before using the token manager" + ) + for key, base_name in ( + ("vault_sccfm_api_token", _CURRENT_TOKEN_NAME), + ("sccfm_api_token", _LEGACY_TOKEN_NAME), + ): + raw_token = data.get(key) + if raw_token is None: + continue + if not isinstance(raw_token, str) or not raw_token: + raise RuntimeError(f"{key} must be a non-empty string") + if any(token.token == raw_token for token in represented): + continue + + token = SavedToken( + name=self._available_token_name(base_name, represented), + region=self._load_active_region(key), + token=raw_token, + ) + unsaved.append(token) + represented.append(token) + return unsaved + + @staticmethod + def _available_token_name(base_name: str, tokens: list[SavedToken]) -> str: + """Return a deterministic collision-safe synthetic token name.""" + used_names = {token.name for token in tokens} + name = base_name + suffix = 2 + while name in used_names: + name = f"{base_name}-{suffix}" + suffix += 1 + return name + + def _merge_unsaved_active_tokens( + self, + data: dict[str, object], + tokens: list[SavedToken], + ) -> list[SavedToken]: + """Retain active-only tokens even when a caller omits them on save.""" + merged = list(tokens) + stored_tokens = self._load_saved_tokens(data) + for unsaved_token in self._load_unsaved_active_tokens(data, stored_tokens): + is_represented = any( + token.name == unsaved_token.name or token.token == unsaved_token.token + for token in merged + ) + if not is_represented: + merged.append(unsaved_token) + return merged + + def _load_active_region(self, token_key: str) -> str: + """Load the compatibility region needed to retain an active-only token.""" + vars_path = self._vault_path.parent / "vars.yml" + transaction_content = self._transaction_text(vars_path) + transaction_manages = self._transaction_manages(vars_path) + if transaction_manages: + vars_content = transaction_content + else: + if vars_path.is_symlink(): + raise RuntimeError("Refusing to read a symlinked vars.yml credential companion") + if not vars_path.exists(): + vars_content = None + elif not vars_path.is_file(): + raise RuntimeError("vars.yml credential companion must be a regular file") + else: + try: + vars_content = vars_path.read_text(encoding="utf-8") + except OSError: + raise RuntimeError( + f"Cannot read sccfm_region needed to preserve {token_key} from {vars_path}" + ) from None + if vars_content is None: + if self._migration_region is not None: + return self._migration_region + raise ActiveTokenRegionRequired( + f"Cannot preserve active-only {token_key} without sccfm_region in {vars_path}" + ) + try: + data = yaml.load(vars_content, Loader=_UniqueKeyLoader) + except yaml.YAMLError: + data = _INVALID_YAML + if data is _INVALID_YAML: + raise RuntimeError( + f"Cannot read sccfm_region needed to preserve {token_key} from {vars_path}" + ) + if not isinstance(data, dict): + raise RuntimeError( + f"Cannot preserve active-only {token_key} because {vars_path} is not a YAML mapping" + ) + raw_region = data.get("sccfm_region") + if raw_region in { + "{{ lookup('env', 'SCCFM_REGION') }}", + '{{ lookup("env", "SCCFM_REGION") }}', + }: + raw_region = self._migration_region or os.environ.get("SCCFM_REGION") + region = normalize_sccfm_region(raw_region if isinstance(raw_region, str) else None) + if region is None and self._migration_region is not None: + return self._migration_region + if region in (None, ""): + raise ActiveTokenRegionRequired( + f"Cannot preserve active-only {token_key} because sccfm_region is unresolved in " + f"{vars_path}" + ) + if region not in SCCFM_REGIONS: + raise RuntimeError( + f"Cannot preserve active-only {token_key} because sccfm_region is missing or " + f"invalid in {vars_path}" + ) + return region + def _encrypt_vault(self, payload: dict[str, object]) -> Path: """Encrypt *payload* in a private temporary file, then replace atomically.""" group_vars_path = self._vault_path.parent.parent - if group_vars_path.is_symlink() or self._vault_path.parent.is_symlink(): - raise RuntimeError("Refusing to write through a symlinked vault directory") - if self._vault_path.is_symlink() or self._vault_pass_path.is_symlink(): - raise RuntimeError("Refusing to use a symlinked vault credential file") - group_vars_path.mkdir(parents=True, exist_ok=True, mode=stat.S_IRWXU) - group_vars_path.chmod(stat.S_IRWXU) - self._vault_path.parent.mkdir(parents=True, exist_ok=True, mode=stat.S_IRWXU) - self._vault_path.parent.chmod(stat.S_IRWXU) + if not self._transaction_manages_vault(): + if group_vars_path.is_symlink() or self._vault_path.parent.is_symlink(): + raise RuntimeError("Refusing to write through a symlinked vault directory") + if self._vault_path.is_symlink() or self._vault_pass_path.is_symlink(): + raise RuntimeError("Refusing to use a symlinked vault credential file") + self._validate_vault_password_file() + if self._vault_path.exists() and not self._vault_path.is_file(): + raise RuntimeError("Vault path must be a regular file") + group_vars_path.mkdir(parents=True, exist_ok=True, mode=stat.S_IRWXU) + group_vars_path.chmod(stat.S_IRWXU) + self._vault_path.parent.mkdir(parents=True, exist_ok=True, mode=stat.S_IRWXU) + self._vault_path.parent.chmod(stat.S_IRWXU) content = "---\n" + yaml.dump(payload, default_flow_style=False, sort_keys=False) + staging_directory, staged_password_path = self._transaction_staging_directory() + password_path = staged_password_path or self._vault_pass_path plaintext_descriptor, plaintext_name = tempfile.mkstemp( prefix=".vault.plaintext.", suffix=".tmp", - dir=self._vault_path.parent, + dir=staging_directory, ) plaintext_path = Path(plaintext_name) ciphertext_path: Path | None = None @@ -142,7 +524,7 @@ def _encrypt_vault(self, payload: dict[str, object]) -> Path: ciphertext_descriptor, ciphertext_name = tempfile.mkstemp( prefix=".vault.ciphertext.", suffix=".tmp", - dir=self._vault_path.parent, + dir=staging_directory, ) ciphertext_path = Path(ciphertext_name) with os.fdopen(ciphertext_descriptor, "wb") as encrypted: @@ -156,7 +538,7 @@ def _encrypt_vault(self, payload: dict[str, object]) -> Path: "--output", str(ciphertext_path), "--vault-password-file", - str(self._vault_pass_path), + str(password_path), ], capture_output=True, text=True, @@ -167,11 +549,90 @@ def _encrypt_vault(self, payload: dict[str, object]) -> Path: if not encrypted.readline(64).startswith(b"$ANSIBLE_VAULT;"): raise RuntimeError("ansible-vault did not produce valid encrypted output") + verification = subprocess.run( + [ + "ansible-vault", + "view", + str(ciphertext_path), + "--vault-password-file", + str(password_path), + ], + capture_output=True, + text=True, + ) + if verification.returncode != 0: + raise RuntimeError("ansible-vault produced ciphertext that could not be verified") + try: + verified_payload = yaml.load(verification.stdout, Loader=_UniqueKeyLoader) + except yaml.YAMLError: + raise RuntimeError( + "ansible-vault produced ciphertext with unverifiable plaintext" + ) from None + if verified_payload != payload: + raise RuntimeError("ansible-vault ciphertext did not preserve the intended payload") + ciphertext_path.chmod(stat.S_IRUSR | stat.S_IWUSR) - os.replace(ciphertext_path, self._vault_path) - self._vault_path.chmod(stat.S_IRUSR | stat.S_IWUSR) + if not self._commit_transaction_ciphertext(ciphertext_path): + os.replace(ciphertext_path, self._vault_path) + self._vault_path.chmod(stat.S_IRUSR | stat.S_IWUSR) return self._vault_path finally: plaintext_path.unlink(missing_ok=True) if ciphertext_path is not None: ciphertext_path.unlink(missing_ok=True) + if staged_password_path is not None: + staged_password_path.unlink(missing_ok=True) + staging_directory.rmdir() + + def _commit_transaction_ciphertext(self, ciphertext_path: Path) -> bool: + """Use setup's pinned transaction writer when this save participates in one.""" + from cisco_sccfm_scripts import setup_tokens + + transaction = setup_tokens._active_credential_transaction + if transaction is None: + return False + return transaction.write_bytes( + self._vault_path, + ciphertext_path.read_bytes(), + mode=0o600, + ) + + def _transaction_staging_directory(self) -> tuple[Path, Path | None]: + """Stage transaction secrets outside a mutable credential ancestor.""" + from cisco_sccfm_scripts import setup_tokens + + transaction = setup_tokens._active_credential_transaction + if transaction is None or not transaction.manages(self._vault_path): + return self._vault_path.parent, None + password_bytes = transaction.read_bytes(self._vault_pass_path) + if password_bytes is None: + raise RuntimeError("Vault password file is required") + staging_path = Path(tempfile.mkdtemp(prefix="sccfm-vault-transaction-")) + staging_path.chmod(0o700) + staged_password_path = staging_path / ".vault_pass" + staged_password_path.write_bytes(password_bytes) + staged_password_path.chmod(0o600) + return staging_path, staged_password_path + + def _transaction_manages_vault(self) -> bool: + from cisco_sccfm_scripts import setup_tokens + + transaction = setup_tokens._active_credential_transaction + return transaction is not None and transaction.manages(self._vault_path) + + @staticmethod + def _transaction_manages(path: Path) -> bool: + from cisco_sccfm_scripts import setup_tokens + + transaction = setup_tokens._active_credential_transaction + return transaction is not None and transaction.manages(path) + + @staticmethod + def _transaction_text(path: Path) -> str | None: + from cisco_sccfm_scripts import setup_tokens + + transaction = setup_tokens._active_credential_transaction + if transaction is None or not transaction.manages(path): + return None + content = transaction.read_bytes(path) + return None if content is None else content.decode("utf-8") diff --git a/cisco_sccfm_scripts/verify_pypi_release.py b/cisco_sccfm_scripts/verify_pypi_release.py index 46f343b5..d44c2d84 100644 --- a/cisco_sccfm_scripts/verify_pypi_release.py +++ b/cisco_sccfm_scripts/verify_pypi_release.py @@ -53,6 +53,7 @@ class PyPIReleaseVerification: version: str file_count: int status: PyPIReleaseStatus + missing_filenames: tuple[str, ...] = () def _python_artifact_names(version: str) -> tuple[str, str]: @@ -201,6 +202,7 @@ def verify_pypi_release( version=version, file_count=len(remote_hashes), status=status, + missing_filenames=tuple(sorted(set(local_hashes) - set(remote_hashes))), ) @@ -233,7 +235,8 @@ def main(argv: Sequence[str] | None = None) -> int: if result.status is PyPIReleaseStatus.PARTIAL: print( - f"PyPI release partially published: version={result.version} files={result.file_count}" + f"PyPI release partially published: version={result.version} files={result.file_count} " + f"missing={','.join(result.missing_filenames)}" ) return 3 print(f"PyPI release verified: version={result.version} files={result.file_count}") diff --git a/docs/ansible/modules/add_asa_shun.md b/docs/ansible/modules/add_asa_shun.md index e5b34173..261be190 100644 --- a/docs/ansible/modules/add_asa_shun.md +++ b/docs/ansible/modules/add_asa_shun.md @@ -135,8 +135,8 @@ EXAMPLES: cisco.sccfm.add_asa_shun: query: "name:prod-* AND connectivityState:ONLINE" source_ip: "10.99.99.99" - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" # Example 2: Shun with connection tuple to drop an existing connection - name: Block attacker and drop active connection @@ -161,8 +161,8 @@ EXAMPLES: dest_port: 443 protocol: tcp - source_ip: "203.0.113.60" - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" # Example 4: Using module_defaults (recommended) - name: Add shun entries @@ -170,8 +170,8 @@ EXAMPLES: gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: Shun attacker IP cisco.sccfm.add_asa_shun: diff --git a/docs/ansible/modules/add_network_group_members.md b/docs/ansible/modules/add_network_group_members.md index efaa18b3..6f716e51 100644 --- a/docs/ansible/modules/add_network_group_members.md +++ b/docs/ansible/modules/add_network_group_members.md @@ -56,8 +56,8 @@ EXAMPLES: referenced_objects: - web-server-01 - web-server-02 - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" # Example 2: Add members by UID - name: Add members to a network group by UID @@ -73,8 +73,8 @@ EXAMPLES: gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: Add web servers to group cisco.sccfm.add_network_group_members: diff --git a/docs/ansible/modules/add_object_override.md b/docs/ansible/modules/add_object_override.md index c6846440..bf722ae5 100644 --- a/docs/ansible/modules/add_object_override.md +++ b/docs/ansible/modules/add_object_override.md @@ -56,8 +56,8 @@ EXAMPLES: uid: "abc-123-def" target_id: "70bde3c9-328c-4a4b-bdc9-a4d4042bf09a" override_value: "10.10.10.10" - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" # Example 2: Using module_defaults to avoid repeating credentials - name: Add object overrides @@ -65,7 +65,7 @@ EXAMPLES: gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: Override web server IP for branch device diff --git a/docs/ansible/modules/apply_object_override_as_default.md b/docs/ansible/modules/apply_object_override_as_default.md index 1b3d0dc6..7d1e9fcf 100644 --- a/docs/ansible/modules/apply_object_override_as_default.md +++ b/docs/ansible/modules/apply_object_override_as_default.md @@ -44,8 +44,8 @@ EXAMPLES: cisco.sccfm.apply_object_override_as_default: uid: "abc-123-def" target_id: "897b293f-132e-4678-9d78-0f0947629500" - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" # Example 2: Using module_defaults - name: Apply object override as default @@ -53,7 +53,7 @@ EXAMPLES: gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: Apply override as default diff --git a/docs/ansible/modules/asa_ha_check.md b/docs/ansible/modules/asa_ha_check.md index d3f1edc4..e4cc98ae 100644 --- a/docs/ansible/modules/asa_ha_check.md +++ b/docs/ansible/modules/asa_ha_check.md @@ -61,8 +61,8 @@ EXAMPLES: - name: Run HA checks on production ASAs cisco.sccfm.asa_ha_check: query: "name:prod-ha-* AND connectivityState:ONLINE" - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" register: ha_results # Example 2: Check HA status on a specific device by UID @@ -89,8 +89,8 @@ EXAMPLES: gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: Run HA checks cisco.sccfm.asa_ha_check: diff --git a/docs/ansible/modules/change_asa_boot_image.md b/docs/ansible/modules/change_asa_boot_image.md index 40bda3de..b299ece3 100644 --- a/docs/ansible/modules/change_asa_boot_image.md +++ b/docs/ansible/modules/change_asa_boot_image.md @@ -67,8 +67,8 @@ EXAMPLES: cisco.sccfm.change_asa_boot_image: query: "name:branch-*" image_path: "disk0:/asa9-18-4-smp-k8.bin" - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" # Example 2: Change boot image on specific devices - name: Change boot image on specific ASA devices @@ -93,8 +93,8 @@ EXAMPLES: gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: Set boot image on branch ASAs cisco.sccfm.change_asa_boot_image: diff --git a/docs/ansible/modules/change_asa_local_password.md b/docs/ansible/modules/change_asa_local_password.md index 890b480f..dd919045 100644 --- a/docs/ansible/modules/change_asa_local_password.md +++ b/docs/ansible/modules/change_asa_local_password.md @@ -72,8 +72,8 @@ EXAMPLES: query: "name:branch-* AND connectivityState:ONLINE" username: admin new_password: "{{ vault_new_asa_password }}" - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" register: password_results # Example 2: Change password on specific devices by UID @@ -92,8 +92,8 @@ EXAMPLES: gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: Change admin password on all online ASAs cisco.sccfm.change_asa_local_password: diff --git a/docs/ansible/modules/clear_asa_shun.md b/docs/ansible/modules/clear_asa_shun.md index 23cbb68b..89893b3a 100644 --- a/docs/ansible/modules/clear_asa_shun.md +++ b/docs/ansible/modules/clear_asa_shun.md @@ -61,8 +61,8 @@ EXAMPLES: - name: Clear all shuns on production ASAs cisco.sccfm.clear_asa_shun: query: "name:prod-* AND connectivityState:ONLINE" - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" # Example 2: Clear shuns on specific devices by UID - name: Clear shuns on specific ASA @@ -76,8 +76,8 @@ EXAMPLES: gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: Clear all shuns on online ASAs cisco.sccfm.clear_asa_shun: diff --git a/docs/ansible/modules/configure_manager.md b/docs/ansible/modules/configure_manager.md index 5c53ed7c..4e29bcfc 100644 --- a/docs/ansible/modules/configure_manager.md +++ b/docs/ansible/modules/configure_manager.md @@ -108,8 +108,8 @@ EXAMPLES: fmc_access_policy_uid: "{{ fmc_access_policy_uid }}" licenses: - BASE - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" register: onboard_result - name: Complete registration over SSH diff --git a/docs/ansible/modules/create_access_rule.md b/docs/ansible/modules/create_access_rule.md index 03cb2319..4760ad9d 100644 --- a/docs/ansible/modules/create_access_rule.md +++ b/docs/ansible/modules/create_access_rule.md @@ -94,8 +94,8 @@ EXAMPLES: protocol: tcp destination_port: "443" remark: "Allow web to database" - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" # Example 2: Create a deny rule using module_defaults - name: Create access rules @@ -103,7 +103,7 @@ EXAMPLES: gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: Create a deny rule for a source subnet diff --git a/docs/ansible/modules/create_network_group.md b/docs/ansible/modules/create_network_group.md index 9583ab34..dba6cadc 100644 --- a/docs/ansible/modules/create_network_group.md +++ b/docs/ansible/modules/create_network_group.md @@ -81,8 +81,8 @@ EXAMPLES: labels: - production - web - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" # Example 2: Create a group with referenced objects using module_defaults - name: Create network groups @@ -90,7 +90,7 @@ EXAMPLES: gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: Create group from existing objects diff --git a/docs/ansible/modules/create_network_object.md b/docs/ansible/modules/create_network_object.md index 197dc550..7bafff0b 100644 --- a/docs/ansible/modules/create_network_object.md +++ b/docs/ansible/modules/create_network_object.md @@ -62,8 +62,8 @@ EXAMPLES: labels: - production - web - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" # Example 2: Create a subnet network object using module_defaults - name: Create network objects @@ -71,7 +71,7 @@ EXAMPLES: gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: Create branch office subnet diff --git a/docs/ansible/modules/delete_access_rule.md b/docs/ansible/modules/delete_access_rule.md index 19c0d257..abc72579 100644 --- a/docs/ansible/modules/delete_access_rule.md +++ b/docs/ansible/modules/delete_access_rule.md @@ -37,8 +37,8 @@ EXAMPLES: - name: Delete access rule cisco.sccfm.delete_access_rule: uid: "ac981dcd-9860-401e-a51d-c615c946b72f" - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" # Example 2: Delete using module_defaults - name: Delete access rules @@ -46,7 +46,7 @@ EXAMPLES: gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: Delete old rule diff --git a/docs/ansible/modules/delete_network_group.md b/docs/ansible/modules/delete_network_group.md index 6cf859e0..0c984d9d 100644 --- a/docs/ansible/modules/delete_network_group.md +++ b/docs/ansible/modules/delete_network_group.md @@ -51,15 +51,15 @@ EXAMPLES: - name: Delete network group by UID cisco.sccfm.delete_network_group: uid: "abc-123-def-456" - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" # Example 2: Delete a network group by name - name: Delete network group by name cisco.sccfm.delete_network_group: name: "web-server-group" - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" # Example 3: Delete multiple groups using module_defaults - name: Delete network groups @@ -67,7 +67,7 @@ EXAMPLES: gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: Delete obsolete network groups diff --git a/docs/ansible/modules/delete_network_object.md b/docs/ansible/modules/delete_network_object.md index 1ccb3f1f..8b67c510 100644 --- a/docs/ansible/modules/delete_network_object.md +++ b/docs/ansible/modules/delete_network_object.md @@ -46,15 +46,15 @@ EXAMPLES: - name: Delete network object by UID cisco.sccfm.delete_network_object: uid: "abc-123-def-456" - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" # Example 2: Delete a network object by name - name: Delete network object by name cisco.sccfm.delete_network_object: name: "old-web-server" - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" # Example 3: Delete multiple objects using module_defaults - name: Delete network objects @@ -62,7 +62,7 @@ EXAMPLES: gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: Delete obsolete network objects diff --git a/docs/ansible/modules/delete_object_override.md b/docs/ansible/modules/delete_object_override.md index 6e7e817a..0231551f 100644 --- a/docs/ansible/modules/delete_object_override.md +++ b/docs/ansible/modules/delete_object_override.md @@ -43,8 +43,8 @@ EXAMPLES: cisco.sccfm.delete_object_override: uid: "abc-123-def" target_id: "70bde3c9-328c-4a4b-bdc9-a4d4042bf09a" - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" # Example 2: Using module_defaults - name: Delete object overrides @@ -52,7 +52,7 @@ EXAMPLES: gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: Delete override diff --git a/docs/ansible/modules/deploy_cdfmc_ftd.md b/docs/ansible/modules/deploy_cdfmc_ftd.md index af910dae..193bc321 100644 --- a/docs/ansible/modules/deploy_cdfmc_ftd.md +++ b/docs/ansible/modules/deploy_cdfmc_ftd.md @@ -82,8 +82,8 @@ EXAMPLES: cisco.sccfm.deploy_cdfmc_ftd: uids: - "12345678-1234-1234-1234-123456789abc" - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" # Example 2: Deploy with notes - name: Deploy FTD changes with deployment notes @@ -107,8 +107,8 @@ EXAMPLES: gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: Deploy branch FTD changes cisco.sccfm.deploy_cdfmc_ftd: diff --git a/docs/ansible/modules/edit_object_override.md b/docs/ansible/modules/edit_object_override.md index 76034a88..23bf20dc 100644 --- a/docs/ansible/modules/edit_object_override.md +++ b/docs/ansible/modules/edit_object_override.md @@ -50,8 +50,8 @@ EXAMPLES: uid: "abc-123-def" target_id: "70bde3c9-328c-4a4b-bdc9-a4d4042bf09a" override_value: "10.20.30.40" - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" # Example 2: Using module_defaults - name: Edit object overrides @@ -59,7 +59,7 @@ EXAMPLES: gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: Edit override diff --git a/docs/ansible/modules/execute_asa_cli.md b/docs/ansible/modules/execute_asa_cli.md index 7b2e8580..1ec5920f 100644 --- a/docs/ansible/modules/execute_asa_cli.md +++ b/docs/ansible/modules/execute_asa_cli.md @@ -77,8 +77,8 @@ EXAMPLES: commands: - "show version" - "show running-config" - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" register: cli_results # Example 2: Execute commands on specific devices by UID @@ -97,8 +97,8 @@ EXAMPLES: gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: Show version on branch ASAs cisco.sccfm.execute_asa_cli: diff --git a/docs/ansible/modules/execute_ftd_cli.md b/docs/ansible/modules/execute_ftd_cli.md index 86ca49af..197188b6 100644 --- a/docs/ansible/modules/execute_ftd_cli.md +++ b/docs/ansible/modules/execute_ftd_cli.md @@ -79,8 +79,8 @@ EXAMPLES: cisco.sccfm.execute_ftd_cli: query: "name:prod-* AND connectivityState:ONLINE" command: "show failover" - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" register: cli_results # Example 2: Execute a command on specific devices by UID @@ -98,8 +98,8 @@ EXAMPLES: gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: Show route on branch FTDs cisco.sccfm.execute_ftd_cli: diff --git a/docs/ansible/modules/get_access_group.md b/docs/ansible/modules/get_access_group.md index 90b739aa..2b87ecb4 100644 --- a/docs/ansible/modules/get_access_group.md +++ b/docs/ansible/modules/get_access_group.md @@ -35,8 +35,8 @@ EXAMPLES: - name: Get access group cisco.sccfm.get_access_group: uid: "c6fa254e-db7a-447e-a58f-95df1e09c2af" - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" register: result - name: Show access group name @@ -49,8 +49,8 @@ EXAMPLES: gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: Get access group cisco.sccfm.get_access_group: diff --git a/docs/ansible/modules/get_access_rule.md b/docs/ansible/modules/get_access_rule.md index f673b9ab..9a477de5 100644 --- a/docs/ansible/modules/get_access_rule.md +++ b/docs/ansible/modules/get_access_rule.md @@ -35,8 +35,8 @@ EXAMPLES: - name: Get access rule cisco.sccfm.get_access_rule: uid: "ac981dcd-9860-401e-a51d-c615c946b72f" - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" register: result - name: Show rule @@ -49,7 +49,7 @@ EXAMPLES: gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: Get access rule details diff --git a/docs/ansible/modules/get_object.md b/docs/ansible/modules/get_object.md index 1ed6e010..7c52616b 100644 --- a/docs/ansible/modules/get_object.md +++ b/docs/ansible/modules/get_object.md @@ -37,8 +37,8 @@ EXAMPLES: - name: Get object cisco.sccfm.get_object: uid: "fd526e22-12ff-4fa0-a88d-7375c5d1e144" - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" register: obj - name: Show object @@ -51,7 +51,7 @@ EXAMPLES: gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: Get object details diff --git a/docs/ansible/modules/list_access_groups.md b/docs/ansible/modules/list_access_groups.md index ec48ddb4..36e10485 100644 --- a/docs/ansible/modules/list_access_groups.md +++ b/docs/ansible/modules/list_access_groups.md @@ -45,8 +45,8 @@ EXAMPLES: # List all access groups - name: List access groups cisco.sccfm.list_access_groups: - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" register: result - name: Display access groups @@ -59,8 +59,8 @@ EXAMPLES: gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: List access groups cisco.sccfm.list_access_groups: diff --git a/docs/ansible/modules/list_access_rules.md b/docs/ansible/modules/list_access_rules.md index 4be78591..9dd5e32b 100644 --- a/docs/ansible/modules/list_access_rules.md +++ b/docs/ansible/modules/list_access_rules.md @@ -45,8 +45,8 @@ EXAMPLES: # Example 1: List all access rules - name: List all access rules cisco.sccfm.list_access_rules: - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" register: result - name: Display access rules @@ -59,7 +59,7 @@ EXAMPLES: gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: List first page of access rules diff --git a/docs/ansible/modules/list_asa_boot_registry.md b/docs/ansible/modules/list_asa_boot_registry.md index f2d9cea6..76c06d65 100644 --- a/docs/ansible/modules/list_asa_boot_registry.md +++ b/docs/ansible/modules/list_asa_boot_registry.md @@ -63,8 +63,8 @@ EXAMPLES: - name: List boot registry on production ASAs cisco.sccfm.list_asa_boot_registry: query: "name:prod-* AND connectivityState:ONLINE" - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" register: boot_registry # Example 2: Get boot registry info for specific devices by UID @@ -81,8 +81,8 @@ EXAMPLES: gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: List boot registry on branch ASAs cisco.sccfm.list_asa_boot_registry: diff --git a/docs/ansible/modules/list_asa_compatible_versions.md b/docs/ansible/modules/list_asa_compatible_versions.md index 9aaacaa0..4c22449c 100644 --- a/docs/ansible/modules/list_asa_compatible_versions.md +++ b/docs/ansible/modules/list_asa_compatible_versions.md @@ -74,8 +74,8 @@ EXAMPLES: cisco.sccfm.list_asa_compatible_versions: uids: - "12345678-1234-1234-1234-123456789abc" - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" register: compat_versions - name: Show compatible versions @@ -114,8 +114,8 @@ EXAMPLES: gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: Get compatible versions for branch ASAs cisco.sccfm.list_asa_compatible_versions: diff --git a/docs/ansible/modules/list_asa_disk_files.md b/docs/ansible/modules/list_asa_disk_files.md index d6265df4..e236234b 100644 --- a/docs/ansible/modules/list_asa_disk_files.md +++ b/docs/ansible/modules/list_asa_disk_files.md @@ -63,8 +63,8 @@ EXAMPLES: - name: List disk files on production ASAs cisco.sccfm.list_asa_disk_files: query: "name:prod-* AND connectivityState:ONLINE" - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" register: disk_files # Example 2: List files on specific devices by UID @@ -81,8 +81,8 @@ EXAMPLES: gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: List files on branch ASAs cisco.sccfm.list_asa_disk_files: diff --git a/docs/ansible/modules/list_asa_local_users.md b/docs/ansible/modules/list_asa_local_users.md index 14948bf8..caddc17a 100644 --- a/docs/ansible/modules/list_asa_local_users.md +++ b/docs/ansible/modules/list_asa_local_users.md @@ -60,15 +60,15 @@ EXAMPLES: cisco.sccfm.list_asa_local_users: query: "name:branch-* AND connectivityState:ONLINE" region: "us" - api_token: "{{ sccfm_api_token }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" - name: List local users with shared auth hosts: localhost gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: List local users on branch ASAs cisco.sccfm.list_asa_local_users: diff --git a/docs/ansible/modules/list_asa_not_on_version.md b/docs/ansible/modules/list_asa_not_on_version.md index 9cdcdc66..bbc370ed 100644 --- a/docs/ansible/modules/list_asa_not_on_version.md +++ b/docs/ansible/modules/list_asa_not_on_version.md @@ -70,8 +70,8 @@ EXAMPLES: - name: Find ASAs not on 9.20(3)13 cisco.sccfm.list_asa_not_on_version: version: "9.20(3)13" - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" register: result - name: Show devices that need upgrading @@ -101,8 +101,8 @@ EXAMPLES: gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: Find ASAs not on target version cisco.sccfm.list_asa_not_on_version: diff --git a/docs/ansible/modules/list_cdfmc_access_policies.md b/docs/ansible/modules/list_cdfmc_access_policies.md index d216bb75..698148e9 100644 --- a/docs/ansible/modules/list_cdfmc_access_policies.md +++ b/docs/ansible/modules/list_cdfmc_access_policies.md @@ -48,8 +48,8 @@ EXAMPLES: - name: List cdFMC access policies cisco.sccfm.list_cdfmc_access_policies: domain_uid: "e276abec-e0f2-11e3-8169-6d9ed49b625f" - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" register: result - name: Show access policies @@ -62,8 +62,8 @@ EXAMPLES: gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: List access policies for a domain cisco.sccfm.list_cdfmc_access_policies: diff --git a/docs/ansible/modules/list_ftd_compatible_versions.md b/docs/ansible/modules/list_ftd_compatible_versions.md index dcbd6c9d..567eb6dc 100644 --- a/docs/ansible/modules/list_ftd_compatible_versions.md +++ b/docs/ansible/modules/list_ftd_compatible_versions.md @@ -71,8 +71,8 @@ EXAMPLES: cisco.sccfm.list_ftd_compatible_versions: uids: - "12345678-1234-1234-1234-123456789abc" - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" register: compat_versions - name: Show compatible versions @@ -111,8 +111,8 @@ EXAMPLES: gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: Get compatible versions for branch FTDs cisco.sccfm.list_ftd_compatible_versions: diff --git a/docs/ansible/modules/list_ftd_not_on_version.md b/docs/ansible/modules/list_ftd_not_on_version.md index 743568c5..776295dd 100644 --- a/docs/ansible/modules/list_ftd_not_on_version.md +++ b/docs/ansible/modules/list_ftd_not_on_version.md @@ -84,8 +84,8 @@ EXAMPLES: - name: Find FTDs not on 7.4.1 cisco.sccfm.list_ftd_not_on_version: version: "7.4.1" - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" register: result - name: Show devices that need upgrading @@ -117,8 +117,8 @@ EXAMPLES: gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: Find FTDs not on target version cisco.sccfm.list_ftd_not_on_version: diff --git a/docs/ansible/modules/list_managers.md b/docs/ansible/modules/list_managers.md index e61147b8..42ea8eef 100644 --- a/docs/ansible/modules/list_managers.md +++ b/docs/ansible/modules/list_managers.md @@ -47,8 +47,8 @@ EXAMPLES: # Example 1: List all managers - name: List all managers cisco.sccfm.list_managers: - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" register: result - name: Show managers @@ -71,8 +71,8 @@ EXAMPLES: gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: List all managers cisco.sccfm.list_managers: diff --git a/docs/ansible/modules/list_network_groups.md b/docs/ansible/modules/list_network_groups.md index 8be047c7..7750ddf5 100644 --- a/docs/ansible/modules/list_network_groups.md +++ b/docs/ansible/modules/list_network_groups.md @@ -49,8 +49,8 @@ EXAMPLES: # Example 1: List all network groups - name: List all network groups cisco.sccfm.list_network_groups: - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" register: result - name: Display network groups @@ -63,7 +63,7 @@ EXAMPLES: gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: Find web-related network groups diff --git a/docs/ansible/modules/list_network_objects.md b/docs/ansible/modules/list_network_objects.md index d81dede9..b76d4402 100644 --- a/docs/ansible/modules/list_network_objects.md +++ b/docs/ansible/modules/list_network_objects.md @@ -49,8 +49,8 @@ EXAMPLES: # Example 1: List all network objects - name: List all network objects cisco.sccfm.list_network_objects: - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" register: result - name: Display network objects @@ -63,7 +63,7 @@ EXAMPLES: gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: Find web-related network objects diff --git a/docs/ansible/modules/onboard_asa.md b/docs/ansible/modules/onboard_asa.md index 455c70f0..199ace3f 100644 --- a/docs/ansible/modules/onboard_asa.md +++ b/docs/ansible/modules/onboard_asa.md @@ -68,7 +68,7 @@ EXAMPLES: hosts: all module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: Onboard branch-asa-1 diff --git a/docs/ansible/modules/onboard_cdfmc_ftd.md b/docs/ansible/modules/onboard_cdfmc_ftd.md index 80424191..1eef2062 100644 --- a/docs/ansible/modules/onboard_cdfmc_ftd.md +++ b/docs/ansible/modules/onboard_cdfmc_ftd.md @@ -72,8 +72,8 @@ EXAMPLES: fmc_access_policy_uid: "your-access-policy-uid" licenses: - BASE - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" # Example 2: Onboard a virtual FTD with multiple licenses - name: Onboard virtual FTD @@ -105,8 +105,8 @@ EXAMPLES: gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: Onboard branch FTD cisco.sccfm.onboard_cdfmc_ftd: diff --git a/docs/ansible/modules/onboard_cdfmc_ftd_ztp.md b/docs/ansible/modules/onboard_cdfmc_ftd_ztp.md index 982b4830..de911cf1 100644 --- a/docs/ansible/modules/onboard_cdfmc_ftd_ztp.md +++ b/docs/ansible/modules/onboard_cdfmc_ftd_ztp.md @@ -76,8 +76,8 @@ EXAMPLES: licenses: - BASE fmc_access_policy_uid: "your-access-policy-uid" - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" # Example 2: Onboard with initial password and device group - name: Onboard FTD via ZTP with password @@ -97,8 +97,8 @@ EXAMPLES: gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: Onboard branch FTD through ZTP cisco.sccfm.onboard_cdfmc_ftd_ztp: diff --git a/docs/ansible/modules/register_cdfmc_ftd.md b/docs/ansible/modules/register_cdfmc_ftd.md index b81e447b..1ef2cda3 100644 --- a/docs/ansible/modules/register_cdfmc_ftd.md +++ b/docs/ansible/modules/register_cdfmc_ftd.md @@ -46,8 +46,8 @@ EXAMPLES: gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: Complete the FTD registration cisco.sccfm.register_cdfmc_ftd: diff --git a/docs/ansible/modules/remove_asa_shun.md b/docs/ansible/modules/remove_asa_shun.md index d286c91a..e8c1bbc7 100644 --- a/docs/ansible/modules/remove_asa_shun.md +++ b/docs/ansible/modules/remove_asa_shun.md @@ -78,8 +78,8 @@ EXAMPLES: cisco.sccfm.remove_asa_shun: query: "name:prod-* AND connectivityState:ONLINE" source_ip: "10.99.99.99" - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" # Example 2: Remove multiple shuns in a single transaction - name: Remove multiple attacker IPs in one call @@ -89,8 +89,8 @@ EXAMPLES: - "203.0.113.40" - "203.0.113.50" - "203.0.113.60" - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" # Example 3: Remove a shun on specific devices by UID - name: Remove shun on specific ASA @@ -105,8 +105,8 @@ EXAMPLES: gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: Remove shun for attacker cisco.sccfm.remove_asa_shun: diff --git a/docs/ansible/modules/remove_network_group_members.md b/docs/ansible/modules/remove_network_group_members.md index 84ec0e70..2acc9c92 100644 --- a/docs/ansible/modules/remove_network_group_members.md +++ b/docs/ansible/modules/remove_network_group_members.md @@ -56,8 +56,8 @@ EXAMPLES: referenced_objects: - web-server-01 - web-server-02 - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" # Example 2: Remove members by UID - name: Remove members from a network group by UID @@ -73,8 +73,8 @@ EXAMPLES: gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: Remove old web servers from group cisco.sccfm.remove_network_group_members: diff --git a/docs/ansible/modules/show_asa_shun.md b/docs/ansible/modules/show_asa_shun.md index 68a5202a..4e58c1ce 100644 --- a/docs/ansible/modules/show_asa_shun.md +++ b/docs/ansible/modules/show_asa_shun.md @@ -69,8 +69,8 @@ EXAMPLES: - name: Show shun entries on production ASAs cisco.sccfm.show_asa_shun: query: "name:prod-* AND connectivityState:ONLINE" - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" register: shun_entries # Example 2: Show shun entries on specific devices by UID @@ -93,8 +93,8 @@ EXAMPLES: gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: Show shun entries cisco.sccfm.show_asa_shun: diff --git a/docs/ansible/modules/trigger_asa_upgrade.md b/docs/ansible/modules/trigger_asa_upgrade.md index 9342366f..3a6f141e 100644 --- a/docs/ansible/modules/trigger_asa_upgrade.md +++ b/docs/ansible/modules/trigger_asa_upgrade.md @@ -109,8 +109,8 @@ EXAMPLES: - "12345678-1234-1234-1234-123456789abc" software_version: "9.18(4)" asdm_version: "7.18(1.152)" - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" # Example 2: Stage-only upgrade using a query - name: Stage ASA upgrade for branch devices @@ -142,8 +142,8 @@ EXAMPLES: gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: Stage ASA upgrade for branch devices cisco.sccfm.trigger_asa_upgrade: diff --git a/docs/ansible/modules/trigger_ftd_upgrade.md b/docs/ansible/modules/trigger_ftd_upgrade.md index 40ad1aa4..86034dee 100644 --- a/docs/ansible/modules/trigger_ftd_upgrade.md +++ b/docs/ansible/modules/trigger_ftd_upgrade.md @@ -98,8 +98,8 @@ EXAMPLES: uids: - "12345678-1234-1234-1234-123456789abc" software_version: "7.4.1" - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" # Example 2: Stage-only upgrade using a query - name: Stage FTD upgrade for branch devices @@ -123,8 +123,8 @@ EXAMPLES: gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: Stage FTD upgrade for branch devices cisco.sccfm.trigger_ftd_upgrade: diff --git a/docs/ansible/modules/update_access_rule.md b/docs/ansible/modules/update_access_rule.md index 5e17aa57..45b3f378 100644 --- a/docs/ansible/modules/update_access_rule.md +++ b/docs/ansible/modules/update_access_rule.md @@ -84,8 +84,8 @@ EXAMPLES: cisco.sccfm.update_access_rule: uid: "ac981dcd-9860-401e-a51d-c615c946b72f" rule_action: DENY - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" # Example 2: Update remark and networks using module_defaults - name: Update access rules @@ -93,7 +93,7 @@ EXAMPLES: gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: Update rule remark and source diff --git a/docs/ansible/modules/update_network_group.md b/docs/ansible/modules/update_network_group.md index 8af937b6..7fe1676e 100644 --- a/docs/ansible/modules/update_network_group.md +++ b/docs/ansible/modules/update_network_group.md @@ -78,8 +78,8 @@ EXAMPLES: referenced_objects: - web-server-01 - web-server-02 - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" # Example 2: Rename a group and update description using module_defaults - name: Update network groups @@ -87,7 +87,7 @@ EXAMPLES: gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: Rename and update group diff --git a/docs/ansible/modules/update_network_object.md b/docs/ansible/modules/update_network_object.md index fcd49b09..d978ccfe 100644 --- a/docs/ansible/modules/update_network_object.md +++ b/docs/ansible/modules/update_network_object.md @@ -73,16 +73,16 @@ EXAMPLES: cisco.sccfm.update_network_object: uid: "abc-123-def" value: "192.168.1.0/24" - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" # Example 2: Rename a network object by name - name: Rename a network object cisco.sccfm.update_network_object: name: old-object-name new_name: new-object-name - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" # Example 3: Update multiple fields using module_defaults - name: Update network objects @@ -90,7 +90,7 @@ EXAMPLES: gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: Update web server object diff --git a/docs/ansible/modules/update_object_default.md b/docs/ansible/modules/update_object_default.md index 6282c0df..c9541902 100644 --- a/docs/ansible/modules/update_object_default.md +++ b/docs/ansible/modules/update_object_default.md @@ -46,8 +46,8 @@ EXAMPLES: cisco.sccfm.update_object_default: uid: "abc-123-def" value: "10.10.10.10" - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" # Example 2: Using module_defaults to avoid repeating credentials - name: Update object default values @@ -55,7 +55,7 @@ EXAMPLES: gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: Update default value @@ -74,8 +74,8 @@ EXAMPLES: gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: Update shared default value cisco.sccfm.update_object_default: diff --git a/docs/cli/sccfm-cli-inventory-devices-cdfmc-managed-ftd-configure-manager.md b/docs/cli/sccfm-cli-inventory-devices-cdfmc-managed-ftd-configure-manager.md index 75efd178..f472f41c 100644 --- a/docs/cli/sccfm-cli-inventory-devices-cdfmc-managed-ftd-configure-manager.md +++ b/docs/cli/sccfm-cli-inventory-devices-cdfmc-managed-ftd-configure-manager.md @@ -27,7 +27,9 @@ Options: --ftd-password TEXT SSH password for the FTD VM (or set SCCFM_FTD_PASSWORD; prompted if needed). --cli-key TEXT The full 'configure manager add ...' string - returned by 'onboard'. [required] + returned by 'onboard' (or set SCCFM_CLI_KEY). + Required unless --check is set. [env var: + SCCFM_CLI_KEY] --jump-host TEXT Optional bastion to tunnel through, as [user@]host[:port]. The FTD then sees the connection from the jump host's IP, so that IP diff --git a/docs/cli/sccfm-cli-inventory-devices-cdfmc-managed-ftd-onboard-ztp.md b/docs/cli/sccfm-cli-inventory-devices-cdfmc-managed-ftd-onboard-ztp.md index c684f7ee..1e92c3b2 100644 --- a/docs/cli/sccfm-cli-inventory-devices-cdfmc-managed-ftd-onboard-ztp.md +++ b/docs/cli/sccfm-cli-inventory-devices-cdfmc-managed-ftd-onboard-ztp.md @@ -30,7 +30,10 @@ Options: device. [required] --admin-password TEXT Initial provisioning password for the device. Required for setup if a password has not - already been set on the device. + already been set on the device. For secure + non-interactive use, set + SCCFM_FTD_ADMIN_PASSWORD. [env var: + SCCFM_FTD_ADMIN_PASSWORD] --device-group-uid TEXT UUID of the device group to assign this device to after registration. --check Run a preflight check without onboarding. diff --git a/docs/man/man1/sccfm-cli-configure.1 b/docs/man/man1/sccfm-cli-configure.1 index b8e3e92a..e04eade1 100644 --- a/docs/man/man1/sccfm-cli-configure.1 +++ b/docs/man/man1/sccfm-cli-configure.1 @@ -1,4 +1,4 @@ -.TH "SCCFM-CLI CONFIGURE" "1" "2026-07-27" "0.38.0" "sccfm-cli configure Manual" +.TH "SCCFM-CLI CONFIGURE" "1" "2026-08-11" "0.38.0" "sccfm-cli configure Manual" .SH NAME sccfm-cli\-configure \- Configure API connectivity. .SH SYNOPSIS diff --git a/docs/man/man1/sccfm-cli-inventory-devices-asa-change-boot-image.1 b/docs/man/man1/sccfm-cli-inventory-devices-asa-change-boot-image.1 index 418671e0..e15b683b 100644 --- a/docs/man/man1/sccfm-cli-inventory-devices-asa-change-boot-image.1 +++ b/docs/man/man1/sccfm-cli-inventory-devices-asa-change-boot-image.1 @@ -1,4 +1,4 @@ -.TH "SCCFM-CLI INVENTORY DEVICES ASA CHANGE-BOOT-IMAGE" "1" "2026-07-27" "0.38.0" "sccfm-cli inventory devices asa change-boot-image Manual" +.TH "SCCFM-CLI INVENTORY DEVICES ASA CHANGE-BOOT-IMAGE" "1" "2026-08-11" "0.38.0" "sccfm-cli inventory devices asa change-boot-image Manual" .SH NAME sccfm-cli\-inventory\-devices\-asa\-change-boot-image \- Change the configured ASA boot image for... .SH SYNOPSIS diff --git a/docs/man/man1/sccfm-cli-inventory-devices-asa-cli-execute.1 b/docs/man/man1/sccfm-cli-inventory-devices-asa-cli-execute.1 index 92bfc4e1..66d4937a 100644 --- a/docs/man/man1/sccfm-cli-inventory-devices-asa-cli-execute.1 +++ b/docs/man/man1/sccfm-cli-inventory-devices-asa-cli-execute.1 @@ -1,4 +1,4 @@ -.TH "SCCFM-CLI INVENTORY DEVICES ASA CLI EXECUTE" "1" "2026-07-27" "0.38.0" "sccfm-cli inventory devices asa cli execute Manual" +.TH "SCCFM-CLI INVENTORY DEVICES ASA CLI EXECUTE" "1" "2026-08-11" "0.38.0" "sccfm-cli inventory devices asa cli execute Manual" .SH NAME sccfm-cli\-inventory\-devices\-asa\-cli\-execute \- Execute CLI commands on ASA devices. .SH SYNOPSIS diff --git a/docs/man/man1/sccfm-cli-inventory-devices-asa-cli.1 b/docs/man/man1/sccfm-cli-inventory-devices-asa-cli.1 index 176def8d..aee31b70 100644 --- a/docs/man/man1/sccfm-cli-inventory-devices-asa-cli.1 +++ b/docs/man/man1/sccfm-cli-inventory-devices-asa-cli.1 @@ -1,4 +1,4 @@ -.TH "SCCFM-CLI INVENTORY DEVICES ASA CLI" "1" "2026-07-27" "0.38.0" "sccfm-cli inventory devices asa cli Manual" +.TH "SCCFM-CLI INVENTORY DEVICES ASA CLI" "1" "2026-08-11" "0.38.0" "sccfm-cli inventory devices asa cli Manual" .SH NAME sccfm-cli\-inventory\-devices\-asa\-cli \- ASA device CLI operations. .SH SYNOPSIS diff --git a/docs/man/man1/sccfm-cli-inventory-devices-asa-disk-list-files.1 b/docs/man/man1/sccfm-cli-inventory-devices-asa-disk-list-files.1 index cef1d7d7..65da6569 100644 --- a/docs/man/man1/sccfm-cli-inventory-devices-asa-disk-list-files.1 +++ b/docs/man/man1/sccfm-cli-inventory-devices-asa-disk-list-files.1 @@ -1,4 +1,4 @@ -.TH "SCCFM-CLI INVENTORY DEVICES ASA DISK LIST-FILES" "1" "2026-07-27" "0.38.0" "sccfm-cli inventory devices asa disk list-files Manual" +.TH "SCCFM-CLI INVENTORY DEVICES ASA DISK LIST-FILES" "1" "2026-08-11" "0.38.0" "sccfm-cli inventory devices asa disk list-files Manual" .SH NAME sccfm-cli\-inventory\-devices\-asa\-disk\-list-files \- List OS, AnyConnect, and ASDM files on ASA... .SH SYNOPSIS diff --git a/docs/man/man1/sccfm-cli-inventory-devices-asa-disk.1 b/docs/man/man1/sccfm-cli-inventory-devices-asa-disk.1 index edbfc923..ea7059ea 100644 --- a/docs/man/man1/sccfm-cli-inventory-devices-asa-disk.1 +++ b/docs/man/man1/sccfm-cli-inventory-devices-asa-disk.1 @@ -1,4 +1,4 @@ -.TH "SCCFM-CLI INVENTORY DEVICES ASA DISK" "1" "2026-07-27" "0.38.0" "sccfm-cli inventory devices asa disk Manual" +.TH "SCCFM-CLI INVENTORY DEVICES ASA DISK" "1" "2026-08-11" "0.38.0" "sccfm-cli inventory devices asa disk Manual" .SH NAME sccfm-cli\-inventory\-devices\-asa\-disk \- ASA device disk operations. .SH SYNOPSIS diff --git a/docs/man/man1/sccfm-cli-inventory-devices-asa-ha-check.1 b/docs/man/man1/sccfm-cli-inventory-devices-asa-ha-check.1 index 50332f0d..8e25c9c6 100644 --- a/docs/man/man1/sccfm-cli-inventory-devices-asa-ha-check.1 +++ b/docs/man/man1/sccfm-cli-inventory-devices-asa-ha-check.1 @@ -1,4 +1,4 @@ -.TH "SCCFM-CLI INVENTORY DEVICES ASA HA-CHECK" "1" "2026-07-27" "0.38.0" "sccfm-cli inventory devices asa ha-check Manual" +.TH "SCCFM-CLI INVENTORY DEVICES ASA HA-CHECK" "1" "2026-08-11" "0.38.0" "sccfm-cli inventory devices asa ha-check Manual" .SH NAME sccfm-cli\-inventory\-devices\-asa\-ha-check \- Run HA health checks on ASA failover pairs. .SH SYNOPSIS diff --git a/docs/man/man1/sccfm-cli-inventory-devices-asa-list-boot-registry.1 b/docs/man/man1/sccfm-cli-inventory-devices-asa-list-boot-registry.1 index fa3ff3ba..aaf3ab0d 100644 --- a/docs/man/man1/sccfm-cli-inventory-devices-asa-list-boot-registry.1 +++ b/docs/man/man1/sccfm-cli-inventory-devices-asa-list-boot-registry.1 @@ -1,4 +1,4 @@ -.TH "SCCFM-CLI INVENTORY DEVICES ASA LIST-BOOT-REGISTRY" "1" "2026-07-27" "0.38.0" "sccfm-cli inventory devices asa list-boot-registry Manual" +.TH "SCCFM-CLI INVENTORY DEVICES ASA LIST-BOOT-REGISTRY" "1" "2026-08-11" "0.38.0" "sccfm-cli inventory devices asa list-boot-registry Manual" .SH NAME sccfm-cli\-inventory\-devices\-asa\-list-boot-registry \- Show boot registry info (system image,... .SH SYNOPSIS diff --git a/docs/man/man1/sccfm-cli-inventory-devices-asa-list-local-users.1 b/docs/man/man1/sccfm-cli-inventory-devices-asa-list-local-users.1 index 27f67c29..5b0dc276 100644 --- a/docs/man/man1/sccfm-cli-inventory-devices-asa-list-local-users.1 +++ b/docs/man/man1/sccfm-cli-inventory-devices-asa-list-local-users.1 @@ -1,4 +1,4 @@ -.TH "SCCFM-CLI INVENTORY DEVICES ASA LIST-LOCAL-USERS" "1" "2026-07-27" "0.38.0" "sccfm-cli inventory devices asa list-local-users Manual" +.TH "SCCFM-CLI INVENTORY DEVICES ASA LIST-LOCAL-USERS" "1" "2026-08-11" "0.38.0" "sccfm-cli inventory devices asa list-local-users Manual" .SH NAME sccfm-cli\-inventory\-devices\-asa\-list-local-users \- List local users on ASA devices. .SH SYNOPSIS diff --git a/docs/man/man1/sccfm-cli-inventory-devices-asa-list-not-on-version.1 b/docs/man/man1/sccfm-cli-inventory-devices-asa-list-not-on-version.1 index bc5f5d05..5085c5ad 100644 --- a/docs/man/man1/sccfm-cli-inventory-devices-asa-list-not-on-version.1 +++ b/docs/man/man1/sccfm-cli-inventory-devices-asa-list-not-on-version.1 @@ -1,4 +1,4 @@ -.TH "SCCFM-CLI INVENTORY DEVICES ASA LIST-NOT-ON-VERSION" "1" "2026-07-27" "0.38.0" "sccfm-cli inventory devices asa list-not-on-version Manual" +.TH "SCCFM-CLI INVENTORY DEVICES ASA LIST-NOT-ON-VERSION" "1" "2026-08-11" "0.38.0" "sccfm-cli inventory devices asa list-not-on-version Manual" .SH NAME sccfm-cli\-inventory\-devices\-asa\-list-not-on-version \- List ASA devices that are NOT running a... .SH SYNOPSIS diff --git a/docs/man/man1/sccfm-cli-inventory-devices-asa-list.1 b/docs/man/man1/sccfm-cli-inventory-devices-asa-list.1 index 389c5742..e7a96867 100644 --- a/docs/man/man1/sccfm-cli-inventory-devices-asa-list.1 +++ b/docs/man/man1/sccfm-cli-inventory-devices-asa-list.1 @@ -1,4 +1,4 @@ -.TH "SCCFM-CLI INVENTORY DEVICES ASA LIST" "1" "2026-07-27" "0.38.0" "sccfm-cli inventory devices asa list Manual" +.TH "SCCFM-CLI INVENTORY DEVICES ASA LIST" "1" "2026-08-11" "0.38.0" "sccfm-cli inventory devices asa list Manual" .SH NAME sccfm-cli\-inventory\-devices\-asa\-list \- List ASA devices. .SH SYNOPSIS diff --git a/docs/man/man1/sccfm-cli-inventory-devices-asa-onboard.1 b/docs/man/man1/sccfm-cli-inventory-devices-asa-onboard.1 index 9c164b48..36730013 100644 --- a/docs/man/man1/sccfm-cli-inventory-devices-asa-onboard.1 +++ b/docs/man/man1/sccfm-cli-inventory-devices-asa-onboard.1 @@ -1,4 +1,4 @@ -.TH "SCCFM-CLI INVENTORY DEVICES ASA ONBOARD" "1" "2026-07-27" "0.38.0" "sccfm-cli inventory devices asa onboard Manual" +.TH "SCCFM-CLI INVENTORY DEVICES ASA ONBOARD" "1" "2026-08-11" "0.38.0" "sccfm-cli inventory devices asa onboard Manual" .SH NAME sccfm-cli\-inventory\-devices\-asa\-onboard \- Onboard an ASA device to SCC Firewall... .SH SYNOPSIS diff --git a/docs/man/man1/sccfm-cli-inventory-devices-asa-shun-add.1 b/docs/man/man1/sccfm-cli-inventory-devices-asa-shun-add.1 index d05b042b..0b769156 100644 --- a/docs/man/man1/sccfm-cli-inventory-devices-asa-shun-add.1 +++ b/docs/man/man1/sccfm-cli-inventory-devices-asa-shun-add.1 @@ -1,4 +1,4 @@ -.TH "SCCFM-CLI INVENTORY DEVICES ASA SHUN ADD" "1" "2026-07-27" "0.38.0" "sccfm-cli inventory devices asa shun add Manual" +.TH "SCCFM-CLI INVENTORY DEVICES ASA SHUN ADD" "1" "2026-08-11" "0.38.0" "sccfm-cli inventory devices asa shun add Manual" .SH NAME sccfm-cli\-inventory\-devices\-asa\-shun\-add \- Shun (block) one or more source IP... .SH SYNOPSIS diff --git a/docs/man/man1/sccfm-cli-inventory-devices-asa-shun-clear.1 b/docs/man/man1/sccfm-cli-inventory-devices-asa-shun-clear.1 index 2d4ec8b7..c0f4619c 100644 --- a/docs/man/man1/sccfm-cli-inventory-devices-asa-shun-clear.1 +++ b/docs/man/man1/sccfm-cli-inventory-devices-asa-shun-clear.1 @@ -1,4 +1,4 @@ -.TH "SCCFM-CLI INVENTORY DEVICES ASA SHUN CLEAR" "1" "2026-07-27" "0.38.0" "sccfm-cli inventory devices asa shun clear Manual" +.TH "SCCFM-CLI INVENTORY DEVICES ASA SHUN CLEAR" "1" "2026-08-11" "0.38.0" "sccfm-cli inventory devices asa shun clear Manual" .SH NAME sccfm-cli\-inventory\-devices\-asa\-shun\-clear \- Disable all active shuns and clear shun... .SH SYNOPSIS diff --git a/docs/man/man1/sccfm-cli-inventory-devices-asa-shun-remove.1 b/docs/man/man1/sccfm-cli-inventory-devices-asa-shun-remove.1 index ffac4eb8..4bd86702 100644 --- a/docs/man/man1/sccfm-cli-inventory-devices-asa-shun-remove.1 +++ b/docs/man/man1/sccfm-cli-inventory-devices-asa-shun-remove.1 @@ -1,4 +1,4 @@ -.TH "SCCFM-CLI INVENTORY DEVICES ASA SHUN REMOVE" "1" "2026-07-27" "0.38.0" "sccfm-cli inventory devices asa shun remove Manual" +.TH "SCCFM-CLI INVENTORY DEVICES ASA SHUN REMOVE" "1" "2026-08-11" "0.38.0" "sccfm-cli inventory devices asa shun remove Manual" .SH NAME sccfm-cli\-inventory\-devices\-asa\-shun\-remove \- Remove one or more shun entries from ASA... .SH SYNOPSIS diff --git a/docs/man/man1/sccfm-cli-inventory-devices-asa-shun-show.1 b/docs/man/man1/sccfm-cli-inventory-devices-asa-shun-show.1 index 6549f69c..2030da56 100644 --- a/docs/man/man1/sccfm-cli-inventory-devices-asa-shun-show.1 +++ b/docs/man/man1/sccfm-cli-inventory-devices-asa-shun-show.1 @@ -1,4 +1,4 @@ -.TH "SCCFM-CLI INVENTORY DEVICES ASA SHUN SHOW" "1" "2026-07-27" "0.38.0" "sccfm-cli inventory devices asa shun show Manual" +.TH "SCCFM-CLI INVENTORY DEVICES ASA SHUN SHOW" "1" "2026-08-11" "0.38.0" "sccfm-cli inventory devices asa shun show Manual" .SH NAME sccfm-cli\-inventory\-devices\-asa\-shun\-show \- Display active shun entries on ASA devices. .SH SYNOPSIS diff --git a/docs/man/man1/sccfm-cli-inventory-devices-asa-shun.1 b/docs/man/man1/sccfm-cli-inventory-devices-asa-shun.1 index 23ae6e00..559c183d 100644 --- a/docs/man/man1/sccfm-cli-inventory-devices-asa-shun.1 +++ b/docs/man/man1/sccfm-cli-inventory-devices-asa-shun.1 @@ -1,4 +1,4 @@ -.TH "SCCFM-CLI INVENTORY DEVICES ASA SHUN" "1" "2026-07-27" "0.38.0" "sccfm-cli inventory devices asa shun Manual" +.TH "SCCFM-CLI INVENTORY DEVICES ASA SHUN" "1" "2026-08-11" "0.38.0" "sccfm-cli inventory devices asa shun Manual" .SH NAME sccfm-cli\-inventory\-devices\-asa\-shun \- Manage shun entries on ASA devices. .SH SYNOPSIS diff --git a/docs/man/man1/sccfm-cli-inventory-devices-asa-smartlicense.1 b/docs/man/man1/sccfm-cli-inventory-devices-asa-smartlicense.1 index c9196fed..e26a029c 100644 --- a/docs/man/man1/sccfm-cli-inventory-devices-asa-smartlicense.1 +++ b/docs/man/man1/sccfm-cli-inventory-devices-asa-smartlicense.1 @@ -1,4 +1,4 @@ -.TH "SCCFM-CLI INVENTORY DEVICES ASA SMARTLICENSE" "1" "2026-07-27" "0.38.0" "sccfm-cli inventory devices asa smartlicense Manual" +.TH "SCCFM-CLI INVENTORY DEVICES ASA SMARTLICENSE" "1" "2026-08-11" "0.38.0" "sccfm-cli inventory devices asa smartlicense Manual" .SH NAME sccfm-cli\-inventory\-devices\-asa\-smartlicense \- Apply Smart License using a Smart license... .SH SYNOPSIS diff --git a/docs/man/man1/sccfm-cli-inventory-devices-asa-upgrade-compatible-versions.1 b/docs/man/man1/sccfm-cli-inventory-devices-asa-upgrade-compatible-versions.1 index 7d4b3475..910717c5 100644 --- a/docs/man/man1/sccfm-cli-inventory-devices-asa-upgrade-compatible-versions.1 +++ b/docs/man/man1/sccfm-cli-inventory-devices-asa-upgrade-compatible-versions.1 @@ -1,4 +1,4 @@ -.TH "SCCFM-CLI INVENTORY DEVICES ASA UPGRADE COMPATIBLE-VERSIONS" "1" "2026-07-27" "0.38.0" "sccfm-cli inventory devices asa upgrade compatible-versions Manual" +.TH "SCCFM-CLI INVENTORY DEVICES ASA UPGRADE COMPATIBLE-VERSIONS" "1" "2026-08-11" "0.38.0" "sccfm-cli inventory devices asa upgrade compatible-versions Manual" .SH NAME sccfm-cli\-inventory\-devices\-asa\-upgrade\-compatible-versions \- List software versions compatible with a... .SH SYNOPSIS diff --git a/docs/man/man1/sccfm-cli-inventory-devices-asa-upgrade-trigger.1 b/docs/man/man1/sccfm-cli-inventory-devices-asa-upgrade-trigger.1 index 19572e74..799195df 100644 --- a/docs/man/man1/sccfm-cli-inventory-devices-asa-upgrade-trigger.1 +++ b/docs/man/man1/sccfm-cli-inventory-devices-asa-upgrade-trigger.1 @@ -1,4 +1,4 @@ -.TH "SCCFM-CLI INVENTORY DEVICES ASA UPGRADE TRIGGER" "1" "2026-07-27" "0.38.0" "sccfm-cli inventory devices asa upgrade trigger Manual" +.TH "SCCFM-CLI INVENTORY DEVICES ASA UPGRADE TRIGGER" "1" "2026-08-11" "0.38.0" "sccfm-cli inventory devices asa upgrade trigger Manual" .SH NAME sccfm-cli\-inventory\-devices\-asa\-upgrade\-trigger \- Trigger an ASA firmware/ASDM upgrade on... .SH SYNOPSIS diff --git a/docs/man/man1/sccfm-cli-inventory-devices-asa-upgrade.1 b/docs/man/man1/sccfm-cli-inventory-devices-asa-upgrade.1 index dbcf305a..f5eed61b 100644 --- a/docs/man/man1/sccfm-cli-inventory-devices-asa-upgrade.1 +++ b/docs/man/man1/sccfm-cli-inventory-devices-asa-upgrade.1 @@ -1,4 +1,4 @@ -.TH "SCCFM-CLI INVENTORY DEVICES ASA UPGRADE" "1" "2026-07-27" "0.38.0" "sccfm-cli inventory devices asa upgrade Manual" +.TH "SCCFM-CLI INVENTORY DEVICES ASA UPGRADE" "1" "2026-08-11" "0.38.0" "sccfm-cli inventory devices asa upgrade Manual" .SH NAME sccfm-cli\-inventory\-devices\-asa\-upgrade \- ASA device upgrade operations. .SH SYNOPSIS diff --git a/docs/man/man1/sccfm-cli-inventory-devices-asa-user-change-password.1 b/docs/man/man1/sccfm-cli-inventory-devices-asa-user-change-password.1 index 9f8aad95..618c9986 100644 --- a/docs/man/man1/sccfm-cli-inventory-devices-asa-user-change-password.1 +++ b/docs/man/man1/sccfm-cli-inventory-devices-asa-user-change-password.1 @@ -1,4 +1,4 @@ -.TH "SCCFM-CLI INVENTORY DEVICES ASA USER CHANGE-PASSWORD" "1" "2026-07-27" "0.38.0" "sccfm-cli inventory devices asa user change-password Manual" +.TH "SCCFM-CLI INVENTORY DEVICES ASA USER CHANGE-PASSWORD" "1" "2026-08-11" "0.38.0" "sccfm-cli inventory devices asa user change-password Manual" .SH NAME sccfm-cli\-inventory\-devices\-asa\-user\-change-password \- Change a local user password on ASA... .SH SYNOPSIS diff --git a/docs/man/man1/sccfm-cli-inventory-devices-asa-user.1 b/docs/man/man1/sccfm-cli-inventory-devices-asa-user.1 index fe5c9163..2ba9f6a3 100644 --- a/docs/man/man1/sccfm-cli-inventory-devices-asa-user.1 +++ b/docs/man/man1/sccfm-cli-inventory-devices-asa-user.1 @@ -1,4 +1,4 @@ -.TH "SCCFM-CLI INVENTORY DEVICES ASA USER" "1" "2026-07-27" "0.38.0" "sccfm-cli inventory devices asa user Manual" +.TH "SCCFM-CLI INVENTORY DEVICES ASA USER" "1" "2026-08-11" "0.38.0" "sccfm-cli inventory devices asa user Manual" .SH NAME sccfm-cli\-inventory\-devices\-asa\-user \- ASA local user operations. .SH SYNOPSIS diff --git a/docs/man/man1/sccfm-cli-inventory-devices-asa.1 b/docs/man/man1/sccfm-cli-inventory-devices-asa.1 index b236dd66..abc981c8 100644 --- a/docs/man/man1/sccfm-cli-inventory-devices-asa.1 +++ b/docs/man/man1/sccfm-cli-inventory-devices-asa.1 @@ -1,4 +1,4 @@ -.TH "SCCFM-CLI INVENTORY DEVICES ASA" "1" "2026-07-27" "0.38.0" "sccfm-cli inventory devices asa Manual" +.TH "SCCFM-CLI INVENTORY DEVICES ASA" "1" "2026-08-11" "0.38.0" "sccfm-cli inventory devices asa Manual" .SH NAME sccfm-cli\-inventory\-devices\-asa \- ASA device operations. .SH SYNOPSIS diff --git a/docs/man/man1/sccfm-cli-inventory-devices-cdfmc-managed-ftd-cli-execute.1 b/docs/man/man1/sccfm-cli-inventory-devices-cdfmc-managed-ftd-cli-execute.1 index e59c7520..988f1eb4 100644 --- a/docs/man/man1/sccfm-cli-inventory-devices-cdfmc-managed-ftd-cli-execute.1 +++ b/docs/man/man1/sccfm-cli-inventory-devices-cdfmc-managed-ftd-cli-execute.1 @@ -1,4 +1,4 @@ -.TH "SCCFM-CLI INVENTORY DEVICES CDFMC-MANAGED-FTD CLI EXECUTE" "1" "2026-07-27" "0.38.0" "sccfm-cli inventory devices cdfmc-managed-ftd cli execute Manual" +.TH "SCCFM-CLI INVENTORY DEVICES CDFMC-MANAGED-FTD CLI EXECUTE" "1" "2026-08-11" "0.38.0" "sccfm-cli inventory devices cdfmc-managed-ftd cli execute Manual" .SH NAME sccfm-cli\-inventory\-devices\-cdfmc-managed-ftd\-cli\-execute \- Execute a show command on cdFMC-managed... .SH SYNOPSIS diff --git a/docs/man/man1/sccfm-cli-inventory-devices-cdfmc-managed-ftd-cli.1 b/docs/man/man1/sccfm-cli-inventory-devices-cdfmc-managed-ftd-cli.1 index 6ecf8d7c..c8fec48c 100644 --- a/docs/man/man1/sccfm-cli-inventory-devices-cdfmc-managed-ftd-cli.1 +++ b/docs/man/man1/sccfm-cli-inventory-devices-cdfmc-managed-ftd-cli.1 @@ -1,4 +1,4 @@ -.TH "SCCFM-CLI INVENTORY DEVICES CDFMC-MANAGED-FTD CLI" "1" "2026-07-27" "0.38.0" "sccfm-cli inventory devices cdfmc-managed-ftd cli Manual" +.TH "SCCFM-CLI INVENTORY DEVICES CDFMC-MANAGED-FTD CLI" "1" "2026-08-11" "0.38.0" "sccfm-cli inventory devices cdfmc-managed-ftd cli Manual" .SH NAME sccfm-cli\-inventory\-devices\-cdfmc-managed-ftd\-cli \- cdFMC-managed FTD device CLI operations. .SH SYNOPSIS diff --git a/docs/man/man1/sccfm-cli-inventory-devices-cdfmc-managed-ftd-configure-manager.1 b/docs/man/man1/sccfm-cli-inventory-devices-cdfmc-managed-ftd-configure-manager.1 index 96a247a9..f8336487 100644 --- a/docs/man/man1/sccfm-cli-inventory-devices-cdfmc-managed-ftd-configure-manager.1 +++ b/docs/man/man1/sccfm-cli-inventory-devices-cdfmc-managed-ftd-configure-manager.1 @@ -1,4 +1,4 @@ -.TH "SCCFM-CLI INVENTORY DEVICES CDFMC-MANAGED-FTD CONFIGURE-MANAGER" "1" "2026-07-27" "0.38.0" "sccfm-cli inventory devices cdfmc-managed-ftd configure-manager Manual" +.TH "SCCFM-CLI INVENTORY DEVICES CDFMC-MANAGED-FTD CONFIGURE-MANAGER" "1" "2026-08-11" "0.38.0" "sccfm-cli inventory devices cdfmc-managed-ftd configure-manager Manual" .SH NAME sccfm-cli\-inventory\-devices\-cdfmc-managed-ftd\-configure-manager \- Complete cdFMC-managed FTD onboarding by... .SH SYNOPSIS @@ -21,7 +21,7 @@ SSH username for the FTD VM. [required] SSH password for the FTD VM (or set SCCFM_FTD_PASSWORD; prompted if needed). .TP \fB\-\-cli\-key\fP TEXT -The full 'configure manager add ...' string returned by 'onboard'. [required] +The full 'configure manager add ...' string returned by 'onboard' (or set SCCFM_CLI_KEY). Required unless --check is set. [env var: SCCFM_CLI_KEY] .TP \fB\-\-jump\-host\fP TEXT Optional bastion to tunnel through, as [user@]host[:port]. The FTD then sees the connection from the jump host's IP, so that IP must be on the FTD ssh-access-list. diff --git a/docs/man/man1/sccfm-cli-inventory-devices-cdfmc-managed-ftd-deploy.1 b/docs/man/man1/sccfm-cli-inventory-devices-cdfmc-managed-ftd-deploy.1 index 95a8e517..011021f3 100644 --- a/docs/man/man1/sccfm-cli-inventory-devices-cdfmc-managed-ftd-deploy.1 +++ b/docs/man/man1/sccfm-cli-inventory-devices-cdfmc-managed-ftd-deploy.1 @@ -1,4 +1,4 @@ -.TH "SCCFM-CLI INVENTORY DEVICES CDFMC-MANAGED-FTD DEPLOY" "1" "2026-07-27" "0.38.0" "sccfm-cli inventory devices cdfmc-managed-ftd deploy Manual" +.TH "SCCFM-CLI INVENTORY DEVICES CDFMC-MANAGED-FTD DEPLOY" "1" "2026-08-11" "0.38.0" "sccfm-cli inventory devices cdfmc-managed-ftd deploy Manual" .SH NAME sccfm-cli\-inventory\-devices\-cdfmc-managed-ftd\-deploy \- Deploy pending configuration changes to... .SH SYNOPSIS diff --git a/docs/man/man1/sccfm-cli-inventory-devices-cdfmc-managed-ftd-list.1 b/docs/man/man1/sccfm-cli-inventory-devices-cdfmc-managed-ftd-list.1 index 74299add..8fdfc866 100644 --- a/docs/man/man1/sccfm-cli-inventory-devices-cdfmc-managed-ftd-list.1 +++ b/docs/man/man1/sccfm-cli-inventory-devices-cdfmc-managed-ftd-list.1 @@ -1,4 +1,4 @@ -.TH "SCCFM-CLI INVENTORY DEVICES CDFMC-MANAGED-FTD LIST" "1" "2026-07-27" "0.38.0" "sccfm-cli inventory devices cdfmc-managed-ftd list Manual" +.TH "SCCFM-CLI INVENTORY DEVICES CDFMC-MANAGED-FTD LIST" "1" "2026-08-11" "0.38.0" "sccfm-cli inventory devices cdfmc-managed-ftd list Manual" .SH NAME sccfm-cli\-inventory\-devices\-cdfmc-managed-ftd\-list \- List cdFMC-managed FTD devices. .SH SYNOPSIS diff --git a/docs/man/man1/sccfm-cli-inventory-devices-cdfmc-managed-ftd-onboard-ztp.1 b/docs/man/man1/sccfm-cli-inventory-devices-cdfmc-managed-ftd-onboard-ztp.1 index b3be9760..26e5b215 100644 --- a/docs/man/man1/sccfm-cli-inventory-devices-cdfmc-managed-ftd-onboard-ztp.1 +++ b/docs/man/man1/sccfm-cli-inventory-devices-cdfmc-managed-ftd-onboard-ztp.1 @@ -1,4 +1,4 @@ -.TH "SCCFM-CLI INVENTORY DEVICES CDFMC-MANAGED-FTD ONBOARD-ZTP" "1" "2026-07-27" "0.38.0" "sccfm-cli inventory devices cdfmc-managed-ftd onboard-ztp Manual" +.TH "SCCFM-CLI INVENTORY DEVICES CDFMC-MANAGED-FTD ONBOARD-ZTP" "1" "2026-08-11" "0.38.0" "sccfm-cli inventory devices cdfmc-managed-ftd onboard-ztp Manual" .SH NAME sccfm-cli\-inventory\-devices\-cdfmc-managed-ftd\-onboard-ztp \- Onboard a cdFMC-managed FTD device using... .SH SYNOPSIS @@ -21,7 +21,7 @@ License(s) to apply to the device. Can be specified multiple times (e.g. --licen UUID of the FMC access policy to apply to this device. [required] .TP \fB\-\-admin\-password\fP TEXT -Initial provisioning password for the device. Required for setup if a password has not already been set on the device. +Initial provisioning password for the device. Required for setup if a password has not already been set on the device. For secure non-interactive use, set SCCFM_FTD_ADMIN_PASSWORD. [env var: SCCFM_FTD_ADMIN_PASSWORD] .TP \fB\-\-device\-group\-uid\fP TEXT UUID of the device group to assign this device to after registration. diff --git a/docs/man/man1/sccfm-cli-inventory-devices-cdfmc-managed-ftd-onboard.1 b/docs/man/man1/sccfm-cli-inventory-devices-cdfmc-managed-ftd-onboard.1 index 262b2419..031aa822 100644 --- a/docs/man/man1/sccfm-cli-inventory-devices-cdfmc-managed-ftd-onboard.1 +++ b/docs/man/man1/sccfm-cli-inventory-devices-cdfmc-managed-ftd-onboard.1 @@ -1,4 +1,4 @@ -.TH "SCCFM-CLI INVENTORY DEVICES CDFMC-MANAGED-FTD ONBOARD" "1" "2026-07-27" "0.38.0" "sccfm-cli inventory devices cdfmc-managed-ftd onboard Manual" +.TH "SCCFM-CLI INVENTORY DEVICES CDFMC-MANAGED-FTD ONBOARD" "1" "2026-08-11" "0.38.0" "sccfm-cli inventory devices cdfmc-managed-ftd onboard Manual" .SH NAME sccfm-cli\-inventory\-devices\-cdfmc-managed-ftd\-onboard \- Onboard a cdFMC-managed FTD device (non-ZTP). .SH SYNOPSIS diff --git a/docs/man/man1/sccfm-cli-inventory-devices-cdfmc-managed-ftd.1 b/docs/man/man1/sccfm-cli-inventory-devices-cdfmc-managed-ftd.1 index 1b8b34f4..63f50ea9 100644 --- a/docs/man/man1/sccfm-cli-inventory-devices-cdfmc-managed-ftd.1 +++ b/docs/man/man1/sccfm-cli-inventory-devices-cdfmc-managed-ftd.1 @@ -1,4 +1,4 @@ -.TH "SCCFM-CLI INVENTORY DEVICES CDFMC-MANAGED-FTD" "1" "2026-07-27" "0.38.0" "sccfm-cli inventory devices cdfmc-managed-ftd Manual" +.TH "SCCFM-CLI INVENTORY DEVICES CDFMC-MANAGED-FTD" "1" "2026-08-11" "0.38.0" "sccfm-cli inventory devices cdfmc-managed-ftd Manual" .SH NAME sccfm-cli\-inventory\-devices\-cdfmc-managed-ftd \- cdFMC-managed FTD device operations. .SH SYNOPSIS diff --git a/docs/man/man1/sccfm-cli-inventory-devices-ftd-list-not-on-version.1 b/docs/man/man1/sccfm-cli-inventory-devices-ftd-list-not-on-version.1 index 7ff1e459..33bb4432 100644 --- a/docs/man/man1/sccfm-cli-inventory-devices-ftd-list-not-on-version.1 +++ b/docs/man/man1/sccfm-cli-inventory-devices-ftd-list-not-on-version.1 @@ -1,4 +1,4 @@ -.TH "SCCFM-CLI INVENTORY DEVICES FTD LIST-NOT-ON-VERSION" "1" "2026-07-27" "0.38.0" "sccfm-cli inventory devices ftd list-not-on-version Manual" +.TH "SCCFM-CLI INVENTORY DEVICES FTD LIST-NOT-ON-VERSION" "1" "2026-08-11" "0.38.0" "sccfm-cli inventory devices ftd list-not-on-version Manual" .SH NAME sccfm-cli\-inventory\-devices\-ftd\-list-not-on-version \- List FTD devices that are NOT running a... .SH SYNOPSIS diff --git a/docs/man/man1/sccfm-cli-inventory-devices-ftd-list.1 b/docs/man/man1/sccfm-cli-inventory-devices-ftd-list.1 index fadaa361..8213a2d6 100644 --- a/docs/man/man1/sccfm-cli-inventory-devices-ftd-list.1 +++ b/docs/man/man1/sccfm-cli-inventory-devices-ftd-list.1 @@ -1,4 +1,4 @@ -.TH "SCCFM-CLI INVENTORY DEVICES FTD LIST" "1" "2026-07-27" "0.38.0" "sccfm-cli inventory devices ftd list Manual" +.TH "SCCFM-CLI INVENTORY DEVICES FTD LIST" "1" "2026-08-11" "0.38.0" "sccfm-cli inventory devices ftd list Manual" .SH NAME sccfm-cli\-inventory\-devices\-ftd\-list \- List FTD devices (includes cdFMC-managed,... .SH SYNOPSIS diff --git a/docs/man/man1/sccfm-cli-inventory-devices-ftd-upgrade-compatible-versions.1 b/docs/man/man1/sccfm-cli-inventory-devices-ftd-upgrade-compatible-versions.1 index 24eb6063..78323b4a 100644 --- a/docs/man/man1/sccfm-cli-inventory-devices-ftd-upgrade-compatible-versions.1 +++ b/docs/man/man1/sccfm-cli-inventory-devices-ftd-upgrade-compatible-versions.1 @@ -1,4 +1,4 @@ -.TH "SCCFM-CLI INVENTORY DEVICES FTD UPGRADE COMPATIBLE-VERSIONS" "1" "2026-07-27" "0.38.0" "sccfm-cli inventory devices ftd upgrade compatible-versions Manual" +.TH "SCCFM-CLI INVENTORY DEVICES FTD UPGRADE COMPATIBLE-VERSIONS" "1" "2026-08-11" "0.38.0" "sccfm-cli inventory devices ftd upgrade compatible-versions Manual" .SH NAME sccfm-cli\-inventory\-devices\-ftd\-upgrade\-compatible-versions \- List software versions compatible with a... .SH SYNOPSIS diff --git a/docs/man/man1/sccfm-cli-inventory-devices-ftd-upgrade-trigger.1 b/docs/man/man1/sccfm-cli-inventory-devices-ftd-upgrade-trigger.1 index b962f636..e1a2d5e2 100644 --- a/docs/man/man1/sccfm-cli-inventory-devices-ftd-upgrade-trigger.1 +++ b/docs/man/man1/sccfm-cli-inventory-devices-ftd-upgrade-trigger.1 @@ -1,4 +1,4 @@ -.TH "SCCFM-CLI INVENTORY DEVICES FTD UPGRADE TRIGGER" "1" "2026-07-27" "0.38.0" "sccfm-cli inventory devices ftd upgrade trigger Manual" +.TH "SCCFM-CLI INVENTORY DEVICES FTD UPGRADE TRIGGER" "1" "2026-08-11" "0.38.0" "sccfm-cli inventory devices ftd upgrade trigger Manual" .SH NAME sccfm-cli\-inventory\-devices\-ftd\-upgrade\-trigger \- Trigger an FTD firmware upgrade on one or... .SH SYNOPSIS diff --git a/docs/man/man1/sccfm-cli-inventory-devices-ftd-upgrade.1 b/docs/man/man1/sccfm-cli-inventory-devices-ftd-upgrade.1 index 8de806c0..68f7015f 100644 --- a/docs/man/man1/sccfm-cli-inventory-devices-ftd-upgrade.1 +++ b/docs/man/man1/sccfm-cli-inventory-devices-ftd-upgrade.1 @@ -1,4 +1,4 @@ -.TH "SCCFM-CLI INVENTORY DEVICES FTD UPGRADE" "1" "2026-07-27" "0.38.0" "sccfm-cli inventory devices ftd upgrade Manual" +.TH "SCCFM-CLI INVENTORY DEVICES FTD UPGRADE" "1" "2026-08-11" "0.38.0" "sccfm-cli inventory devices ftd upgrade Manual" .SH NAME sccfm-cli\-inventory\-devices\-ftd\-upgrade \- FTD device upgrade operations. .SH SYNOPSIS diff --git a/docs/man/man1/sccfm-cli-inventory-devices-ftd.1 b/docs/man/man1/sccfm-cli-inventory-devices-ftd.1 index 70593554..a3ae67ec 100644 --- a/docs/man/man1/sccfm-cli-inventory-devices-ftd.1 +++ b/docs/man/man1/sccfm-cli-inventory-devices-ftd.1 @@ -1,4 +1,4 @@ -.TH "SCCFM-CLI INVENTORY DEVICES FTD" "1" "2026-07-27" "0.38.0" "sccfm-cli inventory devices ftd Manual" +.TH "SCCFM-CLI INVENTORY DEVICES FTD" "1" "2026-08-11" "0.38.0" "sccfm-cli inventory devices ftd Manual" .SH NAME sccfm-cli\-inventory\-devices\-ftd \- FTD device operations. .SH SYNOPSIS diff --git a/docs/man/man1/sccfm-cli-inventory-devices-list.1 b/docs/man/man1/sccfm-cli-inventory-devices-list.1 index 460de608..d02c8cdb 100644 --- a/docs/man/man1/sccfm-cli-inventory-devices-list.1 +++ b/docs/man/man1/sccfm-cli-inventory-devices-list.1 @@ -1,4 +1,4 @@ -.TH "SCCFM-CLI INVENTORY DEVICES LIST" "1" "2026-07-27" "0.38.0" "sccfm-cli inventory devices list Manual" +.TH "SCCFM-CLI INVENTORY DEVICES LIST" "1" "2026-08-11" "0.38.0" "sccfm-cli inventory devices list Manual" .SH NAME sccfm-cli\-inventory\-devices\-list \- List devices. .SH SYNOPSIS diff --git a/docs/man/man1/sccfm-cli-inventory-devices.1 b/docs/man/man1/sccfm-cli-inventory-devices.1 index b2a703a5..55fe1fb4 100644 --- a/docs/man/man1/sccfm-cli-inventory-devices.1 +++ b/docs/man/man1/sccfm-cli-inventory-devices.1 @@ -1,4 +1,4 @@ -.TH "SCCFM-CLI INVENTORY DEVICES" "1" "2026-07-27" "0.38.0" "sccfm-cli inventory devices Manual" +.TH "SCCFM-CLI INVENTORY DEVICES" "1" "2026-08-11" "0.38.0" "sccfm-cli inventory devices Manual" .SH NAME sccfm-cli\-inventory\-devices \- Device inventory operations. .SH SYNOPSIS diff --git a/docs/man/man1/sccfm-cli-inventory-manager-access-policies-list.1 b/docs/man/man1/sccfm-cli-inventory-manager-access-policies-list.1 index 7edee841..7354c71a 100644 --- a/docs/man/man1/sccfm-cli-inventory-manager-access-policies-list.1 +++ b/docs/man/man1/sccfm-cli-inventory-manager-access-policies-list.1 @@ -1,4 +1,4 @@ -.TH "SCCFM-CLI INVENTORY MANAGER ACCESS-POLICIES LIST" "1" "2026-07-27" "0.38.0" "sccfm-cli inventory manager access-policies list Manual" +.TH "SCCFM-CLI INVENTORY MANAGER ACCESS-POLICIES LIST" "1" "2026-08-11" "0.38.0" "sccfm-cli inventory manager access-policies list Manual" .SH NAME sccfm-cli\-inventory\-manager\-access-policies\-list \- List FMC access policies for a given domain. .SH SYNOPSIS diff --git a/docs/man/man1/sccfm-cli-inventory-manager-access-policies.1 b/docs/man/man1/sccfm-cli-inventory-manager-access-policies.1 index 224dbdca..fda2cc05 100644 --- a/docs/man/man1/sccfm-cli-inventory-manager-access-policies.1 +++ b/docs/man/man1/sccfm-cli-inventory-manager-access-policies.1 @@ -1,4 +1,4 @@ -.TH "SCCFM-CLI INVENTORY MANAGER ACCESS-POLICIES" "1" "2026-07-27" "0.38.0" "sccfm-cli inventory manager access-policies Manual" +.TH "SCCFM-CLI INVENTORY MANAGER ACCESS-POLICIES" "1" "2026-08-11" "0.38.0" "sccfm-cli inventory manager access-policies Manual" .SH NAME sccfm-cli\-inventory\-manager\-access-policies \- FMC access policy operations. .SH SYNOPSIS diff --git a/docs/man/man1/sccfm-cli-inventory-manager-list.1 b/docs/man/man1/sccfm-cli-inventory-manager-list.1 index a13fb2b3..a3e94fd6 100644 --- a/docs/man/man1/sccfm-cli-inventory-manager-list.1 +++ b/docs/man/man1/sccfm-cli-inventory-manager-list.1 @@ -1,4 +1,4 @@ -.TH "SCCFM-CLI INVENTORY MANAGER LIST" "1" "2026-07-27" "0.38.0" "sccfm-cli inventory manager list Manual" +.TH "SCCFM-CLI INVENTORY MANAGER LIST" "1" "2026-08-11" "0.38.0" "sccfm-cli inventory manager list Manual" .SH NAME sccfm-cli\-inventory\-manager\-list \- List manager. .SH SYNOPSIS diff --git a/docs/man/man1/sccfm-cli-inventory-manager.1 b/docs/man/man1/sccfm-cli-inventory-manager.1 index 15c490e5..03aaa057 100644 --- a/docs/man/man1/sccfm-cli-inventory-manager.1 +++ b/docs/man/man1/sccfm-cli-inventory-manager.1 @@ -1,4 +1,4 @@ -.TH "SCCFM-CLI INVENTORY MANAGER" "1" "2026-07-27" "0.38.0" "sccfm-cli inventory manager Manual" +.TH "SCCFM-CLI INVENTORY MANAGER" "1" "2026-08-11" "0.38.0" "sccfm-cli inventory manager Manual" .SH NAME sccfm-cli\-inventory\-manager \- Manager inventory operations. .SH SYNOPSIS diff --git a/docs/man/man1/sccfm-cli-inventory.1 b/docs/man/man1/sccfm-cli-inventory.1 index 64b3c857..2818b47f 100644 --- a/docs/man/man1/sccfm-cli-inventory.1 +++ b/docs/man/man1/sccfm-cli-inventory.1 @@ -1,4 +1,4 @@ -.TH "SCCFM-CLI INVENTORY" "1" "2026-07-27" "0.38.0" "sccfm-cli inventory Manual" +.TH "SCCFM-CLI INVENTORY" "1" "2026-08-11" "0.38.0" "sccfm-cli inventory Manual" .SH NAME sccfm-cli\-inventory \- Browse SCC Firewall Management inventory. .SH SYNOPSIS diff --git a/docs/man/man1/sccfm-cli-objects-add-override.1 b/docs/man/man1/sccfm-cli-objects-add-override.1 index 3b3863a3..97dfccab 100644 --- a/docs/man/man1/sccfm-cli-objects-add-override.1 +++ b/docs/man/man1/sccfm-cli-objects-add-override.1 @@ -1,4 +1,4 @@ -.TH "SCCFM-CLI OBJECTS ADD-OVERRIDE" "1" "2026-07-27" "0.38.0" "sccfm-cli objects add-override Manual" +.TH "SCCFM-CLI OBJECTS ADD-OVERRIDE" "1" "2026-08-11" "0.38.0" "sccfm-cli objects add-override Manual" .SH NAME sccfm-cli\-objects\-add-override \- Add a device-specific override to an object. .SH SYNOPSIS diff --git a/docs/man/man1/sccfm-cli-objects-apply-override-as-default.1 b/docs/man/man1/sccfm-cli-objects-apply-override-as-default.1 index 9872bc07..a7a867bc 100644 --- a/docs/man/man1/sccfm-cli-objects-apply-override-as-default.1 +++ b/docs/man/man1/sccfm-cli-objects-apply-override-as-default.1 @@ -1,4 +1,4 @@ -.TH "SCCFM-CLI OBJECTS APPLY-OVERRIDE-AS-DEFAULT" "1" "2026-07-27" "0.38.0" "sccfm-cli objects apply-override-as-default Manual" +.TH "SCCFM-CLI OBJECTS APPLY-OVERRIDE-AS-DEFAULT" "1" "2026-08-11" "0.38.0" "sccfm-cli objects apply-override-as-default Manual" .SH NAME sccfm-cli\-objects\-apply-override-as-default \- Apply an existing override value as the... .SH SYNOPSIS diff --git a/docs/man/man1/sccfm-cli-objects-delete-override.1 b/docs/man/man1/sccfm-cli-objects-delete-override.1 index d8f5afd8..ae9aa9d7 100644 --- a/docs/man/man1/sccfm-cli-objects-delete-override.1 +++ b/docs/man/man1/sccfm-cli-objects-delete-override.1 @@ -1,4 +1,4 @@ -.TH "SCCFM-CLI OBJECTS DELETE-OVERRIDE" "1" "2026-07-27" "0.38.0" "sccfm-cli objects delete-override Manual" +.TH "SCCFM-CLI OBJECTS DELETE-OVERRIDE" "1" "2026-08-11" "0.38.0" "sccfm-cli objects delete-override Manual" .SH NAME sccfm-cli\-objects\-delete-override \- Delete an existing override for a specific... .SH SYNOPSIS diff --git a/docs/man/man1/sccfm-cli-objects-edit-override.1 b/docs/man/man1/sccfm-cli-objects-edit-override.1 index 3b7b10a2..60e019fd 100644 --- a/docs/man/man1/sccfm-cli-objects-edit-override.1 +++ b/docs/man/man1/sccfm-cli-objects-edit-override.1 @@ -1,4 +1,4 @@ -.TH "SCCFM-CLI OBJECTS EDIT-OVERRIDE" "1" "2026-07-27" "0.38.0" "sccfm-cli objects edit-override Manual" +.TH "SCCFM-CLI OBJECTS EDIT-OVERRIDE" "1" "2026-08-11" "0.38.0" "sccfm-cli objects edit-override Manual" .SH NAME sccfm-cli\-objects\-edit-override \- Edit the value of an existing override for... .SH SYNOPSIS diff --git a/docs/man/man1/sccfm-cli-objects-network-create.1 b/docs/man/man1/sccfm-cli-objects-network-create.1 index 3f6b0d80..dcfdb117 100644 --- a/docs/man/man1/sccfm-cli-objects-network-create.1 +++ b/docs/man/man1/sccfm-cli-objects-network-create.1 @@ -1,4 +1,4 @@ -.TH "SCCFM-CLI OBJECTS NETWORK CREATE" "1" "2026-07-27" "0.38.0" "sccfm-cli objects network create Manual" +.TH "SCCFM-CLI OBJECTS NETWORK CREATE" "1" "2026-08-11" "0.38.0" "sccfm-cli objects network create Manual" .SH NAME sccfm-cli\-objects\-network\-create \- Create a network object. .SH SYNOPSIS diff --git a/docs/man/man1/sccfm-cli-objects-network-delete.1 b/docs/man/man1/sccfm-cli-objects-network-delete.1 index cc513f77..29c9c9bc 100644 --- a/docs/man/man1/sccfm-cli-objects-network-delete.1 +++ b/docs/man/man1/sccfm-cli-objects-network-delete.1 @@ -1,4 +1,4 @@ -.TH "SCCFM-CLI OBJECTS NETWORK DELETE" "1" "2026-07-27" "0.38.0" "sccfm-cli objects network delete Manual" +.TH "SCCFM-CLI OBJECTS NETWORK DELETE" "1" "2026-08-11" "0.38.0" "sccfm-cli objects network delete Manual" .SH NAME sccfm-cli\-objects\-network\-delete \- Delete a network object. .SH SYNOPSIS diff --git a/docs/man/man1/sccfm-cli-objects-network-group-add-member.1 b/docs/man/man1/sccfm-cli-objects-network-group-add-member.1 index 362889b4..a86f8a47 100644 --- a/docs/man/man1/sccfm-cli-objects-network-group-add-member.1 +++ b/docs/man/man1/sccfm-cli-objects-network-group-add-member.1 @@ -1,4 +1,4 @@ -.TH "SCCFM-CLI OBJECTS NETWORK-GROUP ADD-MEMBER" "1" "2026-07-27" "0.38.0" "sccfm-cli objects network-group add-member Manual" +.TH "SCCFM-CLI OBJECTS NETWORK-GROUP ADD-MEMBER" "1" "2026-08-11" "0.38.0" "sccfm-cli objects network-group add-member Manual" .SH NAME sccfm-cli\-objects\-network-group\-add-member \- Add referenced network-object members to a... .SH SYNOPSIS diff --git a/docs/man/man1/sccfm-cli-objects-network-group-create.1 b/docs/man/man1/sccfm-cli-objects-network-group-create.1 index f67d3278..e9802fa2 100644 --- a/docs/man/man1/sccfm-cli-objects-network-group-create.1 +++ b/docs/man/man1/sccfm-cli-objects-network-group-create.1 @@ -1,4 +1,4 @@ -.TH "SCCFM-CLI OBJECTS NETWORK-GROUP CREATE" "1" "2026-07-27" "0.38.0" "sccfm-cli objects network-group create Manual" +.TH "SCCFM-CLI OBJECTS NETWORK-GROUP CREATE" "1" "2026-08-11" "0.38.0" "sccfm-cli objects network-group create Manual" .SH NAME sccfm-cli\-objects\-network-group\-create \- Create a network group. .SH SYNOPSIS diff --git a/docs/man/man1/sccfm-cli-objects-network-group-delete.1 b/docs/man/man1/sccfm-cli-objects-network-group-delete.1 index be6b3ae2..7234eb53 100644 --- a/docs/man/man1/sccfm-cli-objects-network-group-delete.1 +++ b/docs/man/man1/sccfm-cli-objects-network-group-delete.1 @@ -1,4 +1,4 @@ -.TH "SCCFM-CLI OBJECTS NETWORK-GROUP DELETE" "1" "2026-07-27" "0.38.0" "sccfm-cli objects network-group delete Manual" +.TH "SCCFM-CLI OBJECTS NETWORK-GROUP DELETE" "1" "2026-08-11" "0.38.0" "sccfm-cli objects network-group delete Manual" .SH NAME sccfm-cli\-objects\-network-group\-delete \- Delete a network group object. .SH SYNOPSIS diff --git a/docs/man/man1/sccfm-cli-objects-network-group-list.1 b/docs/man/man1/sccfm-cli-objects-network-group-list.1 index de1b8ad7..4203ff2f 100644 --- a/docs/man/man1/sccfm-cli-objects-network-group-list.1 +++ b/docs/man/man1/sccfm-cli-objects-network-group-list.1 @@ -1,4 +1,4 @@ -.TH "SCCFM-CLI OBJECTS NETWORK-GROUP LIST" "1" "2026-07-27" "0.38.0" "sccfm-cli objects network-group list Manual" +.TH "SCCFM-CLI OBJECTS NETWORK-GROUP LIST" "1" "2026-08-11" "0.38.0" "sccfm-cli objects network-group list Manual" .SH NAME sccfm-cli\-objects\-network-group\-list \- List network groups. .SH SYNOPSIS diff --git a/docs/man/man1/sccfm-cli-objects-network-group-remove-member.1 b/docs/man/man1/sccfm-cli-objects-network-group-remove-member.1 index 23600e0b..ee6cb5d3 100644 --- a/docs/man/man1/sccfm-cli-objects-network-group-remove-member.1 +++ b/docs/man/man1/sccfm-cli-objects-network-group-remove-member.1 @@ -1,4 +1,4 @@ -.TH "SCCFM-CLI OBJECTS NETWORK-GROUP REMOVE-MEMBER" "1" "2026-07-27" "0.38.0" "sccfm-cli objects network-group remove-member Manual" +.TH "SCCFM-CLI OBJECTS NETWORK-GROUP REMOVE-MEMBER" "1" "2026-08-11" "0.38.0" "sccfm-cli objects network-group remove-member Manual" .SH NAME sccfm-cli\-objects\-network-group\-remove-member \- Remove referenced network-object members... .SH SYNOPSIS diff --git a/docs/man/man1/sccfm-cli-objects-network-group-update.1 b/docs/man/man1/sccfm-cli-objects-network-group-update.1 index 068f4a5d..6aab885a 100644 --- a/docs/man/man1/sccfm-cli-objects-network-group-update.1 +++ b/docs/man/man1/sccfm-cli-objects-network-group-update.1 @@ -1,4 +1,4 @@ -.TH "SCCFM-CLI OBJECTS NETWORK-GROUP UPDATE" "1" "2026-07-27" "0.38.0" "sccfm-cli objects network-group update Manual" +.TH "SCCFM-CLI OBJECTS NETWORK-GROUP UPDATE" "1" "2026-08-11" "0.38.0" "sccfm-cli objects network-group update Manual" .SH NAME sccfm-cli\-objects\-network-group\-update \- Update a network group. .SH SYNOPSIS diff --git a/docs/man/man1/sccfm-cli-objects-network-group.1 b/docs/man/man1/sccfm-cli-objects-network-group.1 index 6c9b9e38..9339275f 100644 --- a/docs/man/man1/sccfm-cli-objects-network-group.1 +++ b/docs/man/man1/sccfm-cli-objects-network-group.1 @@ -1,4 +1,4 @@ -.TH "SCCFM-CLI OBJECTS NETWORK-GROUP" "1" "2026-07-27" "0.38.0" "sccfm-cli objects network-group Manual" +.TH "SCCFM-CLI OBJECTS NETWORK-GROUP" "1" "2026-08-11" "0.38.0" "sccfm-cli objects network-group Manual" .SH NAME sccfm-cli\-objects\-network-group \- Manage network group objects. .SH SYNOPSIS diff --git a/docs/man/man1/sccfm-cli-objects-network-list.1 b/docs/man/man1/sccfm-cli-objects-network-list.1 index d59c308c..ed9f3789 100644 --- a/docs/man/man1/sccfm-cli-objects-network-list.1 +++ b/docs/man/man1/sccfm-cli-objects-network-list.1 @@ -1,4 +1,4 @@ -.TH "SCCFM-CLI OBJECTS NETWORK LIST" "1" "2026-07-27" "0.38.0" "sccfm-cli objects network list Manual" +.TH "SCCFM-CLI OBJECTS NETWORK LIST" "1" "2026-08-11" "0.38.0" "sccfm-cli objects network list Manual" .SH NAME sccfm-cli\-objects\-network\-list \- List network objects. .SH SYNOPSIS diff --git a/docs/man/man1/sccfm-cli-objects-network-update.1 b/docs/man/man1/sccfm-cli-objects-network-update.1 index 4038787a..16055ebd 100644 --- a/docs/man/man1/sccfm-cli-objects-network-update.1 +++ b/docs/man/man1/sccfm-cli-objects-network-update.1 @@ -1,4 +1,4 @@ -.TH "SCCFM-CLI OBJECTS NETWORK UPDATE" "1" "2026-07-27" "0.38.0" "sccfm-cli objects network update Manual" +.TH "SCCFM-CLI OBJECTS NETWORK UPDATE" "1" "2026-08-11" "0.38.0" "sccfm-cli objects network update Manual" .SH NAME sccfm-cli\-objects\-network\-update \- Update a network object. .SH SYNOPSIS diff --git a/docs/man/man1/sccfm-cli-objects-network.1 b/docs/man/man1/sccfm-cli-objects-network.1 index 750bc00d..56f7c71f 100644 --- a/docs/man/man1/sccfm-cli-objects-network.1 +++ b/docs/man/man1/sccfm-cli-objects-network.1 @@ -1,4 +1,4 @@ -.TH "SCCFM-CLI OBJECTS NETWORK" "1" "2026-07-27" "0.38.0" "sccfm-cli objects network Manual" +.TH "SCCFM-CLI OBJECTS NETWORK" "1" "2026-08-11" "0.38.0" "sccfm-cli objects network Manual" .SH NAME sccfm-cli\-objects\-network \- Manage network objects. .SH SYNOPSIS diff --git a/docs/man/man1/sccfm-cli-objects-show.1 b/docs/man/man1/sccfm-cli-objects-show.1 index ce59c4d3..3a45bc15 100644 --- a/docs/man/man1/sccfm-cli-objects-show.1 +++ b/docs/man/man1/sccfm-cli-objects-show.1 @@ -1,4 +1,4 @@ -.TH "SCCFM-CLI OBJECTS SHOW" "1" "2026-07-27" "0.38.0" "sccfm-cli objects show Manual" +.TH "SCCFM-CLI OBJECTS SHOW" "1" "2026-08-11" "0.38.0" "sccfm-cli objects show Manual" .SH NAME sccfm-cli\-objects\-show \- Show the full details of an object,... .SH SYNOPSIS diff --git a/docs/man/man1/sccfm-cli-objects-update-default.1 b/docs/man/man1/sccfm-cli-objects-update-default.1 index 2427766c..57a3cb2b 100644 --- a/docs/man/man1/sccfm-cli-objects-update-default.1 +++ b/docs/man/man1/sccfm-cli-objects-update-default.1 @@ -1,4 +1,4 @@ -.TH "SCCFM-CLI OBJECTS UPDATE-DEFAULT" "1" "2026-07-27" "0.38.0" "sccfm-cli objects update-default Manual" +.TH "SCCFM-CLI OBJECTS UPDATE-DEFAULT" "1" "2026-08-11" "0.38.0" "sccfm-cli objects update-default Manual" .SH NAME sccfm-cli\-objects\-update-default \- Update the default content value of an... .SH SYNOPSIS diff --git a/docs/man/man1/sccfm-cli-objects.1 b/docs/man/man1/sccfm-cli-objects.1 index 1e659535..b8523028 100644 --- a/docs/man/man1/sccfm-cli-objects.1 +++ b/docs/man/man1/sccfm-cli-objects.1 @@ -1,4 +1,4 @@ -.TH "SCCFM-CLI OBJECTS" "1" "2026-07-27" "0.38.0" "sccfm-cli objects Manual" +.TH "SCCFM-CLI OBJECTS" "1" "2026-08-11" "0.38.0" "sccfm-cli objects Manual" .SH NAME sccfm-cli\-objects \- Manage SCC Firewall Management objects. .SH SYNOPSIS diff --git a/docs/man/man1/sccfm-cli-policies-access-group-get.1 b/docs/man/man1/sccfm-cli-policies-access-group-get.1 index 2f2c2cbe..27894eb4 100644 --- a/docs/man/man1/sccfm-cli-policies-access-group-get.1 +++ b/docs/man/man1/sccfm-cli-policies-access-group-get.1 @@ -1,4 +1,4 @@ -.TH "SCCFM-CLI POLICIES ACCESS-GROUP GET" "1" "2026-07-27" "0.38.0" "sccfm-cli policies access-group get Manual" +.TH "SCCFM-CLI POLICIES ACCESS-GROUP GET" "1" "2026-08-11" "0.38.0" "sccfm-cli policies access-group get Manual" .SH NAME sccfm-cli\-policies\-access-group\-get \- Get an ASA access group by UID. .SH SYNOPSIS diff --git a/docs/man/man1/sccfm-cli-policies-access-group-list.1 b/docs/man/man1/sccfm-cli-policies-access-group-list.1 index 3c806126..5c6ea0c4 100644 --- a/docs/man/man1/sccfm-cli-policies-access-group-list.1 +++ b/docs/man/man1/sccfm-cli-policies-access-group-list.1 @@ -1,4 +1,4 @@ -.TH "SCCFM-CLI POLICIES ACCESS-GROUP LIST" "1" "2026-07-27" "0.38.0" "sccfm-cli policies access-group list Manual" +.TH "SCCFM-CLI POLICIES ACCESS-GROUP LIST" "1" "2026-08-11" "0.38.0" "sccfm-cli policies access-group list Manual" .SH NAME sccfm-cli\-policies\-access-group\-list \- List ASA access groups. .SH SYNOPSIS diff --git a/docs/man/man1/sccfm-cli-policies-access-group.1 b/docs/man/man1/sccfm-cli-policies-access-group.1 index 5628a0df..aa80132f 100644 --- a/docs/man/man1/sccfm-cli-policies-access-group.1 +++ b/docs/man/man1/sccfm-cli-policies-access-group.1 @@ -1,4 +1,4 @@ -.TH "SCCFM-CLI POLICIES ACCESS-GROUP" "1" "2026-07-27" "0.38.0" "sccfm-cli policies access-group Manual" +.TH "SCCFM-CLI POLICIES ACCESS-GROUP" "1" "2026-08-11" "0.38.0" "sccfm-cli policies access-group Manual" .SH NAME sccfm-cli\-policies\-access-group \- List and inspect ASA access groups. .SH SYNOPSIS diff --git a/docs/man/man1/sccfm-cli-policies-access-rule-create.1 b/docs/man/man1/sccfm-cli-policies-access-rule-create.1 index 459e2b5c..7d859bd4 100644 --- a/docs/man/man1/sccfm-cli-policies-access-rule-create.1 +++ b/docs/man/man1/sccfm-cli-policies-access-rule-create.1 @@ -1,4 +1,4 @@ -.TH "SCCFM-CLI POLICIES ACCESS-RULE CREATE" "1" "2026-07-27" "0.38.0" "sccfm-cli policies access-rule create Manual" +.TH "SCCFM-CLI POLICIES ACCESS-RULE CREATE" "1" "2026-08-11" "0.38.0" "sccfm-cli policies access-rule create Manual" .SH NAME sccfm-cli\-policies\-access-rule\-create \- Create an ASA access rule. .SH SYNOPSIS diff --git a/docs/man/man1/sccfm-cli-policies-access-rule-delete.1 b/docs/man/man1/sccfm-cli-policies-access-rule-delete.1 index 5ada2912..a0f16e0c 100644 --- a/docs/man/man1/sccfm-cli-policies-access-rule-delete.1 +++ b/docs/man/man1/sccfm-cli-policies-access-rule-delete.1 @@ -1,4 +1,4 @@ -.TH "SCCFM-CLI POLICIES ACCESS-RULE DELETE" "1" "2026-07-27" "0.38.0" "sccfm-cli policies access-rule delete Manual" +.TH "SCCFM-CLI POLICIES ACCESS-RULE DELETE" "1" "2026-08-11" "0.38.0" "sccfm-cli policies access-rule delete Manual" .SH NAME sccfm-cli\-policies\-access-rule\-delete \- Delete an ASA access rule. .SH SYNOPSIS diff --git a/docs/man/man1/sccfm-cli-policies-access-rule-get.1 b/docs/man/man1/sccfm-cli-policies-access-rule-get.1 index dc1b3cf3..c30818c3 100644 --- a/docs/man/man1/sccfm-cli-policies-access-rule-get.1 +++ b/docs/man/man1/sccfm-cli-policies-access-rule-get.1 @@ -1,4 +1,4 @@ -.TH "SCCFM-CLI POLICIES ACCESS-RULE GET" "1" "2026-07-27" "0.38.0" "sccfm-cli policies access-rule get Manual" +.TH "SCCFM-CLI POLICIES ACCESS-RULE GET" "1" "2026-08-11" "0.38.0" "sccfm-cli policies access-rule get Manual" .SH NAME sccfm-cli\-policies\-access-rule\-get \- Get an ASA access rule by UID. .SH SYNOPSIS diff --git a/docs/man/man1/sccfm-cli-policies-access-rule-list.1 b/docs/man/man1/sccfm-cli-policies-access-rule-list.1 index fb5da756..b9d903c4 100644 --- a/docs/man/man1/sccfm-cli-policies-access-rule-list.1 +++ b/docs/man/man1/sccfm-cli-policies-access-rule-list.1 @@ -1,4 +1,4 @@ -.TH "SCCFM-CLI POLICIES ACCESS-RULE LIST" "1" "2026-07-27" "0.38.0" "sccfm-cli policies access-rule list Manual" +.TH "SCCFM-CLI POLICIES ACCESS-RULE LIST" "1" "2026-08-11" "0.38.0" "sccfm-cli policies access-rule list Manual" .SH NAME sccfm-cli\-policies\-access-rule\-list \- List ASA access rules. .SH SYNOPSIS diff --git a/docs/man/man1/sccfm-cli-policies-access-rule-update.1 b/docs/man/man1/sccfm-cli-policies-access-rule-update.1 index d7478785..22866f77 100644 --- a/docs/man/man1/sccfm-cli-policies-access-rule-update.1 +++ b/docs/man/man1/sccfm-cli-policies-access-rule-update.1 @@ -1,4 +1,4 @@ -.TH "SCCFM-CLI POLICIES ACCESS-RULE UPDATE" "1" "2026-07-27" "0.38.0" "sccfm-cli policies access-rule update Manual" +.TH "SCCFM-CLI POLICIES ACCESS-RULE UPDATE" "1" "2026-08-11" "0.38.0" "sccfm-cli policies access-rule update Manual" .SH NAME sccfm-cli\-policies\-access-rule\-update \- Update an ASA access rule. .SH SYNOPSIS diff --git a/docs/man/man1/sccfm-cli-policies-access-rule.1 b/docs/man/man1/sccfm-cli-policies-access-rule.1 index 7942eae1..294d242c 100644 --- a/docs/man/man1/sccfm-cli-policies-access-rule.1 +++ b/docs/man/man1/sccfm-cli-policies-access-rule.1 @@ -1,4 +1,4 @@ -.TH "SCCFM-CLI POLICIES ACCESS-RULE" "1" "2026-07-27" "0.38.0" "sccfm-cli policies access-rule Manual" +.TH "SCCFM-CLI POLICIES ACCESS-RULE" "1" "2026-08-11" "0.38.0" "sccfm-cli policies access-rule Manual" .SH NAME sccfm-cli\-policies\-access-rule \- Manage ASA access rules. .SH SYNOPSIS diff --git a/docs/man/man1/sccfm-cli-policies.1 b/docs/man/man1/sccfm-cli-policies.1 index cf37fe1e..10cb5461 100644 --- a/docs/man/man1/sccfm-cli-policies.1 +++ b/docs/man/man1/sccfm-cli-policies.1 @@ -1,4 +1,4 @@ -.TH "SCCFM-CLI POLICIES" "1" "2026-07-27" "0.38.0" "sccfm-cli policies Manual" +.TH "SCCFM-CLI POLICIES" "1" "2026-08-11" "0.38.0" "sccfm-cli policies Manual" .SH NAME sccfm-cli\-policies \- Manage SCC Firewall Management policies. .SH SYNOPSIS diff --git a/docs/man/man1/sccfm-cli-schema-export.1 b/docs/man/man1/sccfm-cli-schema-export.1 index 93bd6e7e..2ea4ed5c 100644 --- a/docs/man/man1/sccfm-cli-schema-export.1 +++ b/docs/man/man1/sccfm-cli-schema-export.1 @@ -1,4 +1,4 @@ -.TH "SCCFM-CLI SCHEMA EXPORT" "1" "2026-07-27" "0.38.0" "sccfm-cli schema export Manual" +.TH "SCCFM-CLI SCHEMA EXPORT" "1" "2026-08-11" "0.38.0" "sccfm-cli schema export Manual" .SH NAME sccfm-cli\-schema\-export \- Export the sccfm-cli command schema. .SH SYNOPSIS diff --git a/docs/man/man1/sccfm-cli-schema.1 b/docs/man/man1/sccfm-cli-schema.1 index f37fa734..00ff6007 100644 --- a/docs/man/man1/sccfm-cli-schema.1 +++ b/docs/man/man1/sccfm-cli-schema.1 @@ -1,4 +1,4 @@ -.TH "SCCFM-CLI SCHEMA" "1" "2026-07-27" "0.38.0" "sccfm-cli schema Manual" +.TH "SCCFM-CLI SCHEMA" "1" "2026-08-11" "0.38.0" "sccfm-cli schema Manual" .SH NAME sccfm-cli\-schema \- Export machine-readable command metadata. .SH SYNOPSIS diff --git a/docs/man/man1/sccfm-cli-status.1 b/docs/man/man1/sccfm-cli-status.1 index 7d1fd694..b071d92e 100644 --- a/docs/man/man1/sccfm-cli-status.1 +++ b/docs/man/man1/sccfm-cli-status.1 @@ -1,4 +1,4 @@ -.TH "SCCFM-CLI STATUS" "1" "2026-07-27" "0.38.0" "sccfm-cli status Manual" +.TH "SCCFM-CLI STATUS" "1" "2026-08-11" "0.38.0" "sccfm-cli status Manual" .SH NAME sccfm-cli\-status \- Display the state of SCCFM subsystems. .SH SYNOPSIS diff --git a/docs/man/man1/sccfm-cli-transaction.1 b/docs/man/man1/sccfm-cli-transaction.1 index 8b82deb8..cc1078d5 100644 --- a/docs/man/man1/sccfm-cli-transaction.1 +++ b/docs/man/man1/sccfm-cli-transaction.1 @@ -1,4 +1,4 @@ -.TH "SCCFM-CLI TRANSACTION" "1" "2026-07-27" "0.38.0" "sccfm-cli transaction Manual" +.TH "SCCFM-CLI TRANSACTION" "1" "2026-08-11" "0.38.0" "sccfm-cli transaction Manual" .SH NAME sccfm-cli\-transaction \- Check transaction status by UID. .SH SYNOPSIS diff --git a/docs/man/man1/sccfm-cli.1 b/docs/man/man1/sccfm-cli.1 index 6d5809f5..8f0b27ef 100644 --- a/docs/man/man1/sccfm-cli.1 +++ b/docs/man/man1/sccfm-cli.1 @@ -1,4 +1,4 @@ -.TH "SCCFM-CLI" "1" "2026-07-27" "0.38.0" "sccfm-cli Manual" +.TH "SCCFM-CLI" "1" "2026-08-11" "0.38.0" "sccfm-cli Manual" .SH NAME sccfm-cli \- SCC Firewall Manager CLI .SH SYNOPSIS diff --git a/sccfm-ansible/README.md b/sccfm-ansible/README.md index 465ea000..90ee68af 100644 --- a/sccfm-ansible/README.md +++ b/sccfm-ansible/README.md @@ -176,8 +176,10 @@ printf "\n" export SCCFM_API_TOKEN ``` -For long-lived automation, use a secret manager or an Ansible Vault variable in a playbook-local -file. For example, create `vault.yml` with `ansible-vault create vault.yml` and store: +For long-lived automation, use a secret manager or an Ansible Vault variable. The packaged +examples automatically use a non-empty `vault_sccfm_api_token` from their adjacent encrypted +`group_vars/all/vault.yml`; otherwise they fall back to `SCCFM_API_TOKEN`. For a standalone +playbook, create `vault.yml` with `ansible-vault create vault.yml` and store: ```yaml --- @@ -189,10 +191,14 @@ Reference it without exposing the token: ```yaml vars_files: - vault.yml +vars: + sccfm_api_token_effective: >- + {{ vault_sccfm_api_token | + default(lookup('env', 'SCCFM_API_TOKEN'), true) }} module_defaults: group/cisco.sccfm.all: region: "{{ lookup('env', 'SCCFM_REGION') }}" - api_token: "{{ vault_sccfm_api_token }}" + api_token: "{{ sccfm_api_token_effective }}" ``` Run vault-backed playbooks with `--ask-vault-pass` or with your organization's approved vault @@ -276,7 +282,7 @@ Onboard an ASA device to your SCCFM tenant. hosts: localhost module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: @@ -337,7 +343,8 @@ documentation. Ansible Vault encrypts sensitive data such as API tokens and passwords. The local `vault.yml` and `.vault_pass` files are Git-ignored and excluded from collection artifacts; do not commit either -file, even when the vault is encrypted. +file, even when the vault is encrypted. Store the active API token as +`vault_sccfm_api_token`; packaged examples prefer a non-empty value over `SCCFM_API_TOKEN`. ### Vault Commands Reference @@ -388,10 +395,14 @@ Instead of repeating `region` and `api_token` for every task, use `module_defaul ```yaml - name: Manage SCCFM devices hosts: localhost + vars: + sccfm_api_token_effective: >- + {{ vault_sccfm_api_token | + default(lookup('env', 'SCCFM_API_TOKEN'), true) }} module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ sccfm_api_token_effective }}" tasks: - name: Onboard device 1 @@ -407,16 +418,26 @@ Instead of repeating `region` and `api_token` for every task, use `module_defaul ## Authentication Methods -Three ways to provide credentials (in order of precedence): +Packaged examples resolve authentication in this order: + +1. A non-empty encrypted **`vault_sccfm_api_token`** value +2. The controller **`SCCFM_API_TOKEN`** environment variable + +The resolved token is passed once through `module_defaults`; do not repeat it in each task. +Region remains controller-environment based: -1. **Module parameters** (explicit in task) -2. **Module defaults** (recommended - set once per playbook) -3. **Environment variables**: ```bash export SCCFM_REGION=us - # Inject SCCFM_API_TOKEN through your shell or secret manager as shown above. + # Inject SCCFM_API_TOKEN unless vault_sccfm_api_token is configured. ``` +`change-tokens` continues to write `sccfm_region` for compatibility with existing local projects, +but the packaged examples intentionally read `SCCFM_REGION`. + +For a one-time migration of an active-only Vault whose original region is unavailable, set +`SCCFM_LEGACY_REGION` to the old token's region. Do not infer it from the region of a newly added +token; the tool fails closed when the old region cannot be established safely. + ## Security Best Practices 1. **Never commit credential files**, including encrypted customer vaults, to your project @@ -436,14 +457,14 @@ Three ways to provide credentials (in order of precedence): - Ensure you're using the right vault password file ### "region is required" error -- Verify `sccfm_region` is set in `group_vars/all/vars.yml` -- Or set `SCCFM_REGION` environment variable +- Verify `SCCFM_REGION` is set in the controller environment - Or provide `region` parameter in module defaults ### "api_token is required" error -- Verify `SCCFM_API_TOKEN` is set in the controller environment -- Or provide a Vault-backed `api_token` parameter in module defaults; keep the Vault - playbook-local instead of placing it in inventory or `group_vars` +- Verify `SCCFM_API_TOKEN` is set in the controller environment, or set a non-empty + `vault_sccfm_api_token` in the encrypted Vault file loaded by the playbook +- If you maintain your own `module_defaults`, pass the effective token there instead of placing a + token directly in inventory ### Inventory returns no hosts - Check your API token has proper permissions @@ -462,9 +483,11 @@ are Git-ignored and excluded from collection artifacts: - **`execute_ftd_cli.yml`** - Execute show commands on cdFMC-managed FTD devices - **`asa_ha_check.yml`** - Run HA health checks on ASA failover devices - **`change_asa_boot_image.yml`** - Change the configured ASA boot image -- **`group_vars/all/vars.yml`** - Plain variables (region, defaults) -- **`group_vars/all/vault.yml`** - Locally generated encrypted secrets; never packaged -- **`group_vars/all/vault.yml.example`** - Template for vault structure +- **`group_vars/all/vars.yml`** - Compatibility variables and non-secret defaults; packaged + examples read region from `SCCFM_REGION` +- **`group_vars/all/vault.yml`** - Locally generated encrypted secrets, including + `vault_sccfm_api_token`; never packaged +- **`group_vars/all/vault.yml.example`** - Template using the supported Vault variable names ## Additional Resources diff --git a/sccfm-ansible/changelogs/changelog.yaml b/sccfm-ansible/changelogs/changelog.yaml index 0b4e2ebd..4bcde64d 100644 --- a/sccfm-ansible/changelogs/changelog.yaml +++ b/sccfm-ansible/changelogs/changelog.yaml @@ -1,5 +1,6 @@ --- ancestor: null +# sccfm-release-retarget-seed: 0.38.0 releases: 0.38.0: changes: diff --git a/sccfm-ansible/e2e/access_rules/playbooks/cleanup.yml b/sccfm-ansible/e2e/access_rules/playbooks/cleanup.yml index 67de20db..5aeb5d16 100644 --- a/sccfm-ansible/e2e/access_rules/playbooks/cleanup.yml +++ b/sccfm-ansible/e2e/access_rules/playbooks/cleanup.yml @@ -15,7 +15,7 @@ module_defaults: group/cisco.sccfm.all: region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + api_token: "{{ vault_sccfm_api_token }}" tasks: # ── Access rule cleanup ────────────────────────────────────── diff --git a/sccfm-ansible/e2e/access_rules/playbooks/create_access_rule.yml b/sccfm-ansible/e2e/access_rules/playbooks/create_access_rule.yml index 7e07d484..d409212a 100644 --- a/sccfm-ansible/e2e/access_rules/playbooks/create_access_rule.yml +++ b/sccfm-ansible/e2e/access_rules/playbooks/create_access_rule.yml @@ -13,7 +13,7 @@ module_defaults: group/cisco.sccfm.all: region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + api_token: "{{ vault_sccfm_api_token }}" tasks: - name: Read cached access group UID diff --git a/sccfm-ansible/e2e/access_rules/playbooks/delete_access_rule.yml b/sccfm-ansible/e2e/access_rules/playbooks/delete_access_rule.yml index 4b0d1467..fb8bd76b 100644 --- a/sccfm-ansible/e2e/access_rules/playbooks/delete_access_rule.yml +++ b/sccfm-ansible/e2e/access_rules/playbooks/delete_access_rule.yml @@ -13,7 +13,7 @@ module_defaults: group/cisco.sccfm.all: region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + api_token: "{{ vault_sccfm_api_token }}" tasks: - name: Read cached rule UID diff --git a/sccfm-ansible/e2e/access_rules/playbooks/delete_idempotency.yml b/sccfm-ansible/e2e/access_rules/playbooks/delete_idempotency.yml index 7231fdf8..b9d6adf1 100644 --- a/sccfm-ansible/e2e/access_rules/playbooks/delete_idempotency.yml +++ b/sccfm-ansible/e2e/access_rules/playbooks/delete_idempotency.yml @@ -17,7 +17,7 @@ module_defaults: group/cisco.sccfm.all: region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + api_token: "{{ vault_sccfm_api_token }}" tasks: - name: Read cached rule UID diff --git a/sccfm-ansible/e2e/access_rules/playbooks/get_access_group.yml b/sccfm-ansible/e2e/access_rules/playbooks/get_access_group.yml index 7378ca54..7075471d 100644 --- a/sccfm-ansible/e2e/access_rules/playbooks/get_access_group.yml +++ b/sccfm-ansible/e2e/access_rules/playbooks/get_access_group.yml @@ -13,7 +13,7 @@ module_defaults: group/cisco.sccfm.all: region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + api_token: "{{ vault_sccfm_api_token }}" tasks: - name: Read cached access group UID diff --git a/sccfm-ansible/e2e/access_rules/playbooks/list_access_groups.yml b/sccfm-ansible/e2e/access_rules/playbooks/list_access_groups.yml index e28fdad8..5aedd19c 100644 --- a/sccfm-ansible/e2e/access_rules/playbooks/list_access_groups.yml +++ b/sccfm-ansible/e2e/access_rules/playbooks/list_access_groups.yml @@ -13,7 +13,7 @@ module_defaults: group/cisco.sccfm.all: region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + api_token: "{{ vault_sccfm_api_token }}" tasks: - name: List access groups for CI ASA device (with retry for CDO sync) diff --git a/sccfm-ansible/e2e/access_rules/playbooks/list_access_rules.yml b/sccfm-ansible/e2e/access_rules/playbooks/list_access_rules.yml index 9172eb9e..beb306b1 100644 --- a/sccfm-ansible/e2e/access_rules/playbooks/list_access_rules.yml +++ b/sccfm-ansible/e2e/access_rules/playbooks/list_access_rules.yml @@ -13,7 +13,7 @@ module_defaults: group/cisco.sccfm.all: region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + api_token: "{{ vault_sccfm_api_token }}" tasks: - name: List access rules (first page) diff --git a/sccfm-ansible/e2e/access_rules/playbooks/provision_access_group.yml b/sccfm-ansible/e2e/access_rules/playbooks/provision_access_group.yml index c2b49482..80609da3 100644 --- a/sccfm-ansible/e2e/access_rules/playbooks/provision_access_group.yml +++ b/sccfm-ansible/e2e/access_rules/playbooks/provision_access_group.yml @@ -14,7 +14,7 @@ module_defaults: group/cisco.sccfm.all: region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + api_token: "{{ vault_sccfm_api_token }}" tasks: - name: Create source network object for access rule tests diff --git a/sccfm-ansible/e2e/access_rules/playbooks/update_access_rule.yml b/sccfm-ansible/e2e/access_rules/playbooks/update_access_rule.yml index cee2c076..982f3e43 100644 --- a/sccfm-ansible/e2e/access_rules/playbooks/update_access_rule.yml +++ b/sccfm-ansible/e2e/access_rules/playbooks/update_access_rule.yml @@ -13,7 +13,7 @@ module_defaults: group/cisco.sccfm.all: region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + api_token: "{{ vault_sccfm_api_token }}" tasks: - name: Read cached rule UID diff --git a/sccfm-ansible/e2e/access_rules/playbooks/update_idempotency.yml b/sccfm-ansible/e2e/access_rules/playbooks/update_idempotency.yml index 339f02ac..2fe3213f 100644 --- a/sccfm-ansible/e2e/access_rules/playbooks/update_idempotency.yml +++ b/sccfm-ansible/e2e/access_rules/playbooks/update_idempotency.yml @@ -15,7 +15,7 @@ module_defaults: group/cisco.sccfm.all: region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + api_token: "{{ vault_sccfm_api_token }}" tasks: - name: Read cached rule UID diff --git a/sccfm-ansible/e2e/access_rules/playbooks/verify_create.yml b/sccfm-ansible/e2e/access_rules/playbooks/verify_create.yml index ad3f359f..057c3ebd 100644 --- a/sccfm-ansible/e2e/access_rules/playbooks/verify_create.yml +++ b/sccfm-ansible/e2e/access_rules/playbooks/verify_create.yml @@ -13,7 +13,7 @@ module_defaults: group/cisco.sccfm.all: region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + api_token: "{{ vault_sccfm_api_token }}" tasks: - name: Read cached rule UID diff --git a/sccfm-ansible/e2e/access_rules/playbooks/verify_delete.yml b/sccfm-ansible/e2e/access_rules/playbooks/verify_delete.yml index 7cb6d181..9ab8ffb3 100644 --- a/sccfm-ansible/e2e/access_rules/playbooks/verify_delete.yml +++ b/sccfm-ansible/e2e/access_rules/playbooks/verify_delete.yml @@ -13,7 +13,7 @@ module_defaults: group/cisco.sccfm.all: region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + api_token: "{{ vault_sccfm_api_token }}" tasks: - name: Read cached rule UID diff --git a/sccfm-ansible/e2e/access_rules/playbooks/verify_update.yml b/sccfm-ansible/e2e/access_rules/playbooks/verify_update.yml index 8fcd7496..9f7888be 100644 --- a/sccfm-ansible/e2e/access_rules/playbooks/verify_update.yml +++ b/sccfm-ansible/e2e/access_rules/playbooks/verify_update.yml @@ -13,7 +13,7 @@ module_defaults: group/cisco.sccfm.all: region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + api_token: "{{ vault_sccfm_api_token }}" tasks: - name: Read cached rule UID diff --git a/sccfm-ansible/e2e/asa/playbooks/add_shun.yml b/sccfm-ansible/e2e/asa/playbooks/add_shun.yml index 42f13395..44fb4b87 100644 --- a/sccfm-ansible/e2e/asa/playbooks/add_shun.yml +++ b/sccfm-ansible/e2e/asa/playbooks/add_shun.yml @@ -13,7 +13,7 @@ module_defaults: group/cisco.sccfm.all: region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + api_token: "{{ vault_sccfm_api_token }}" tasks: - name: Add shun entries on CI ASA devices diff --git a/sccfm-ansible/e2e/asa/playbooks/cleanup.yml b/sccfm-ansible/e2e/asa/playbooks/cleanup.yml index 3728de78..ebb8504a 100644 --- a/sccfm-ansible/e2e/asa/playbooks/cleanup.yml +++ b/sccfm-ansible/e2e/asa/playbooks/cleanup.yml @@ -13,7 +13,7 @@ module_defaults: group/cisco.sccfm.all: region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + api_token: "{{ vault_sccfm_api_token }}" tasks: - name: Clear all shun entries on CI ASA devices diff --git a/sccfm-ansible/e2e/asa/playbooks/clear_shun.yml b/sccfm-ansible/e2e/asa/playbooks/clear_shun.yml index 9a6c1ab5..c426d201 100644 --- a/sccfm-ansible/e2e/asa/playbooks/clear_shun.yml +++ b/sccfm-ansible/e2e/asa/playbooks/clear_shun.yml @@ -13,7 +13,7 @@ module_defaults: group/cisco.sccfm.all: region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + api_token: "{{ vault_sccfm_api_token }}" tasks: - name: Clear all shun entries on CI ASA devices diff --git a/sccfm-ansible/e2e/asa/playbooks/execute_cli_read.yml b/sccfm-ansible/e2e/asa/playbooks/execute_cli_read.yml index f51782d9..3030d25b 100644 --- a/sccfm-ansible/e2e/asa/playbooks/execute_cli_read.yml +++ b/sccfm-ansible/e2e/asa/playbooks/execute_cli_read.yml @@ -13,7 +13,7 @@ module_defaults: group/cisco.sccfm.all: region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + api_token: "{{ vault_sccfm_api_token }}" tasks: - name: Execute read-only CLI commands on CI ASA devices diff --git a/sccfm-ansible/e2e/asa/playbooks/ha_check_assert_structure.yml b/sccfm-ansible/e2e/asa/playbooks/ha_check_assert_structure.yml index 87834208..96e7380d 100644 --- a/sccfm-ansible/e2e/asa/playbooks/ha_check_assert_structure.yml +++ b/sccfm-ansible/e2e/asa/playbooks/ha_check_assert_structure.yml @@ -13,7 +13,7 @@ module_defaults: group/cisco.sccfm.all: region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + api_token: "{{ vault_sccfm_api_token }}" tasks: - name: Run HA checks on CI HA-enabled ASA device diff --git a/sccfm-ansible/e2e/asa/playbooks/ha_check_by_uid.yml b/sccfm-ansible/e2e/asa/playbooks/ha_check_by_uid.yml index 4bf5d4af..b34a3e04 100644 --- a/sccfm-ansible/e2e/asa/playbooks/ha_check_by_uid.yml +++ b/sccfm-ansible/e2e/asa/playbooks/ha_check_by_uid.yml @@ -13,7 +13,7 @@ module_defaults: group/cisco.sccfm.all: region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + api_token: "{{ vault_sccfm_api_token }}" tasks: - name: Discover device UID via query diff --git a/sccfm-ansible/e2e/asa/playbooks/ha_check_query.yml b/sccfm-ansible/e2e/asa/playbooks/ha_check_query.yml index 89d3ca65..61626f36 100644 --- a/sccfm-ansible/e2e/asa/playbooks/ha_check_query.yml +++ b/sccfm-ansible/e2e/asa/playbooks/ha_check_query.yml @@ -13,7 +13,7 @@ module_defaults: group/cisco.sccfm.all: region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + api_token: "{{ vault_sccfm_api_token }}" tasks: - name: Run HA checks on CI HA-enabled ASA devices diff --git a/sccfm-ansible/e2e/asa/playbooks/ha_check_query_with_limit.yml b/sccfm-ansible/e2e/asa/playbooks/ha_check_query_with_limit.yml index 5fc18fb1..37dd3a63 100644 --- a/sccfm-ansible/e2e/asa/playbooks/ha_check_query_with_limit.yml +++ b/sccfm-ansible/e2e/asa/playbooks/ha_check_query_with_limit.yml @@ -13,7 +13,7 @@ module_defaults: group/cisco.sccfm.all: region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + api_token: "{{ vault_sccfm_api_token }}" tasks: - name: Run HA checks with limit of 1 diff --git a/sccfm-ansible/e2e/asa/playbooks/list_boot_registry.yml b/sccfm-ansible/e2e/asa/playbooks/list_boot_registry.yml index 8ac9d2bc..955a49db 100644 --- a/sccfm-ansible/e2e/asa/playbooks/list_boot_registry.yml +++ b/sccfm-ansible/e2e/asa/playbooks/list_boot_registry.yml @@ -13,7 +13,7 @@ module_defaults: group/cisco.sccfm.all: region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + api_token: "{{ vault_sccfm_api_token }}" tasks: - name: List boot registry on CI ASA devices diff --git a/sccfm-ansible/e2e/asa/playbooks/list_compatible_versions.yml b/sccfm-ansible/e2e/asa/playbooks/list_compatible_versions.yml index bb7b0a24..bafc5759 100644 --- a/sccfm-ansible/e2e/asa/playbooks/list_compatible_versions.yml +++ b/sccfm-ansible/e2e/asa/playbooks/list_compatible_versions.yml @@ -13,7 +13,7 @@ module_defaults: group/cisco.sccfm.all: region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + api_token: "{{ vault_sccfm_api_token }}" tasks: - name: List compatible versions for CI ASA devices diff --git a/sccfm-ansible/e2e/asa/playbooks/list_compatible_versions_for_upgrade.yml b/sccfm-ansible/e2e/asa/playbooks/list_compatible_versions_for_upgrade.yml index 0bc7ea82..75068524 100644 --- a/sccfm-ansible/e2e/asa/playbooks/list_compatible_versions_for_upgrade.yml +++ b/sccfm-ansible/e2e/asa/playbooks/list_compatible_versions_for_upgrade.yml @@ -13,7 +13,7 @@ module_defaults: group/cisco.sccfm.all: region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + api_token: "{{ vault_sccfm_api_token }}" tasks: - name: List compatible versions with per-device detail diff --git a/sccfm-ansible/e2e/asa/playbooks/list_disk_files.yml b/sccfm-ansible/e2e/asa/playbooks/list_disk_files.yml index c63930eb..8dc2c781 100644 --- a/sccfm-ansible/e2e/asa/playbooks/list_disk_files.yml +++ b/sccfm-ansible/e2e/asa/playbooks/list_disk_files.yml @@ -13,7 +13,7 @@ module_defaults: group/cisco.sccfm.all: region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + api_token: "{{ vault_sccfm_api_token }}" tasks: - name: List disk files on CI ASA devices diff --git a/sccfm-ansible/e2e/asa/playbooks/list_local_users.yml b/sccfm-ansible/e2e/asa/playbooks/list_local_users.yml index c3790cc0..fd7ae730 100644 --- a/sccfm-ansible/e2e/asa/playbooks/list_local_users.yml +++ b/sccfm-ansible/e2e/asa/playbooks/list_local_users.yml @@ -13,7 +13,7 @@ module_defaults: group/cisco.sccfm.all: region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + api_token: "{{ vault_sccfm_api_token }}" tasks: - name: List local users on CI ASA devices diff --git a/sccfm-ansible/e2e/asa/playbooks/list_not_on_version.yml b/sccfm-ansible/e2e/asa/playbooks/list_not_on_version.yml index dc77c177..56b6e439 100644 --- a/sccfm-ansible/e2e/asa/playbooks/list_not_on_version.yml +++ b/sccfm-ansible/e2e/asa/playbooks/list_not_on_version.yml @@ -13,7 +13,7 @@ module_defaults: group/cisco.sccfm.all: region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + api_token: "{{ vault_sccfm_api_token }}" tasks: - name: List ASA devices not on fake version diff --git a/sccfm-ansible/e2e/asa/playbooks/onboard_vasa.yml b/sccfm-ansible/e2e/asa/playbooks/onboard_vasa.yml index 4703262f..5ecb6f78 100644 --- a/sccfm-ansible/e2e/asa/playbooks/onboard_vasa.yml +++ b/sccfm-ansible/e2e/asa/playbooks/onboard_vasa.yml @@ -17,7 +17,7 @@ module_defaults: group/cisco.sccfm.all: region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + api_token: "{{ vault_sccfm_api_token }}" tasks: - name: Validate required environment variables diff --git a/sccfm-ansible/e2e/asa/playbooks/remove_shun.yml b/sccfm-ansible/e2e/asa/playbooks/remove_shun.yml index fd696534..f1dbdfde 100644 --- a/sccfm-ansible/e2e/asa/playbooks/remove_shun.yml +++ b/sccfm-ansible/e2e/asa/playbooks/remove_shun.yml @@ -13,7 +13,7 @@ module_defaults: group/cisco.sccfm.all: region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + api_token: "{{ vault_sccfm_api_token }}" tasks: - name: Remove one shun entry by source IP diff --git a/sccfm-ansible/e2e/asa/playbooks/show_shun.yml b/sccfm-ansible/e2e/asa/playbooks/show_shun.yml index 4c3a6b44..5ef19f82 100644 --- a/sccfm-ansible/e2e/asa/playbooks/show_shun.yml +++ b/sccfm-ansible/e2e/asa/playbooks/show_shun.yml @@ -13,7 +13,7 @@ module_defaults: group/cisco.sccfm.all: region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + api_token: "{{ vault_sccfm_api_token }}" tasks: - name: Show shun entries on CI ASA devices diff --git a/sccfm-ansible/e2e/asa/playbooks/show_shun_statistics.yml b/sccfm-ansible/e2e/asa/playbooks/show_shun_statistics.yml index 6e963f91..408f17c7 100644 --- a/sccfm-ansible/e2e/asa/playbooks/show_shun_statistics.yml +++ b/sccfm-ansible/e2e/asa/playbooks/show_shun_statistics.yml @@ -13,7 +13,7 @@ module_defaults: group/cisco.sccfm.all: region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + api_token: "{{ vault_sccfm_api_token }}" tasks: - name: Show shun statistics on CI ASA devices diff --git a/sccfm-ansible/e2e/asa/playbooks/trigger_upgrade_stage.yml b/sccfm-ansible/e2e/asa/playbooks/trigger_upgrade_stage.yml index 6804a64e..04ff62d4 100644 --- a/sccfm-ansible/e2e/asa/playbooks/trigger_upgrade_stage.yml +++ b/sccfm-ansible/e2e/asa/playbooks/trigger_upgrade_stage.yml @@ -13,7 +13,7 @@ module_defaults: group/cisco.sccfm.all: region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + api_token: "{{ vault_sccfm_api_token }}" tasks: - name: Skip if upgrade version is not configured diff --git a/sccfm-ansible/e2e/asa/playbooks/verify_boot_registry_after_stage.yml b/sccfm-ansible/e2e/asa/playbooks/verify_boot_registry_after_stage.yml index bb4a79fb..18f180b6 100644 --- a/sccfm-ansible/e2e/asa/playbooks/verify_boot_registry_after_stage.yml +++ b/sccfm-ansible/e2e/asa/playbooks/verify_boot_registry_after_stage.yml @@ -13,7 +13,7 @@ module_defaults: group/cisco.sccfm.all: region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + api_token: "{{ vault_sccfm_api_token }}" tasks: - name: List boot registry after staging diff --git a/sccfm-ansible/e2e/asa/playbooks/verify_shun_cleared.yml b/sccfm-ansible/e2e/asa/playbooks/verify_shun_cleared.yml index 0162e3ea..34cc0f36 100644 --- a/sccfm-ansible/e2e/asa/playbooks/verify_shun_cleared.yml +++ b/sccfm-ansible/e2e/asa/playbooks/verify_shun_cleared.yml @@ -13,7 +13,7 @@ module_defaults: group/cisco.sccfm.all: region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + api_token: "{{ vault_sccfm_api_token }}" tasks: - name: Show shun entries after clear diff --git a/sccfm-ansible/e2e/ftd/playbooks/deploy_ftd.yml b/sccfm-ansible/e2e/ftd/playbooks/deploy_ftd.yml index 40ae07e7..cc8e23ed 100644 --- a/sccfm-ansible/e2e/ftd/playbooks/deploy_ftd.yml +++ b/sccfm-ansible/e2e/ftd/playbooks/deploy_ftd.yml @@ -13,7 +13,7 @@ module_defaults: group/cisco.sccfm.all: region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + api_token: "{{ vault_sccfm_api_token }}" tasks: - name: Deploy configuration to CI FTD devices diff --git a/sccfm-ansible/e2e/ftd/playbooks/list_compatible_versions.yml b/sccfm-ansible/e2e/ftd/playbooks/list_compatible_versions.yml index b99dcc71..ad31708c 100644 --- a/sccfm-ansible/e2e/ftd/playbooks/list_compatible_versions.yml +++ b/sccfm-ansible/e2e/ftd/playbooks/list_compatible_versions.yml @@ -13,7 +13,7 @@ module_defaults: group/cisco.sccfm.all: region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + api_token: "{{ vault_sccfm_api_token }}" tasks: - name: List compatible versions for CI FTD devices diff --git a/sccfm-ansible/e2e/ftd/playbooks/list_compatible_versions_for_upgrade.yml b/sccfm-ansible/e2e/ftd/playbooks/list_compatible_versions_for_upgrade.yml index 0656834b..cf0231be 100644 --- a/sccfm-ansible/e2e/ftd/playbooks/list_compatible_versions_for_upgrade.yml +++ b/sccfm-ansible/e2e/ftd/playbooks/list_compatible_versions_for_upgrade.yml @@ -13,7 +13,7 @@ module_defaults: group/cisco.sccfm.all: region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + api_token: "{{ vault_sccfm_api_token }}" tasks: - name: List compatible versions with per-device detail diff --git a/sccfm-ansible/e2e/ftd/playbooks/list_not_on_recommended.yml b/sccfm-ansible/e2e/ftd/playbooks/list_not_on_recommended.yml index 7cbd3d24..1822771e 100644 --- a/sccfm-ansible/e2e/ftd/playbooks/list_not_on_recommended.yml +++ b/sccfm-ansible/e2e/ftd/playbooks/list_not_on_recommended.yml @@ -13,7 +13,7 @@ module_defaults: group/cisco.sccfm.all: region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + api_token: "{{ vault_sccfm_api_token }}" tasks: - name: List FTD devices not on recommended version diff --git a/sccfm-ansible/e2e/ftd/playbooks/list_not_on_version.yml b/sccfm-ansible/e2e/ftd/playbooks/list_not_on_version.yml index 34fd80e9..1dc96a47 100644 --- a/sccfm-ansible/e2e/ftd/playbooks/list_not_on_version.yml +++ b/sccfm-ansible/e2e/ftd/playbooks/list_not_on_version.yml @@ -13,7 +13,7 @@ module_defaults: group/cisco.sccfm.all: region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + api_token: "{{ vault_sccfm_api_token }}" tasks: - name: List FTD devices not on fake version diff --git a/sccfm-ansible/e2e/ftd/playbooks/trigger_upgrade_stage.yml b/sccfm-ansible/e2e/ftd/playbooks/trigger_upgrade_stage.yml index cb13f2b4..9d5a45e7 100644 --- a/sccfm-ansible/e2e/ftd/playbooks/trigger_upgrade_stage.yml +++ b/sccfm-ansible/e2e/ftd/playbooks/trigger_upgrade_stage.yml @@ -13,7 +13,7 @@ module_defaults: group/cisco.sccfm.all: region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + api_token: "{{ vault_sccfm_api_token }}" tasks: - name: Skip if upgrade version is not configured diff --git a/sccfm-ansible/e2e/objects/playbooks/cleanup.yml b/sccfm-ansible/e2e/objects/playbooks/cleanup.yml index 366cdd1f..53f501b0 100644 --- a/sccfm-ansible/e2e/objects/playbooks/cleanup.yml +++ b/sccfm-ansible/e2e/objects/playbooks/cleanup.yml @@ -13,7 +13,7 @@ module_defaults: group/cisco.sccfm.all: region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + api_token: "{{ vault_sccfm_api_token }}" tasks: - name: Delete test group diff --git a/sccfm-ansible/e2e/objects/playbooks/create_idempotency.yml b/sccfm-ansible/e2e/objects/playbooks/create_idempotency.yml index 5576d78a..5b05b8bf 100644 --- a/sccfm-ansible/e2e/objects/playbooks/create_idempotency.yml +++ b/sccfm-ansible/e2e/objects/playbooks/create_idempotency.yml @@ -13,7 +13,7 @@ module_defaults: group/cisco.sccfm.all: region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + api_token: "{{ vault_sccfm_api_token }}" tasks: - name: Re-create same objects diff --git a/sccfm-ansible/e2e/objects/playbooks/create_network_group.yml b/sccfm-ansible/e2e/objects/playbooks/create_network_group.yml index a21b0c8e..cdde594b 100644 --- a/sccfm-ansible/e2e/objects/playbooks/create_network_group.yml +++ b/sccfm-ansible/e2e/objects/playbooks/create_network_group.yml @@ -13,7 +13,7 @@ module_defaults: group/cisco.sccfm.all: region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + api_token: "{{ vault_sccfm_api_token }}" tasks: - name: Create network group with literals diff --git a/sccfm-ansible/e2e/objects/playbooks/create_network_objects.yml b/sccfm-ansible/e2e/objects/playbooks/create_network_objects.yml index e8ddf1c5..677066f9 100644 --- a/sccfm-ansible/e2e/objects/playbooks/create_network_objects.yml +++ b/sccfm-ansible/e2e/objects/playbooks/create_network_objects.yml @@ -13,7 +13,7 @@ module_defaults: group/cisco.sccfm.all: region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + api_token: "{{ vault_sccfm_api_token }}" tasks: - name: Create network objects diff --git a/sccfm-ansible/e2e/objects/playbooks/delete_idempotency.yml b/sccfm-ansible/e2e/objects/playbooks/delete_idempotency.yml index 1fa10707..caa224f0 100644 --- a/sccfm-ansible/e2e/objects/playbooks/delete_idempotency.yml +++ b/sccfm-ansible/e2e/objects/playbooks/delete_idempotency.yml @@ -13,7 +13,7 @@ module_defaults: group/cisco.sccfm.all: region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + api_token: "{{ vault_sccfm_api_token }}" tasks: - name: Re-delete group diff --git a/sccfm-ansible/e2e/objects/playbooks/delete_network_group.yml b/sccfm-ansible/e2e/objects/playbooks/delete_network_group.yml index ea930038..a48fa203 100644 --- a/sccfm-ansible/e2e/objects/playbooks/delete_network_group.yml +++ b/sccfm-ansible/e2e/objects/playbooks/delete_network_group.yml @@ -13,7 +13,7 @@ module_defaults: group/cisco.sccfm.all: region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + api_token: "{{ vault_sccfm_api_token }}" tasks: - name: Delete network group diff --git a/sccfm-ansible/e2e/objects/playbooks/delete_network_objects.yml b/sccfm-ansible/e2e/objects/playbooks/delete_network_objects.yml index 18859819..7e84a09b 100644 --- a/sccfm-ansible/e2e/objects/playbooks/delete_network_objects.yml +++ b/sccfm-ansible/e2e/objects/playbooks/delete_network_objects.yml @@ -13,7 +13,7 @@ module_defaults: group/cisco.sccfm.all: region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + api_token: "{{ vault_sccfm_api_token }}" tasks: - name: Delete network objects diff --git a/sccfm-ansible/e2e/objects/playbooks/update_idempotency.yml b/sccfm-ansible/e2e/objects/playbooks/update_idempotency.yml index 093a1b5e..35a915e8 100644 --- a/sccfm-ansible/e2e/objects/playbooks/update_idempotency.yml +++ b/sccfm-ansible/e2e/objects/playbooks/update_idempotency.yml @@ -13,7 +13,7 @@ module_defaults: group/cisco.sccfm.all: region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + api_token: "{{ vault_sccfm_api_token }}" tasks: - name: Re-update host diff --git a/sccfm-ansible/e2e/objects/playbooks/update_network_group.yml b/sccfm-ansible/e2e/objects/playbooks/update_network_group.yml index a155678a..aef33b23 100644 --- a/sccfm-ansible/e2e/objects/playbooks/update_network_group.yml +++ b/sccfm-ansible/e2e/objects/playbooks/update_network_group.yml @@ -13,7 +13,7 @@ module_defaults: group/cisco.sccfm.all: region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + api_token: "{{ vault_sccfm_api_token }}" tasks: - name: Update group to reference test objects diff --git a/sccfm-ansible/e2e/objects/playbooks/update_network_objects.yml b/sccfm-ansible/e2e/objects/playbooks/update_network_objects.yml index f892cff7..80fa6faa 100644 --- a/sccfm-ansible/e2e/objects/playbooks/update_network_objects.yml +++ b/sccfm-ansible/e2e/objects/playbooks/update_network_objects.yml @@ -13,7 +13,7 @@ module_defaults: group/cisco.sccfm.all: region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + api_token: "{{ vault_sccfm_api_token }}" tasks: - name: Update host IP address and description diff --git a/sccfm-ansible/e2e/objects/playbooks/verify_create.yml b/sccfm-ansible/e2e/objects/playbooks/verify_create.yml index 9c5e3f65..1d36a15c 100644 --- a/sccfm-ansible/e2e/objects/playbooks/verify_create.yml +++ b/sccfm-ansible/e2e/objects/playbooks/verify_create.yml @@ -13,7 +13,7 @@ module_defaults: group/cisco.sccfm.all: region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + api_token: "{{ vault_sccfm_api_token }}" tasks: - name: List ci-test network objects diff --git a/sccfm-ansible/e2e/objects/playbooks/verify_delete.yml b/sccfm-ansible/e2e/objects/playbooks/verify_delete.yml index 561349ba..eb211ccf 100644 --- a/sccfm-ansible/e2e/objects/playbooks/verify_delete.yml +++ b/sccfm-ansible/e2e/objects/playbooks/verify_delete.yml @@ -13,7 +13,7 @@ module_defaults: group/cisco.sccfm.all: region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + api_token: "{{ vault_sccfm_api_token }}" tasks: - name: List ci-test objects after deletion diff --git a/sccfm-ansible/e2e/objects/playbooks/verify_group.yml b/sccfm-ansible/e2e/objects/playbooks/verify_group.yml index 13bb19a2..a874a23a 100644 --- a/sccfm-ansible/e2e/objects/playbooks/verify_group.yml +++ b/sccfm-ansible/e2e/objects/playbooks/verify_group.yml @@ -13,7 +13,7 @@ module_defaults: group/cisco.sccfm.all: region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + api_token: "{{ vault_sccfm_api_token }}" tasks: - name: List ci-test network groups diff --git a/sccfm-ansible/e2e/objects/playbooks/verify_update.yml b/sccfm-ansible/e2e/objects/playbooks/verify_update.yml index e87c2ff7..a72bca05 100644 --- a/sccfm-ansible/e2e/objects/playbooks/verify_update.yml +++ b/sccfm-ansible/e2e/objects/playbooks/verify_update.yml @@ -13,7 +13,7 @@ module_defaults: group/cisco.sccfm.all: region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + api_token: "{{ vault_sccfm_api_token }}" tasks: - name: List ci-test-host-01 diff --git a/sccfm-ansible/examples/access_rules.yml b/sccfm-ansible/examples/access_rules.yml index 82e915d1..1860dca5 100644 --- a/sccfm-ansible/examples/access_rules.yml +++ b/sccfm-ansible/examples/access_rules.yml @@ -12,7 +12,7 @@ # # PREREQUISITES: # - SCCFM_REGION and SCCFM_API_TOKEN environment variables set, OR -# - Region and API token in group_vars/all/vars.yml +# - SCCFM_REGION set and vault_sccfm_api_token in encrypted group_vars/all/vault.yml # - A valid device UID and access group UID # - Existing source and destination network objects referenced by the rule @@ -23,10 +23,11 @@ # Use module_defaults to avoid repeating region and api_token module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ sccfm_api_token_effective }}" vars: + sccfm_api_token_effective: "{{ vault_sccfm_api_token | default(lookup('env', 'SCCFM_API_TOKEN'), true) }}" device_uid: "YOUR_DEVICE_UID" access_group_uid: "YOUR_ACCESS_GROUP_UID" diff --git a/sccfm-ansible/examples/add_object_override.yml b/sccfm-ansible/examples/add_object_override.yml index 28b8512c..2b82ffb6 100644 --- a/sccfm-ansible/examples/add_object_override.yml +++ b/sccfm-ansible/examples/add_object_override.yml @@ -23,11 +23,13 @@ - name: Object override lifecycle hosts: localhost gather_facts: false + vars: + sccfm_api_token_effective: "{{ vault_sccfm_api_token | default(lookup('env', 'SCCFM_API_TOKEN'), true) }}" module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ sccfm_api_token_effective }}" tasks: # ------------------------------------------------------------------------- diff --git a/sccfm-ansible/examples/asa_ha_check.yml b/sccfm-ansible/examples/asa_ha_check.yml index 819e1f53..8442353a 100644 --- a/sccfm-ansible/examples/asa_ha_check.yml +++ b/sccfm-ansible/examples/asa_ha_check.yml @@ -22,10 +22,11 @@ module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ sccfm_api_token_effective }}" vars: + sccfm_api_token_effective: "{{ vault_sccfm_api_token | default(lookup('env', 'SCCFM_API_TOKEN'), true) }}" device_uid: "" tasks: diff --git a/sccfm-ansible/examples/change_asa_boot_image.yml b/sccfm-ansible/examples/change_asa_boot_image.yml index d35173b7..75ffaab6 100644 --- a/sccfm-ansible/examples/change_asa_boot_image.yml +++ b/sccfm-ansible/examples/change_asa_boot_image.yml @@ -1,10 +1,12 @@ - name: Change ASA boot image via SCCFM hosts: localhost gather_facts: false + vars: + sccfm_api_token_effective: "{{ vault_sccfm_api_token | default(lookup('env', 'SCCFM_API_TOKEN'), true) }}" module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ sccfm_api_token_effective }}" tasks: - name: Preview boot image change on branch ASAs diff --git a/sccfm-ansible/examples/change_asa_local_password.yml b/sccfm-ansible/examples/change_asa_local_password.yml index ffe81b3e..8e02aed7 100644 --- a/sccfm-ansible/examples/change_asa_local_password.yml +++ b/sccfm-ansible/examples/change_asa_local_password.yml @@ -17,7 +17,7 @@ # # PREREQUISITES: # - SCCFM_REGION and SCCFM_API_TOKEN environment variables set, OR -# - Region and API token in group_vars/all/vars.yml +# - SCCFM_REGION set and vault_sccfm_api_token in encrypted group_vars/all/vault.yml # # NOTE: Config commands require the device to be in SYNCED state. # Passwords with 3+ sequential or repetitive characters (e.g. "1234", @@ -29,10 +29,11 @@ module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ sccfm_api_token_effective }}" vars: + sccfm_api_token_effective: "{{ vault_sccfm_api_token | default(lookup('env', 'SCCFM_API_TOKEN'), true) }}" device_uid: "" test_user: devkit-example-user initial_password: "{{ vault_initial_password | default('') }}" diff --git a/sccfm-ansible/examples/create_network_groups.yml b/sccfm-ansible/examples/create_network_groups.yml index a0ed921e..861cfc53 100644 --- a/sccfm-ansible/examples/create_network_groups.yml +++ b/sccfm-ansible/examples/create_network_groups.yml @@ -6,7 +6,7 @@ # # PREREQUISITES: # - SCCFM_REGION and SCCFM_API_TOKEN environment variables set, OR -# - Region and API token in group_vars/all/vars.yml +# - SCCFM_REGION set and vault_sccfm_api_token in encrypted group_vars/all/vault.yml # - Run the create network_objects.yml playbook first to create any referenced network objects @@ -17,10 +17,11 @@ # Use module_defaults to avoid repeating region and api_token module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ sccfm_api_token_effective }}" vars: + sccfm_api_token_effective: "{{ vault_sccfm_api_token | default(lookup('env', 'SCCFM_API_TOKEN'), true) }}" network_groups: - name: web-servers # network_literals can be IP addresses or CIDR subnets diff --git a/sccfm-ansible/examples/create_network_objects.yml b/sccfm-ansible/examples/create_network_objects.yml index 7ed296ce..e02af33e 100644 --- a/sccfm-ansible/examples/create_network_objects.yml +++ b/sccfm-ansible/examples/create_network_objects.yml @@ -10,7 +10,7 @@ # # PREREQUISITES: # - SCCFM_REGION and SCCFM_API_TOKEN environment variables set, OR -# - Region and API token in group_vars/all/vars.yml +# - SCCFM_REGION set and vault_sccfm_api_token in encrypted group_vars/all/vault.yml - name: Create network objects in SCC Firewall Manager hosts: localhost @@ -19,10 +19,11 @@ # Use module_defaults to avoid repeating region and api_token module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ sccfm_api_token_effective }}" vars: + sccfm_api_token_effective: "{{ vault_sccfm_api_token | default(lookup('env', 'SCCFM_API_TOKEN'), true) }}" # Define network objects to create network_objects: - name: web-server-01 diff --git a/sccfm-ansible/examples/delete_network_groups.yml b/sccfm-ansible/examples/delete_network_groups.yml index 6d8bcd47..489ee256 100644 --- a/sccfm-ansible/examples/delete_network_groups.yml +++ b/sccfm-ansible/examples/delete_network_groups.yml @@ -10,7 +10,7 @@ # # PREREQUISITES: # - SCCFM_REGION and SCCFM_API_TOKEN environment variables set, OR -# - Region and API token in group_vars/all/vars.yml +# - SCCFM_REGION set and vault_sccfm_api_token in encrypted group_vars/all/vault.yml - name: Delete network groups in SCC Firewall Manager hosts: localhost @@ -19,10 +19,11 @@ # Use module_defaults to avoid repeating region and api_token module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ sccfm_api_token_effective }}" vars: + sccfm_api_token_effective: "{{ vault_sccfm_api_token | default(lookup('env', 'SCCFM_API_TOKEN'), true) }}" # Define network groups to delete by name network_groups: - web-servers diff --git a/sccfm-ansible/examples/delete_network_objects.yml b/sccfm-ansible/examples/delete_network_objects.yml index 702b4811..9673bf7e 100644 --- a/sccfm-ansible/examples/delete_network_objects.yml +++ b/sccfm-ansible/examples/delete_network_objects.yml @@ -10,7 +10,7 @@ # # PREREQUISITES: # - SCCFM_REGION and SCCFM_API_TOKEN environment variables set, OR -# - Region and API token in group_vars/all/vars.yml +# - SCCFM_REGION set and vault_sccfm_api_token in encrypted group_vars/all/vault.yml - name: Delete network objects in SCC Firewall Manager hosts: localhost @@ -19,10 +19,11 @@ # Use module_defaults to avoid repeating region and api_token module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ sccfm_api_token_effective }}" vars: + sccfm_api_token_effective: "{{ vault_sccfm_api_token | default(lookup('env', 'SCCFM_API_TOKEN'), true) }}" # Define network objects to delete by name network_objects: - web-server-01 diff --git a/sccfm-ansible/examples/deploy_cdfmc_ftd.yml b/sccfm-ansible/examples/deploy_cdfmc_ftd.yml index 81c0cae3..1f4dd5fa 100644 --- a/sccfm-ansible/examples/deploy_cdfmc_ftd.yml +++ b/sccfm-ansible/examples/deploy_cdfmc_ftd.yml @@ -32,10 +32,11 @@ module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ sccfm_api_token_effective }}" vars: + sccfm_api_token_effective: "{{ vault_sccfm_api_token | default(lookup('env', 'SCCFM_API_TOKEN'), true) }}" device_uids: [] deployment_notes: "" description: "" diff --git a/sccfm-ansible/examples/execute_asa_cli.yml b/sccfm-ansible/examples/execute_asa_cli.yml index 31199d96..91dbef8c 100644 --- a/sccfm-ansible/examples/execute_asa_cli.yml +++ b/sccfm-ansible/examples/execute_asa_cli.yml @@ -21,10 +21,11 @@ # Use module_defaults to avoid repeating region and api_token module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ sccfm_api_token_effective }}" vars: + sccfm_api_token_effective: "{{ vault_sccfm_api_token | default(lookup('env', 'SCCFM_API_TOKEN'), true) }}" device_uid: "" show_commands: - "show version" diff --git a/sccfm-ansible/examples/execute_ftd_cli.yml b/sccfm-ansible/examples/execute_ftd_cli.yml index 81941094..380aa370 100644 --- a/sccfm-ansible/examples/execute_ftd_cli.yml +++ b/sccfm-ansible/examples/execute_ftd_cli.yml @@ -16,10 +16,11 @@ module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ sccfm_api_token_effective }}" vars: + sccfm_api_token_effective: "{{ vault_sccfm_api_token | default(lookup('env', 'SCCFM_API_TOKEN'), true) }}" device_uid: "" tasks: diff --git a/sccfm-ansible/examples/group_vars/all/vars.yml b/sccfm-ansible/examples/group_vars/all/vars.yml index a36eeb6b..0fb82843 100644 --- a/sccfm-ansible/examples/group_vars/all/vars.yml +++ b/sccfm-ansible/examples/group_vars/all/vars.yml @@ -2,8 +2,8 @@ # Plain variables (not sensitive) # These can be committed to version control -# SCCFM connection settings -sccfm_region: int +# Compatibility setting for existing projects; packaged examples read SCCFM_REGION directly +sccfm_region: "{{ lookup('env', 'SCCFM_REGION') }}" # Common ASA configuration default_asa_username: asavuser diff --git a/sccfm-ansible/examples/group_vars/all/vault.yml.example b/sccfm-ansible/examples/group_vars/all/vault.yml.example index e1efeb1b..c6037eed 100644 --- a/sccfm-ansible/examples/group_vars/all/vault.yml.example +++ b/sccfm-ansible/examples/group_vars/all/vault.yml.example @@ -12,7 +12,7 @@ # SCC Firewall Manager API token # Obtain this from your CDO/SCCFM tenant -sccfm_api_token: "your-api-token-here-abc123xyz789" +vault_sccfm_api_token: "your-api-token-here-abc123xyz789" # ASA device passwords - each device can have its own password # Use descriptive names that match your device naming in the playbook diff --git a/sccfm-ansible/examples/list_asa_boot_registry.yml b/sccfm-ansible/examples/list_asa_boot_registry.yml index e0eba74a..00167e29 100644 --- a/sccfm-ansible/examples/list_asa_boot_registry.yml +++ b/sccfm-ansible/examples/list_asa_boot_registry.yml @@ -20,10 +20,11 @@ module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ sccfm_api_token_effective }}" vars: + sccfm_api_token_effective: "{{ vault_sccfm_api_token | default(lookup('env', 'SCCFM_API_TOKEN'), true) }}" device_uid: "" tasks: diff --git a/sccfm-ansible/examples/list_asa_compatible_versions.yml b/sccfm-ansible/examples/list_asa_compatible_versions.yml index 47ac08e5..4e421072 100644 --- a/sccfm-ansible/examples/list_asa_compatible_versions.yml +++ b/sccfm-ansible/examples/list_asa_compatible_versions.yml @@ -22,10 +22,11 @@ module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ sccfm_api_token_effective }}" vars: + sccfm_api_token_effective: "{{ vault_sccfm_api_token | default(lookup('env', 'SCCFM_API_TOKEN'), true) }}" device_uid: "" device_uids: [] discovery_query: "connectivityState:ONLINE AND configState:SYNCED" diff --git a/sccfm-ansible/examples/list_asa_disk_files.yml b/sccfm-ansible/examples/list_asa_disk_files.yml index 4d528465..8e045d4f 100644 --- a/sccfm-ansible/examples/list_asa_disk_files.yml +++ b/sccfm-ansible/examples/list_asa_disk_files.yml @@ -22,10 +22,11 @@ module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ sccfm_api_token_effective }}" vars: + sccfm_api_token_effective: "{{ vault_sccfm_api_token | default(lookup('env', 'SCCFM_API_TOKEN'), true) }}" device_uid: "" tasks: diff --git a/sccfm-ansible/examples/list_asa_local_users.yml b/sccfm-ansible/examples/list_asa_local_users.yml index f0387016..fea532e5 100644 --- a/sccfm-ansible/examples/list_asa_local_users.yml +++ b/sccfm-ansible/examples/list_asa_local_users.yml @@ -1,10 +1,12 @@ --- - hosts: localhost gather_facts: false + vars: + sccfm_api_token_effective: "{{ vault_sccfm_api_token | default(lookup('env', 'SCCFM_API_TOKEN'), true) }}" module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ sccfm_api_token_effective }}" tasks: - name: Get list of ASA local users for UIDs cisco.sccfm.list_asa_local_users: diff --git a/sccfm-ansible/examples/list_asa_not_on_version.yml b/sccfm-ansible/examples/list_asa_not_on_version.yml index f767bb6b..7bdd490d 100644 --- a/sccfm-ansible/examples/list_asa_not_on_version.yml +++ b/sccfm-ansible/examples/list_asa_not_on_version.yml @@ -26,10 +26,11 @@ module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ sccfm_api_token_effective }}" vars: + sccfm_api_token_effective: "{{ vault_sccfm_api_token | default(lookup('env', 'SCCFM_API_TOKEN'), true) }}" target_version: "" check_query: "" diff --git a/sccfm-ansible/examples/list_ftd_compatible_versions.yml b/sccfm-ansible/examples/list_ftd_compatible_versions.yml index a231d6c5..c5322858 100644 --- a/sccfm-ansible/examples/list_ftd_compatible_versions.yml +++ b/sccfm-ansible/examples/list_ftd_compatible_versions.yml @@ -22,10 +22,11 @@ module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ sccfm_api_token_effective }}" vars: + sccfm_api_token_effective: "{{ vault_sccfm_api_token | default(lookup('env', 'SCCFM_API_TOKEN'), true) }}" device_uid: "" device_uids: [] discovery_query: "deviceType:CDFMC_MANAGED_FTD AND connectivityState:ONLINE AND configState:SYNCED" diff --git a/sccfm-ansible/examples/list_ftd_not_on_version.yml b/sccfm-ansible/examples/list_ftd_not_on_version.yml index 89ee6d34..0dee3452 100644 --- a/sccfm-ansible/examples/list_ftd_not_on_version.yml +++ b/sccfm-ansible/examples/list_ftd_not_on_version.yml @@ -25,10 +25,11 @@ module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ sccfm_api_token_effective }}" vars: + sccfm_api_token_effective: "{{ vault_sccfm_api_token | default(lookup('env', 'SCCFM_API_TOKEN'), true) }}" target_version: "" check_query: "" diff --git a/sccfm-ansible/examples/list_network_groups.yml b/sccfm-ansible/examples/list_network_groups.yml index a61f1eeb..df393ff5 100644 --- a/sccfm-ansible/examples/list_network_groups.yml +++ b/sccfm-ansible/examples/list_network_groups.yml @@ -6,17 +6,19 @@ # # PREREQUISITES: # - SCCFM_REGION and SCCFM_API_TOKEN environment variables set, OR -# - Region and API token in group_vars/all/vars.yml +# - SCCFM_REGION set and vault_sccfm_api_token in encrypted group_vars/all/vault.yml - name: List network groups in SCC Firewall Manager hosts: localhost gather_facts: false + vars: + sccfm_api_token_effective: "{{ vault_sccfm_api_token | default(lookup('env', 'SCCFM_API_TOKEN'), true) }}" # Use module_defaults to avoid repeating region and api_token module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ sccfm_api_token_effective }}" tasks: # ============================================================ diff --git a/sccfm-ansible/examples/list_network_objects.yml b/sccfm-ansible/examples/list_network_objects.yml index 8ed521a2..5ca7efe7 100644 --- a/sccfm-ansible/examples/list_network_objects.yml +++ b/sccfm-ansible/examples/list_network_objects.yml @@ -6,17 +6,19 @@ # # PREREQUISITES: # - SCCFM_REGION and SCCFM_API_TOKEN environment variables set, OR -# - Region and API token in group_vars/all/vars.yml +# - SCCFM_REGION set and vault_sccfm_api_token in encrypted group_vars/all/vault.yml - name: List network objects in SCC Firewall Manager hosts: localhost gather_facts: false + vars: + sccfm_api_token_effective: "{{ vault_sccfm_api_token | default(lookup('env', 'SCCFM_API_TOKEN'), true) }}" # Use module_defaults to avoid repeating region and api_token module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ sccfm_api_token_effective }}" tasks: # ============================================================ diff --git a/sccfm-ansible/examples/manage_asa_shun.yml b/sccfm-ansible/examples/manage_asa_shun.yml index 66b9bdc2..8f3ad3bf 100644 --- a/sccfm-ansible/examples/manage_asa_shun.yml +++ b/sccfm-ansible/examples/manage_asa_shun.yml @@ -13,10 +13,11 @@ module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ sccfm_api_token_effective }}" vars: + sccfm_api_token_effective: "{{ vault_sccfm_api_token | default(lookup('env', 'SCCFM_API_TOKEN'), true) }}" device_uid: "" tasks: diff --git a/sccfm-ansible/examples/manage_network_group_members.yml b/sccfm-ansible/examples/manage_network_group_members.yml index 12623db8..a34749c9 100644 --- a/sccfm-ansible/examples/manage_network_group_members.yml +++ b/sccfm-ansible/examples/manage_network_group_members.yml @@ -9,7 +9,7 @@ # # PREREQUISITES: # - SCCFM_REGION and SCCFM_API_TOKEN environment variables set, OR -# - Region and API token in group_vars/all/vars.yml +# - SCCFM_REGION set and vault_sccfm_api_token in encrypted group_vars/all/vault.yml # - The network group and referenced network objects must already exist. # Run create_network_objects.yml and create_network_groups.yml first. @@ -19,10 +19,11 @@ module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ sccfm_api_token_effective }}" vars: + sccfm_api_token_effective: "{{ vault_sccfm_api_token | default(lookup('env', 'SCCFM_API_TOKEN'), true) }}" group_name: web-servers members_to_add: - web-server-01 diff --git a/sccfm-ansible/examples/network_objects.yml b/sccfm-ansible/examples/network_objects.yml index 35653bea..858ef073 100644 --- a/sccfm-ansible/examples/network_objects.yml +++ b/sccfm-ansible/examples/network_objects.yml @@ -8,7 +8,7 @@ # # PREREQUISITES: # - SCCFM_REGION and SCCFM_API_TOKEN environment variables set, OR -# - Region and API token in group_vars/all/vars.yml +# - SCCFM_REGION set and vault_sccfm_api_token in encrypted group_vars/all/vault.yml - name: Network object lifecycle — create, list, update, delete hosts: localhost @@ -16,10 +16,11 @@ module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ sccfm_api_token_effective }}" vars: + sccfm_api_token_effective: "{{ vault_sccfm_api_token | default(lookup('env', 'SCCFM_API_TOKEN'), true) }}" network_objects: - name: web-server-01-full-lifecycle value: "10.0.1.100" diff --git a/sccfm-ansible/examples/onboard_asas.yml b/sccfm-ansible/examples/onboard_asas.yml index 7cfedd30..f02701c0 100644 --- a/sccfm-ansible/examples/onboard_asas.yml +++ b/sccfm-ansible/examples/onboard_asas.yml @@ -6,10 +6,11 @@ # Use module_defaults to avoid repeating region and api_token module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ sccfm_api_token_effective }}" vars: + sccfm_api_token_effective: "{{ vault_sccfm_api_token | default(lookup('env', 'SCCFM_API_TOKEN'), true) }}" # List of ASAs to onboard # Each device references its own password from the encrypted vault file asas_to_onboard: diff --git a/sccfm-ansible/examples/onboard_cdfmc_ftd.yml b/sccfm-ansible/examples/onboard_cdfmc_ftd.yml index 4fbc6667..1a0cbd63 100644 --- a/sccfm-ansible/examples/onboard_cdfmc_ftd.yml +++ b/sccfm-ansible/examples/onboard_cdfmc_ftd.yml @@ -29,10 +29,11 @@ module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ sccfm_api_token_effective }}" vars: + sccfm_api_token_effective: "{{ vault_sccfm_api_token | default(lookup('env', 'SCCFM_API_TOKEN'), true) }}" device_name: "" manager_name: "" access_policy_name: "" diff --git a/sccfm-ansible/examples/onboard_cdfmc_ftd_ztp.yml b/sccfm-ansible/examples/onboard_cdfmc_ftd_ztp.yml index 7cf5fcc9..67fdca81 100644 --- a/sccfm-ansible/examples/onboard_cdfmc_ftd_ztp.yml +++ b/sccfm-ansible/examples/onboard_cdfmc_ftd_ztp.yml @@ -42,10 +42,11 @@ module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ sccfm_api_token_effective }}" vars: + sccfm_api_token_effective: "{{ vault_sccfm_api_token | default(lookup('env', 'SCCFM_API_TOKEN'), true) }}" device_name: "" serial_number: "" manager_name: "" diff --git a/sccfm-ansible/examples/trigger_asa_upgrade.yml b/sccfm-ansible/examples/trigger_asa_upgrade.yml index b48caf94..de205a67 100644 --- a/sccfm-ansible/examples/trigger_asa_upgrade.yml +++ b/sccfm-ansible/examples/trigger_asa_upgrade.yml @@ -37,10 +37,11 @@ module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ sccfm_api_token_effective }}" vars: + sccfm_api_token_effective: "{{ vault_sccfm_api_token | default(lookup('env', 'SCCFM_API_TOKEN'), true) }}" device_uids: [] software_version: "" asdm_version: "" diff --git a/sccfm-ansible/examples/trigger_ftd_upgrade.yml b/sccfm-ansible/examples/trigger_ftd_upgrade.yml index 76539e86..a5b54de1 100644 --- a/sccfm-ansible/examples/trigger_ftd_upgrade.yml +++ b/sccfm-ansible/examples/trigger_ftd_upgrade.yml @@ -31,10 +31,11 @@ module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ sccfm_api_token_effective }}" vars: + sccfm_api_token_effective: "{{ vault_sccfm_api_token | default(lookup('env', 'SCCFM_API_TOKEN'), true) }}" device_uids: [] software_version: "" stage_upgrade: false diff --git a/sccfm-ansible/examples/update_network_groups.yml b/sccfm-ansible/examples/update_network_groups.yml index ae2227f1..4c29b874 100644 --- a/sccfm-ansible/examples/update_network_groups.yml +++ b/sccfm-ansible/examples/update_network_groups.yml @@ -6,7 +6,7 @@ # # PREREQUISITES: # - SCCFM_REGION and SCCFM_API_TOKEN environment variables set, OR -# - Region and API token in group_vars/all/vars.yml +# - SCCFM_REGION set and vault_sccfm_api_token in encrypted group_vars/all/vault.yml # - Run create_network_objects.yml first to create the referenced network objects # - Run create_network_groups.yml first to create the groups to update # @@ -17,12 +17,14 @@ - name: Update network groups in SCC Firewall Manager hosts: localhost gather_facts: false + vars: + sccfm_api_token_effective: "{{ vault_sccfm_api_token | default(lookup('env', 'SCCFM_API_TOKEN'), true) }}" # Use module_defaults to avoid repeating region and api_token module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ sccfm_api_token_effective }}" tasks: # ============================================================ diff --git a/sccfm-ansible/examples/update_network_objects.yml b/sccfm-ansible/examples/update_network_objects.yml index 7fe95f87..cfd4b752 100644 --- a/sccfm-ansible/examples/update_network_objects.yml +++ b/sccfm-ansible/examples/update_network_objects.yml @@ -10,7 +10,7 @@ # # PREREQUISITES: # - SCCFM_REGION and SCCFM_API_TOKEN environment variables set, OR -# - Region and API token in group_vars/all/vars.yml +# - SCCFM_REGION set and vault_sccfm_api_token in encrypted group_vars/all/vault.yml # # NOTE: # This module is idempotent. Running the same playbook twice will @@ -19,12 +19,14 @@ - name: Update network objects in SCC Firewall Manager hosts: localhost gather_facts: false + vars: + sccfm_api_token_effective: "{{ vault_sccfm_api_token | default(lookup('env', 'SCCFM_API_TOKEN'), true) }}" # Use module_defaults to avoid repeating region and api_token module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ sccfm_api_token_effective }}" tasks: # ============================================================ diff --git a/sccfm-ansible/plugins/module_utils/dependencies.py b/sccfm-ansible/plugins/module_utils/dependencies.py index 4951c180..f21edcfe 100644 --- a/sccfm-ansible/plugins/module_utils/dependencies.py +++ b/sccfm-ansible/plugins/module_utils/dependencies.py @@ -15,6 +15,7 @@ from ansible.module_utils.basic import AnsibleModule _IMPORT_ERRORS: list[tuple[str, str]] = [] +_PAIRED_DEVKIT_REQUIREMENT = "cisco-sccfm-devkit==0.38.0" def record_import_error(error: ImportError) -> None: @@ -28,8 +29,11 @@ def ensure_required_dependencies(module: "AnsibleModule") -> None: if not _IMPORT_ERRORS: return - library, import_traceback = _IMPORT_ERRORS[0] + _, import_traceback = _IMPORT_ERRORS[0] module.fail_json( - msg=missing_required_lib(library), + msg=missing_required_lib( + _PAIRED_DEVKIT_REQUIREMENT, + reason="by this cisco.sccfm collection release", + ), exception=import_traceback, ) diff --git a/sccfm-ansible/plugins/modules/add_asa_shun.py b/sccfm-ansible/plugins/modules/add_asa_shun.py index 77dd0ee8..22a06d6b 100644 --- a/sccfm-ansible/plugins/modules/add_asa_shun.py +++ b/sccfm-ansible/plugins/modules/add_asa_shun.py @@ -142,8 +142,8 @@ cisco.sccfm.add_asa_shun: query: "name:prod-* AND connectivityState:ONLINE" source_ip: "10.99.99.99" - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" # Example 2: Shun with connection tuple to drop an existing connection - name: Block attacker and drop active connection @@ -168,8 +168,8 @@ dest_port: 443 protocol: tcp - source_ip: "203.0.113.60" - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" # Example 4: Using module_defaults (recommended) - name: Add shun entries @@ -177,8 +177,8 @@ gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: Shun attacker IP cisco.sccfm.add_asa_shun: diff --git a/sccfm-ansible/plugins/modules/add_network_group_members.py b/sccfm-ansible/plugins/modules/add_network_group_members.py index e203ef44..55c7ee4f 100644 --- a/sccfm-ansible/plugins/modules/add_network_group_members.py +++ b/sccfm-ansible/plugins/modules/add_network_group_members.py @@ -55,8 +55,8 @@ referenced_objects: - web-server-01 - web-server-02 - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" # Example 2: Add members by UID - name: Add members to a network group by UID @@ -72,8 +72,8 @@ gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: Add web servers to group cisco.sccfm.add_network_group_members: diff --git a/sccfm-ansible/plugins/modules/add_object_override.py b/sccfm-ansible/plugins/modules/add_object_override.py index 9f52c7f5..af0b81bd 100644 --- a/sccfm-ansible/plugins/modules/add_object_override.py +++ b/sccfm-ansible/plugins/modules/add_object_override.py @@ -54,8 +54,8 @@ uid: "abc-123-def" target_id: "70bde3c9-328c-4a4b-bdc9-a4d4042bf09a" override_value: "10.10.10.10" - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" # Example 2: Using module_defaults to avoid repeating credentials - name: Add object overrides @@ -63,7 +63,7 @@ gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: Override web server IP for branch device diff --git a/sccfm-ansible/plugins/modules/apply_object_override_as_default.py b/sccfm-ansible/plugins/modules/apply_object_override_as_default.py index 016304e2..553fb967 100644 --- a/sccfm-ansible/plugins/modules/apply_object_override_as_default.py +++ b/sccfm-ansible/plugins/modules/apply_object_override_as_default.py @@ -45,8 +45,8 @@ cisco.sccfm.apply_object_override_as_default: uid: "abc-123-def" target_id: "897b293f-132e-4678-9d78-0f0947629500" - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" # Example 2: Using module_defaults - name: Apply object override as default @@ -54,7 +54,7 @@ gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: Apply override as default diff --git a/sccfm-ansible/plugins/modules/asa_ha_check.py b/sccfm-ansible/plugins/modules/asa_ha_check.py index 2171901d..7023a473 100644 --- a/sccfm-ansible/plugins/modules/asa_ha_check.py +++ b/sccfm-ansible/plugins/modules/asa_ha_check.py @@ -67,8 +67,8 @@ - name: Run HA checks on production ASAs cisco.sccfm.asa_ha_check: query: "name:prod-ha-* AND connectivityState:ONLINE" - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" register: ha_results # Example 2: Check HA status on a specific device by UID @@ -95,8 +95,8 @@ gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: Run HA checks cisco.sccfm.asa_ha_check: diff --git a/sccfm-ansible/plugins/modules/change_asa_boot_image.py b/sccfm-ansible/plugins/modules/change_asa_boot_image.py index 55e33835..3a9709fa 100644 --- a/sccfm-ansible/plugins/modules/change_asa_boot_image.py +++ b/sccfm-ansible/plugins/modules/change_asa_boot_image.py @@ -74,8 +74,8 @@ cisco.sccfm.change_asa_boot_image: query: "name:branch-*" image_path: "disk0:/asa9-18-4-smp-k8.bin" - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" # Example 2: Change boot image on specific devices - name: Change boot image on specific ASA devices @@ -100,8 +100,8 @@ gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: Set boot image on branch ASAs cisco.sccfm.change_asa_boot_image: diff --git a/sccfm-ansible/plugins/modules/change_asa_local_password.py b/sccfm-ansible/plugins/modules/change_asa_local_password.py index 6831fa93..8a56fa66 100644 --- a/sccfm-ansible/plugins/modules/change_asa_local_password.py +++ b/sccfm-ansible/plugins/modules/change_asa_local_password.py @@ -79,8 +79,8 @@ query: "name:branch-* AND connectivityState:ONLINE" username: admin new_password: "{{ vault_new_asa_password }}" - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" register: password_results # Example 2: Change password on specific devices by UID @@ -99,8 +99,8 @@ gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: Change admin password on all online ASAs cisco.sccfm.change_asa_local_password: diff --git a/sccfm-ansible/plugins/modules/clear_asa_shun.py b/sccfm-ansible/plugins/modules/clear_asa_shun.py index 42241b03..d98932e9 100644 --- a/sccfm-ansible/plugins/modules/clear_asa_shun.py +++ b/sccfm-ansible/plugins/modules/clear_asa_shun.py @@ -65,8 +65,8 @@ - name: Clear all shuns on production ASAs cisco.sccfm.clear_asa_shun: query: "name:prod-* AND connectivityState:ONLINE" - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" # Example 2: Clear shuns on specific devices by UID - name: Clear shuns on specific ASA @@ -80,8 +80,8 @@ gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: Clear all shuns on online ASAs cisco.sccfm.clear_asa_shun: diff --git a/sccfm-ansible/plugins/modules/configure_manager.py b/sccfm-ansible/plugins/modules/configure_manager.py index 17c362f3..478bea08 100644 --- a/sccfm-ansible/plugins/modules/configure_manager.py +++ b/sccfm-ansible/plugins/modules/configure_manager.py @@ -107,8 +107,8 @@ fmc_access_policy_uid: "{{ fmc_access_policy_uid }}" licenses: - BASE - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" register: onboard_result - name: Complete registration over SSH diff --git a/sccfm-ansible/plugins/modules/create_access_rule.py b/sccfm-ansible/plugins/modules/create_access_rule.py index 59ac4b1f..c4ac9abf 100644 --- a/sccfm-ansible/plugins/modules/create_access_rule.py +++ b/sccfm-ansible/plugins/modules/create_access_rule.py @@ -97,8 +97,8 @@ protocol: tcp destination_port: "443" remark: "Allow web to database" - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" # Example 2: Create a deny rule using module_defaults - name: Create access rules @@ -106,7 +106,7 @@ gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: Create a deny rule for a source subnet diff --git a/sccfm-ansible/plugins/modules/create_network_group.py b/sccfm-ansible/plugins/modules/create_network_group.py index e369cf55..ac549786 100644 --- a/sccfm-ansible/plugins/modules/create_network_group.py +++ b/sccfm-ansible/plugins/modules/create_network_group.py @@ -83,8 +83,8 @@ labels: - production - web - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" # Example 2: Create a group with referenced objects using module_defaults - name: Create network groups @@ -92,7 +92,7 @@ gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: Create group from existing objects diff --git a/sccfm-ansible/plugins/modules/create_network_object.py b/sccfm-ansible/plugins/modules/create_network_object.py index 0a743d1c..6329b5af 100644 --- a/sccfm-ansible/plugins/modules/create_network_object.py +++ b/sccfm-ansible/plugins/modules/create_network_object.py @@ -67,8 +67,8 @@ labels: - production - web - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" # Example 2: Create a subnet network object using module_defaults - name: Create network objects @@ -76,7 +76,7 @@ gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: Create branch office subnet diff --git a/sccfm-ansible/plugins/modules/delete_access_rule.py b/sccfm-ansible/plugins/modules/delete_access_rule.py index 31fe3578..4a2f8d90 100644 --- a/sccfm-ansible/plugins/modules/delete_access_rule.py +++ b/sccfm-ansible/plugins/modules/delete_access_rule.py @@ -38,8 +38,8 @@ - name: Delete access rule cisco.sccfm.delete_access_rule: uid: "ac981dcd-9860-401e-a51d-c615c946b72f" - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" # Example 2: Delete using module_defaults - name: Delete access rules @@ -47,7 +47,7 @@ gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: Delete old rule diff --git a/sccfm-ansible/plugins/modules/delete_network_group.py b/sccfm-ansible/plugins/modules/delete_network_group.py index a9ec2e4d..c543288f 100644 --- a/sccfm-ansible/plugins/modules/delete_network_group.py +++ b/sccfm-ansible/plugins/modules/delete_network_group.py @@ -46,15 +46,15 @@ - name: Delete network group by UID cisco.sccfm.delete_network_group: uid: "abc-123-def-456" - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" # Example 2: Delete a network group by name - name: Delete network group by name cisco.sccfm.delete_network_group: name: "web-server-group" - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" # Example 3: Delete multiple groups using module_defaults - name: Delete network groups @@ -62,7 +62,7 @@ gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: Delete obsolete network groups diff --git a/sccfm-ansible/plugins/modules/delete_network_object.py b/sccfm-ansible/plugins/modules/delete_network_object.py index fa696489..394c5634 100644 --- a/sccfm-ansible/plugins/modules/delete_network_object.py +++ b/sccfm-ansible/plugins/modules/delete_network_object.py @@ -44,15 +44,15 @@ - name: Delete network object by UID cisco.sccfm.delete_network_object: uid: "abc-123-def-456" - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" # Example 2: Delete a network object by name - name: Delete network object by name cisco.sccfm.delete_network_object: name: "old-web-server" - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" # Example 3: Delete multiple objects using module_defaults - name: Delete network objects @@ -60,7 +60,7 @@ gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: Delete obsolete network objects diff --git a/sccfm-ansible/plugins/modules/delete_object_override.py b/sccfm-ansible/plugins/modules/delete_object_override.py index 6a3e47d6..a1d30626 100644 --- a/sccfm-ansible/plugins/modules/delete_object_override.py +++ b/sccfm-ansible/plugins/modules/delete_object_override.py @@ -45,8 +45,8 @@ cisco.sccfm.delete_object_override: uid: "abc-123-def" target_id: "70bde3c9-328c-4a4b-bdc9-a4d4042bf09a" - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" # Example 2: Using module_defaults - name: Delete object overrides @@ -54,7 +54,7 @@ gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: Delete override diff --git a/sccfm-ansible/plugins/modules/deploy_cdfmc_ftd.py b/sccfm-ansible/plugins/modules/deploy_cdfmc_ftd.py index a6ee7fb8..7bd14072 100644 --- a/sccfm-ansible/plugins/modules/deploy_cdfmc_ftd.py +++ b/sccfm-ansible/plugins/modules/deploy_cdfmc_ftd.py @@ -91,8 +91,8 @@ cisco.sccfm.deploy_cdfmc_ftd: uids: - "12345678-1234-1234-1234-123456789abc" - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" # Example 2: Deploy with notes - name: Deploy FTD changes with deployment notes @@ -116,8 +116,8 @@ gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: Deploy branch FTD changes cisco.sccfm.deploy_cdfmc_ftd: diff --git a/sccfm-ansible/plugins/modules/edit_object_override.py b/sccfm-ansible/plugins/modules/edit_object_override.py index 79584cf2..20dcafc2 100644 --- a/sccfm-ansible/plugins/modules/edit_object_override.py +++ b/sccfm-ansible/plugins/modules/edit_object_override.py @@ -54,8 +54,8 @@ uid: "abc-123-def" target_id: "70bde3c9-328c-4a4b-bdc9-a4d4042bf09a" override_value: "10.20.30.40" - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" # Example 2: Using module_defaults - name: Edit object overrides @@ -63,7 +63,7 @@ gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: Edit override diff --git a/sccfm-ansible/plugins/modules/execute_asa_cli.py b/sccfm-ansible/plugins/modules/execute_asa_cli.py index e145579e..55fcaa92 100644 --- a/sccfm-ansible/plugins/modules/execute_asa_cli.py +++ b/sccfm-ansible/plugins/modules/execute_asa_cli.py @@ -80,8 +80,8 @@ commands: - "show version" - "show running-config" - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" register: cli_results # Example 2: Execute commands on specific devices by UID @@ -100,8 +100,8 @@ gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: Show version on branch ASAs cisco.sccfm.execute_asa_cli: diff --git a/sccfm-ansible/plugins/modules/execute_ftd_cli.py b/sccfm-ansible/plugins/modules/execute_ftd_cli.py index 9cdc47a2..ec82e3c4 100644 --- a/sccfm-ansible/plugins/modules/execute_ftd_cli.py +++ b/sccfm-ansible/plugins/modules/execute_ftd_cli.py @@ -79,8 +79,8 @@ cisco.sccfm.execute_ftd_cli: query: "name:prod-* AND connectivityState:ONLINE" command: "show failover" - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" register: cli_results # Example 2: Execute a command on specific devices by UID @@ -98,8 +98,8 @@ gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: Show route on branch FTDs cisco.sccfm.execute_ftd_cli: diff --git a/sccfm-ansible/plugins/modules/get_access_group.py b/sccfm-ansible/plugins/modules/get_access_group.py index e7f8415a..1ff24e56 100644 --- a/sccfm-ansible/plugins/modules/get_access_group.py +++ b/sccfm-ansible/plugins/modules/get_access_group.py @@ -36,8 +36,8 @@ - name: Get access group cisco.sccfm.get_access_group: uid: "c6fa254e-db7a-447e-a58f-95df1e09c2af" - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" register: result - name: Show access group name @@ -50,8 +50,8 @@ gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: Get access group cisco.sccfm.get_access_group: diff --git a/sccfm-ansible/plugins/modules/get_access_rule.py b/sccfm-ansible/plugins/modules/get_access_rule.py index dbee6929..29cafd15 100644 --- a/sccfm-ansible/plugins/modules/get_access_rule.py +++ b/sccfm-ansible/plugins/modules/get_access_rule.py @@ -36,8 +36,8 @@ - name: Get access rule cisco.sccfm.get_access_rule: uid: "ac981dcd-9860-401e-a51d-c615c946b72f" - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" register: result - name: Show rule @@ -50,7 +50,7 @@ gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: Get access rule details diff --git a/sccfm-ansible/plugins/modules/get_object.py b/sccfm-ansible/plugins/modules/get_object.py index e883b2f1..b109c0f9 100644 --- a/sccfm-ansible/plugins/modules/get_object.py +++ b/sccfm-ansible/plugins/modules/get_object.py @@ -38,8 +38,8 @@ - name: Get object cisco.sccfm.get_object: uid: "fd526e22-12ff-4fa0-a88d-7375c5d1e144" - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" register: obj - name: Show object @@ -52,7 +52,7 @@ gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: Get object details diff --git a/sccfm-ansible/plugins/modules/list_access_groups.py b/sccfm-ansible/plugins/modules/list_access_groups.py index 265916ec..a66cc1e7 100644 --- a/sccfm-ansible/plugins/modules/list_access_groups.py +++ b/sccfm-ansible/plugins/modules/list_access_groups.py @@ -48,8 +48,8 @@ # List all access groups - name: List access groups cisco.sccfm.list_access_groups: - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" register: result - name: Display access groups @@ -62,8 +62,8 @@ gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: List access groups cisco.sccfm.list_access_groups: diff --git a/sccfm-ansible/plugins/modules/list_access_rules.py b/sccfm-ansible/plugins/modules/list_access_rules.py index c4adbc08..3e9bef3c 100644 --- a/sccfm-ansible/plugins/modules/list_access_rules.py +++ b/sccfm-ansible/plugins/modules/list_access_rules.py @@ -48,8 +48,8 @@ # Example 1: List all access rules - name: List all access rules cisco.sccfm.list_access_rules: - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" register: result - name: Display access rules @@ -62,7 +62,7 @@ gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: List first page of access rules diff --git a/sccfm-ansible/plugins/modules/list_asa_boot_registry.py b/sccfm-ansible/plugins/modules/list_asa_boot_registry.py index a3c7146c..8869aa7e 100644 --- a/sccfm-ansible/plugins/modules/list_asa_boot_registry.py +++ b/sccfm-ansible/plugins/modules/list_asa_boot_registry.py @@ -67,8 +67,8 @@ - name: List boot registry on production ASAs cisco.sccfm.list_asa_boot_registry: query: "name:prod-* AND connectivityState:ONLINE" - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" register: boot_registry # Example 2: Get boot registry info for specific devices by UID @@ -85,8 +85,8 @@ gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: List boot registry on branch ASAs cisco.sccfm.list_asa_boot_registry: diff --git a/sccfm-ansible/plugins/modules/list_asa_compatible_versions.py b/sccfm-ansible/plugins/modules/list_asa_compatible_versions.py index 92b9da2c..5dd3eeed 100644 --- a/sccfm-ansible/plugins/modules/list_asa_compatible_versions.py +++ b/sccfm-ansible/plugins/modules/list_asa_compatible_versions.py @@ -78,8 +78,8 @@ cisco.sccfm.list_asa_compatible_versions: uids: - "12345678-1234-1234-1234-123456789abc" - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" register: compat_versions - name: Show compatible versions @@ -118,8 +118,8 @@ gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: Get compatible versions for branch ASAs cisco.sccfm.list_asa_compatible_versions: diff --git a/sccfm-ansible/plugins/modules/list_asa_disk_files.py b/sccfm-ansible/plugins/modules/list_asa_disk_files.py index a04b59b8..63b49ff4 100644 --- a/sccfm-ansible/plugins/modules/list_asa_disk_files.py +++ b/sccfm-ansible/plugins/modules/list_asa_disk_files.py @@ -67,8 +67,8 @@ - name: List disk files on production ASAs cisco.sccfm.list_asa_disk_files: query: "name:prod-* AND connectivityState:ONLINE" - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" register: disk_files # Example 2: List files on specific devices by UID @@ -85,8 +85,8 @@ gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: List files on branch ASAs cisco.sccfm.list_asa_disk_files: diff --git a/sccfm-ansible/plugins/modules/list_asa_local_users.py b/sccfm-ansible/plugins/modules/list_asa_local_users.py index 063cf7f3..63a90892 100644 --- a/sccfm-ansible/plugins/modules/list_asa_local_users.py +++ b/sccfm-ansible/plugins/modules/list_asa_local_users.py @@ -64,15 +64,15 @@ cisco.sccfm.list_asa_local_users: query: "name:branch-* AND connectivityState:ONLINE" region: "us" - api_token: "{{ sccfm_api_token }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" - name: List local users with shared auth hosts: localhost gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: List local users on branch ASAs cisco.sccfm.list_asa_local_users: diff --git a/sccfm-ansible/plugins/modules/list_asa_not_on_version.py b/sccfm-ansible/plugins/modules/list_asa_not_on_version.py index 07b8e1d0..27150e85 100644 --- a/sccfm-ansible/plugins/modules/list_asa_not_on_version.py +++ b/sccfm-ansible/plugins/modules/list_asa_not_on_version.py @@ -74,8 +74,8 @@ - name: Find ASAs not on 9.20(3)13 cisco.sccfm.list_asa_not_on_version: version: "9.20(3)13" - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" register: result - name: Show devices that need upgrading @@ -105,8 +105,8 @@ gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: Find ASAs not on target version cisco.sccfm.list_asa_not_on_version: diff --git a/sccfm-ansible/plugins/modules/list_cdfmc_access_policies.py b/sccfm-ansible/plugins/modules/list_cdfmc_access_policies.py index a17930e7..391d4c26 100644 --- a/sccfm-ansible/plugins/modules/list_cdfmc_access_policies.py +++ b/sccfm-ansible/plugins/modules/list_cdfmc_access_policies.py @@ -49,8 +49,8 @@ - name: List cdFMC access policies cisco.sccfm.list_cdfmc_access_policies: domain_uid: "e276abec-e0f2-11e3-8169-6d9ed49b625f" - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" register: result - name: Show access policies @@ -63,8 +63,8 @@ gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: List access policies for a domain cisco.sccfm.list_cdfmc_access_policies: diff --git a/sccfm-ansible/plugins/modules/list_ftd_compatible_versions.py b/sccfm-ansible/plugins/modules/list_ftd_compatible_versions.py index 409914be..b08c1a46 100644 --- a/sccfm-ansible/plugins/modules/list_ftd_compatible_versions.py +++ b/sccfm-ansible/plugins/modules/list_ftd_compatible_versions.py @@ -76,8 +76,8 @@ cisco.sccfm.list_ftd_compatible_versions: uids: - "12345678-1234-1234-1234-123456789abc" - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" register: compat_versions - name: Show compatible versions @@ -116,8 +116,8 @@ gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: Get compatible versions for branch FTDs cisco.sccfm.list_ftd_compatible_versions: diff --git a/sccfm-ansible/plugins/modules/list_ftd_not_on_version.py b/sccfm-ansible/plugins/modules/list_ftd_not_on_version.py index 684f3bc8..dc4a60e0 100644 --- a/sccfm-ansible/plugins/modules/list_ftd_not_on_version.py +++ b/sccfm-ansible/plugins/modules/list_ftd_not_on_version.py @@ -86,8 +86,8 @@ - name: Find FTDs not on 7.4.1 cisco.sccfm.list_ftd_not_on_version: version: "7.4.1" - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" register: result - name: Show devices that need upgrading @@ -119,8 +119,8 @@ gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: Find FTDs not on target version cisco.sccfm.list_ftd_not_on_version: diff --git a/sccfm-ansible/plugins/modules/list_managers.py b/sccfm-ansible/plugins/modules/list_managers.py index f1f1eac6..bf90cc19 100644 --- a/sccfm-ansible/plugins/modules/list_managers.py +++ b/sccfm-ansible/plugins/modules/list_managers.py @@ -47,8 +47,8 @@ # Example 1: List all managers - name: List all managers cisco.sccfm.list_managers: - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" register: result - name: Show managers @@ -71,8 +71,8 @@ gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: List all managers cisco.sccfm.list_managers: diff --git a/sccfm-ansible/plugins/modules/list_network_groups.py b/sccfm-ansible/plugins/modules/list_network_groups.py index 92fcbffd..7e7cdb88 100644 --- a/sccfm-ansible/plugins/modules/list_network_groups.py +++ b/sccfm-ansible/plugins/modules/list_network_groups.py @@ -51,8 +51,8 @@ # Example 1: List all network groups - name: List all network groups cisco.sccfm.list_network_groups: - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" register: result - name: Display network groups @@ -65,7 +65,7 @@ gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: Find web-related network groups diff --git a/sccfm-ansible/plugins/modules/list_network_objects.py b/sccfm-ansible/plugins/modules/list_network_objects.py index cf0105a4..7846a71f 100644 --- a/sccfm-ansible/plugins/modules/list_network_objects.py +++ b/sccfm-ansible/plugins/modules/list_network_objects.py @@ -51,8 +51,8 @@ # Example 1: List all network objects - name: List all network objects cisco.sccfm.list_network_objects: - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" register: result - name: Display network objects @@ -65,7 +65,7 @@ gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: Find web-related network objects diff --git a/sccfm-ansible/plugins/modules/onboard_asa.py b/sccfm-ansible/plugins/modules/onboard_asa.py index 230767f2..564853f2 100644 --- a/sccfm-ansible/plugins/modules/onboard_asa.py +++ b/sccfm-ansible/plugins/modules/onboard_asa.py @@ -73,7 +73,7 @@ hosts: all module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: Onboard branch-asa-1 diff --git a/sccfm-ansible/plugins/modules/onboard_cdfmc_ftd.py b/sccfm-ansible/plugins/modules/onboard_cdfmc_ftd.py index 841c464f..1edce022 100644 --- a/sccfm-ansible/plugins/modules/onboard_cdfmc_ftd.py +++ b/sccfm-ansible/plugins/modules/onboard_cdfmc_ftd.py @@ -76,8 +76,8 @@ fmc_access_policy_uid: "your-access-policy-uid" licenses: - BASE - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" # Example 2: Onboard a virtual FTD with multiple licenses - name: Onboard virtual FTD @@ -109,8 +109,8 @@ gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: Onboard branch FTD cisco.sccfm.onboard_cdfmc_ftd: diff --git a/sccfm-ansible/plugins/modules/onboard_cdfmc_ftd_ztp.py b/sccfm-ansible/plugins/modules/onboard_cdfmc_ftd_ztp.py index 3ad0f31c..08cf9383 100644 --- a/sccfm-ansible/plugins/modules/onboard_cdfmc_ftd_ztp.py +++ b/sccfm-ansible/plugins/modules/onboard_cdfmc_ftd_ztp.py @@ -76,8 +76,8 @@ licenses: - BASE fmc_access_policy_uid: "your-access-policy-uid" - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" # Example 2: Onboard with initial password and device group - name: Onboard FTD via ZTP with password @@ -97,8 +97,8 @@ gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: Onboard branch FTD through ZTP cisco.sccfm.onboard_cdfmc_ftd_ztp: diff --git a/sccfm-ansible/plugins/modules/register_cdfmc_ftd.py b/sccfm-ansible/plugins/modules/register_cdfmc_ftd.py index cea00769..86148d83 100644 --- a/sccfm-ansible/plugins/modules/register_cdfmc_ftd.py +++ b/sccfm-ansible/plugins/modules/register_cdfmc_ftd.py @@ -48,8 +48,8 @@ gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: Complete the FTD registration cisco.sccfm.register_cdfmc_ftd: diff --git a/sccfm-ansible/plugins/modules/remove_asa_shun.py b/sccfm-ansible/plugins/modules/remove_asa_shun.py index b5f35e20..462967ad 100644 --- a/sccfm-ansible/plugins/modules/remove_asa_shun.py +++ b/sccfm-ansible/plugins/modules/remove_asa_shun.py @@ -83,8 +83,8 @@ cisco.sccfm.remove_asa_shun: query: "name:prod-* AND connectivityState:ONLINE" source_ip: "10.99.99.99" - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" # Example 2: Remove multiple shuns in a single transaction - name: Remove multiple attacker IPs in one call @@ -94,8 +94,8 @@ - "203.0.113.40" - "203.0.113.50" - "203.0.113.60" - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" # Example 3: Remove a shun on specific devices by UID - name: Remove shun on specific ASA @@ -110,8 +110,8 @@ gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: Remove shun for attacker cisco.sccfm.remove_asa_shun: diff --git a/sccfm-ansible/plugins/modules/remove_network_group_members.py b/sccfm-ansible/plugins/modules/remove_network_group_members.py index 4e2e65aa..591f69c9 100644 --- a/sccfm-ansible/plugins/modules/remove_network_group_members.py +++ b/sccfm-ansible/plugins/modules/remove_network_group_members.py @@ -55,8 +55,8 @@ referenced_objects: - web-server-01 - web-server-02 - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" # Example 2: Remove members by UID - name: Remove members from a network group by UID @@ -72,8 +72,8 @@ gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: Remove old web servers from group cisco.sccfm.remove_network_group_members: diff --git a/sccfm-ansible/plugins/modules/show_asa_shun.py b/sccfm-ansible/plugins/modules/show_asa_shun.py index 8048e427..e53dcd58 100644 --- a/sccfm-ansible/plugins/modules/show_asa_shun.py +++ b/sccfm-ansible/plugins/modules/show_asa_shun.py @@ -74,8 +74,8 @@ - name: Show shun entries on production ASAs cisco.sccfm.show_asa_shun: query: "name:prod-* AND connectivityState:ONLINE" - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" register: shun_entries # Example 2: Show shun entries on specific devices by UID @@ -98,8 +98,8 @@ gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: Show shun entries cisco.sccfm.show_asa_shun: diff --git a/sccfm-ansible/plugins/modules/tests/test_inventory_plugin_security.py b/sccfm-ansible/plugins/modules/tests/test_inventory_plugin_security.py index f9733c0e..b417a5fd 100644 --- a/sccfm-ansible/plugins/modules/tests/test_inventory_plugin_security.py +++ b/sccfm-ansible/plugins/modules/tests/test_inventory_plugin_security.py @@ -13,6 +13,7 @@ import yaml from ansible.inventory.data import InventoryData from ansible.parsing.dataloader import DataLoader +from ansible.template import Templar, trust_as_template from plugins.inventory import sccfm as inventory_plugin from plugins.module_utils.config import Config from scc_firewall_manager_sdk import Device @@ -20,6 +21,26 @@ _SYNTHETIC_TOKEN = "not-a-secret-sec002" _DEVICE_NAME = "sec002-device" _EXAMPLES_DIR = Path(__file__).resolve().parents[3] / "examples" +_E2E_DIR = _EXAMPLES_DIR.parent / "e2e" +_SCCFM_ACTION_GROUP = "group/cisco.sccfm.all" +_EFFECTIVE_TOKEN_VARIABLE = "sccfm_api_token_effective" +_EFFECTIVE_TOKEN_EXPRESSION = ( + "{{ vault_sccfm_api_token | " "default(lookup('env', 'SCCFM_API_TOKEN'), true) }}" +) +_PACKAGED_PLAYBOOK_AUTH_DEFAULTS = { + "region": "{{ lookup('env', 'SCCFM_REGION') }}", + "api_token": f"{{{{ {_EFFECTIVE_TOKEN_VARIABLE} }}}}", +} +_EXAMPLES_WITHOUT_SCCFM_API_AUTH = { + "configure_manager.yml", + "inventory.sccfm.yml", + "show_devices.yml", +} +_E2E_VAULT_FILE = "../../../examples/group_vars/all/vault.yml" +_E2E_VAULT_TOKEN_EXPRESSION = "{{ vault_sccfm_api_token }}" +_E2E_LOCAL_AUTH_PLAYBOOK = Path("asa/playbooks/remove_vasa.yml") +_E2E_LOCAL_TOKEN_EXPRESSION = "{{ lookup('env', 'API_TOKEN') }}" +_E2E_NO_AUTH_PLAYBOOKS = {Path("ftd/playbooks/cleanup.yml")} @dataclass(frozen=True) @@ -130,11 +151,164 @@ def test_inventory_auth_token_is_consumed_but_never_exported( } -def test_packaged_examples_do_not_depend_on_inventory_token_variable() -> None: - offenders = [ - path.name - for path in sorted(_EXAMPLES_DIR.glob("*.yml")) - if "{{ sccfm_api_token }}" in path.read_text(encoding="utf-8") - ] +def test_packaged_api_playbooks_use_safe_vault_over_environment_auth() -> None: + offenders: dict[str, object] = {} + checked_examples: set[str] = set() - assert offenders == [] + for path in sorted(_EXAMPLES_DIR.glob("*.yml")): + if path.name in _EXAMPLES_WITHOUT_SCCFM_API_AUTH: + continue + + checked_examples.add(path.name) + playbook = yaml.safe_load(path.read_text(encoding="utf-8")) + if not isinstance(playbook, list): + offenders[path.name] = "example is not a playbook" + continue + + for play_number, play in enumerate(playbook, start=1): + if not isinstance(play, dict): + offenders[f"{path.name} play {play_number}"] = "play is not a mapping" + continue + module_defaults = play.get("module_defaults", {}) + actual = ( + module_defaults.get(_SCCFM_ACTION_GROUP) + if isinstance(module_defaults, dict) + else None + ) + variables = play.get("vars", {}) + effective_token = ( + variables.get(_EFFECTIVE_TOKEN_VARIABLE) if isinstance(variables, dict) else None + ) + if ( + actual != _PACKAGED_PLAYBOOK_AUTH_DEFAULTS + or effective_token != _EFFECTIVE_TOKEN_EXPRESSION + ): + offenders[f"{path.name} play {play_number}"] = { + "module_defaults": actual, + _EFFECTIVE_TOKEN_VARIABLE: effective_token, + } + + assert checked_examples + assert offenders == {} + + +def test_e2e_playbooks_use_current_vault_key_except_explicit_local_auth() -> None: + offenders: dict[str, object] = {} + checked_vault_playbooks: set[Path] = set() + checked_local_auth = False + checked_no_auth: set[Path] = set() + + for path in sorted(_E2E_DIR.glob("*/playbooks/*.yml")): + relative_path = path.relative_to(_E2E_DIR) + playbook = yaml.safe_load(path.read_text(encoding="utf-8")) + if not isinstance(playbook, list): + offenders[relative_path.as_posix()] = "example is not a playbook" + continue + + for play_number, play in enumerate(playbook, start=1): + offender_key = f"{relative_path.as_posix()} play {play_number}" + if not isinstance(play, dict): + offenders[offender_key] = "play is not a mapping" + continue + + vars_files = play.get("vars_files") + module_defaults = play.get("module_defaults", {}) + action_group_defaults = ( + module_defaults.get(_SCCFM_ACTION_GROUP) + if isinstance(module_defaults, dict) + else None + ) + + if relative_path in _E2E_NO_AUTH_PLAYBOOKS: + if vars_files is not None or module_defaults: + offenders[offender_key] = { + "vars_files": vars_files, + "module_defaults": module_defaults, + } + checked_no_auth.add(relative_path) + continue + + if relative_path == _E2E_LOCAL_AUTH_PLAYBOOK: + variables = play.get("vars", {}) + local_token = ( + variables.get("sccfm_api_token") if isinstance(variables, dict) else None + ) + if ( + vars_files is not None + or module_defaults + or local_token != _E2E_LOCAL_TOKEN_EXPRESSION + ): + offenders[offender_key] = { + "vars_files": vars_files, + "module_defaults": module_defaults, + "sccfm_api_token": local_token, + } + checked_local_auth = True + continue + + actual_token = ( + action_group_defaults.get("api_token") + if isinstance(action_group_defaults, dict) + else None + ) + has_vault_file = isinstance(vars_files, list) and _E2E_VAULT_FILE in vars_files + has_legacy_reference = "{{ sccfm_api_token }}" in path.read_text(encoding="utf-8") + if ( + not has_vault_file + or actual_token != _E2E_VAULT_TOKEN_EXPRESSION + or has_legacy_reference + ): + offenders[offender_key] = { + "vars_files": vars_files, + "api_token": actual_token, + "legacy_reference": has_legacy_reference, + } + checked_vault_playbooks.add(relative_path) + + assert checked_vault_playbooks + assert checked_local_auth + assert checked_no_auth == _E2E_NO_AUTH_PLAYBOOKS + assert offenders == {} + + +@pytest.mark.parametrize( + ("vault_token", "environment_token", "expected"), + [ + (None, "environment-token", "environment-token"), + ("", "environment-token", "environment-token"), + ("vault-token", "environment-token", "vault-token"), + (None, "", ""), + ("", "", ""), + ], + ids=[ + "undefined-vault", + "empty-vault", + "vault-override", + "both-missing", + "both-empty", + ], +) +def test_effective_playbook_token_precedence( + monkeypatch: pytest.MonkeyPatch, + vault_token: str | None, + environment_token: str, + expected: str, +) -> None: + monkeypatch.setenv("SCCFM_API_TOKEN", environment_token) + variables = {} if vault_token is None else {"vault_sccfm_api_token": vault_token} + templar = Templar(loader=DataLoader(), variables=variables) + + rendered = templar.template(trust_as_template(_EFFECTIVE_TOKEN_EXPRESSION)) + + assert rendered == expected + if not expected: + with pytest.raises(ValueError, match="api_token is required"): + Config(region="us", api_token=rendered) + + +def test_packaged_group_vars_does_not_override_controller_region() -> None: + variables = yaml.safe_load( + (_EXAMPLES_DIR / "group_vars" / "all" / "vars.yml").read_text(encoding="utf-8") + ) + + assert variables["sccfm_region"] == _PACKAGED_PLAYBOOK_AUTH_DEFAULTS["region"] diff --git a/sccfm-ansible/plugins/modules/tests/test_module_utils_config.py b/sccfm-ansible/plugins/modules/tests/test_module_utils_config.py index 32c6d526..58116247 100644 --- a/sccfm-ansible/plugins/modules/tests/test_module_utils_config.py +++ b/sccfm-ansible/plugins/modules/tests/test_module_utils_config.py @@ -48,5 +48,7 @@ def test_missing_dependency_uses_actionable_ansible_failure( dependencies.ensure_required_dependencies(_FakeModule()) payload = exc_info.value.payload - assert "cisco_sccfm_core" in payload["msg"] + assert dependencies._PAIRED_DEVKIT_REQUIREMENT in payload["msg"] + assert dependencies._PAIRED_DEVKIT_REQUIREMENT.startswith("cisco-sccfm-devkit==") + assert "cisco_sccfm_core" not in payload["msg"] assert payload["exception"] == "synthetic import traceback" diff --git a/sccfm-ansible/plugins/modules/trigger_asa_upgrade.py b/sccfm-ansible/plugins/modules/trigger_asa_upgrade.py index dbf9f6af..79c6dba7 100644 --- a/sccfm-ansible/plugins/modules/trigger_asa_upgrade.py +++ b/sccfm-ansible/plugins/modules/trigger_asa_upgrade.py @@ -121,8 +121,8 @@ - "12345678-1234-1234-1234-123456789abc" software_version: "9.18(4)" asdm_version: "7.18(1.152)" - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" # Example 2: Stage-only upgrade using a query - name: Stage ASA upgrade for branch devices @@ -154,8 +154,8 @@ gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: Stage ASA upgrade for branch devices cisco.sccfm.trigger_asa_upgrade: diff --git a/sccfm-ansible/plugins/modules/trigger_ftd_upgrade.py b/sccfm-ansible/plugins/modules/trigger_ftd_upgrade.py index 502cb30b..e4e72219 100644 --- a/sccfm-ansible/plugins/modules/trigger_ftd_upgrade.py +++ b/sccfm-ansible/plugins/modules/trigger_ftd_upgrade.py @@ -109,8 +109,8 @@ uids: - "12345678-1234-1234-1234-123456789abc" software_version: "7.4.1" - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" # Example 2: Stage-only upgrade using a query - name: Stage FTD upgrade for branch devices @@ -134,8 +134,8 @@ gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: Stage FTD upgrade for branch devices cisco.sccfm.trigger_ftd_upgrade: diff --git a/sccfm-ansible/plugins/modules/update_access_rule.py b/sccfm-ansible/plugins/modules/update_access_rule.py index 248b0815..a71a34c9 100644 --- a/sccfm-ansible/plugins/modules/update_access_rule.py +++ b/sccfm-ansible/plugins/modules/update_access_rule.py @@ -85,8 +85,8 @@ cisco.sccfm.update_access_rule: uid: "ac981dcd-9860-401e-a51d-c615c946b72f" rule_action: DENY - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" # Example 2: Update remark and networks using module_defaults - name: Update access rules @@ -94,7 +94,7 @@ gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: Update rule remark and source diff --git a/sccfm-ansible/plugins/modules/update_network_group.py b/sccfm-ansible/plugins/modules/update_network_group.py index 49d82a76..f0115470 100644 --- a/sccfm-ansible/plugins/modules/update_network_group.py +++ b/sccfm-ansible/plugins/modules/update_network_group.py @@ -76,8 +76,8 @@ referenced_objects: - web-server-01 - web-server-02 - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" # Example 2: Rename a group and update description using module_defaults - name: Update network groups @@ -85,7 +85,7 @@ gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: Rename and update group diff --git a/sccfm-ansible/plugins/modules/update_network_object.py b/sccfm-ansible/plugins/modules/update_network_object.py index cc9a557d..b5921ac1 100644 --- a/sccfm-ansible/plugins/modules/update_network_object.py +++ b/sccfm-ansible/plugins/modules/update_network_object.py @@ -73,16 +73,16 @@ cisco.sccfm.update_network_object: uid: "abc-123-def" value: "192.168.1.0/24" - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" # Example 2: Rename a network object by name - name: Rename a network object cisco.sccfm.update_network_object: name: old-object-name new_name: new-object-name - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" # Example 3: Update multiple fields using module_defaults - name: Update network objects @@ -90,7 +90,7 @@ gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: Update web server object diff --git a/sccfm-ansible/plugins/modules/update_object_default.py b/sccfm-ansible/plugins/modules/update_object_default.py index 76460012..61e11884 100644 --- a/sccfm-ansible/plugins/modules/update_object_default.py +++ b/sccfm-ansible/plugins/modules/update_object_default.py @@ -47,8 +47,8 @@ cisco.sccfm.update_object_default: uid: "abc-123-def" value: "10.10.10.10" - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" # Example 2: Using module_defaults to avoid repeating credentials - name: Update object default values @@ -56,7 +56,7 @@ gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: Update default value @@ -75,8 +75,8 @@ gather_facts: false module_defaults: group/cisco.sccfm.all: - region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + region: "{{ lookup('env', 'SCCFM_REGION') }}" + api_token: "{{ lookup('env', 'SCCFM_API_TOKEN') }}" tasks: - name: Update shared default value cisco.sccfm.update_object_default: diff --git a/tests/test_ansible_dependency_metadata.py b/tests/test_ansible_dependency_metadata.py index fd6b3c31..b8f70a92 100644 --- a/tests/test_ansible_dependency_metadata.py +++ b/tests/test_ansible_dependency_metadata.py @@ -4,6 +4,7 @@ from __future__ import annotations +import re import tomllib from pathlib import Path from typing import Any, cast @@ -31,9 +32,19 @@ def test_collection_python_requirement_matches_release_versions() -> None: for line in (_COLLECTION_ROOT / "requirements.txt").read_text().splitlines() if line.strip() and not line.lstrip().startswith("#") ] + dependency_source = ( + _COLLECTION_ROOT / "plugins" / "module_utils" / "dependencies.py" + ).read_text() + runtime_requirement = re.search( + r'^_PAIRED_DEVKIT_REQUIREMENT = "(?P[^"]+)"$', + dependency_source, + re.MULTILINE, + ) assert galaxy_version == project_version assert requirement_lines == [f"cisco-sccfm-devkit=={project_version}"] + assert runtime_requirement is not None + assert runtime_requirement.group("requirement") == requirement_lines[0] def test_execution_environment_uses_collection_requirements() -> None: diff --git a/tests/test_devkit_cli.py b/tests/test_devkit_cli.py new file mode 100644 index 00000000..c2c47c38 --- /dev/null +++ b/tests/test_devkit_cli.py @@ -0,0 +1,190 @@ +# Copyright 2026 Cisco Systems, Inc. and its affiliates +# +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for secure interactive CLI command construction in the devkit.""" + +from __future__ import annotations + +import os +from collections.abc import Iterator +from pathlib import Path +from typing import Any + +import pytest + +from cisco_sccfm_scripts import devkit_cli +from cisco_sccfm_scripts.cli_commands import CliCommand, CliGroup, CliParam, build_cli_tree + + +class _Prompt: + """Small questionary prompt stand-in that returns one configured answer.""" + + def __init__(self, answer: str | None) -> None: + self._answer = answer + + def unsafe_ask(self) -> str | None: + return self._answer + + +def _find_command(nodes: list[CliGroup | CliCommand], args: list[str]) -> CliCommand: + for node in nodes: + if isinstance(node, CliCommand) and node.args == args: + return node + if isinstance(node, CliGroup): + try: + return _find_command(node.children, args) + except LookupError: + continue + raise LookupError(f"CLI command not found: {args}") + + +def test_cli_tree_preserves_sensitive_option_metadata() -> None: + tree = build_cli_tree() + configure = _find_command(tree, ["configure"]) + asa_onboard = _find_command( + tree, + ["inventory", "devices", "asa", "onboard"], + ) + configure_manager = _find_command( + tree, + ["inventory", "devices", "cdfmc-managed-ftd", "configure-manager"], + ) + + api_token = next(param for param in configure.params if param.flag == "--api-token") + asa_password = next(param for param in asa_onboard.params if param.flag == "--password") + cli_key = next(param for param in configure_manager.params if param.flag == "--cli-key") + + assert api_token.sensitive + assert api_token.envvar == "SCCFM_API_TOKEN" + assert api_token.envvar_list_splitter is None + assert asa_password.sensitive + assert asa_password.envvar is None + assert cli_key.sensitive + assert cli_key.envvar == "SCCFM_CLI_KEY" + assert not next(param for param in configure.params if param.flag == "--region").sensitive + + +@pytest.mark.parametrize( + ("multiple", "answers", "splitter", "expected_env_value"), + [ + (False, ["single-secret"], None, "single-secret"), + (True, ["first-secret", "second-secret", ""], None, "first-secret second-secret"), + (True, ["first-secret", "second-secret", ""], ":", "first-secret:second-secret"), + ], +) +def test_sensitive_cli_values_use_hidden_prompts_and_child_only_environment( + monkeypatch: pytest.MonkeyPatch, + multiple: bool, + answers: list[str], + splitter: str | None, + expected_env_value: str, +) -> None: + envvar = "SCCFM_TEST_SECRET" + monkeypatch.delenv(envvar, raising=False) + pending: Iterator[str] = iter(answers) + password_prompts: list[str] = [] + rendered: list[str] = [] + calls: list[tuple[list[str], Path, dict[str, str] | None]] = [] + + def fake_password(message: str, **kwargs: Any) -> _Prompt: + password_prompts.append(message) + return _Prompt(next(pending)) + + def reject_text(message: str, **kwargs: Any) -> _Prompt: + raise AssertionError(f"sensitive value used a visible prompt: {message}") + + def fake_print(value: object = "", *args: object, **kwargs: object) -> None: + rendered.append(str(value)) + + def fake_call( + argv: list[str], + *, + cwd: Path, + env: dict[str, str] | None, + ) -> int: + calls.append((list(argv), cwd, None if env is None else dict(env))) + return 0 + + monkeypatch.setattr(devkit_cli.questionary, "password", fake_password) + monkeypatch.setattr(devkit_cli.questionary, "text", reject_text) + monkeypatch.setattr(devkit_cli.console, "print", fake_print) + monkeypatch.setattr(devkit_cli.subprocess, "call", fake_call) + + command = CliCommand( + name="example", + description="Example command", + args=["example"], + params=[ + CliParam( + label="Sensitive value", + flag="--secret", + required=True, + multiple=multiple, + sensitive=True, + envvar=envvar, + envvar_list_splitter=splitter, + ) + ], + ) + + devkit_cli._execute_cli_command(command) + + assert len(calls) == 1 + argv, cwd, child_env = calls[0] + assert argv == ["sccfm-cli", "example"] + assert cwd == devkit_cli._project_root() + assert child_env is not None + assert child_env[envvar] == expected_env_value + assert envvar not in os.environ + assert len(password_prompts) == len(answers) + display = "\n".join(rendered) + assert all(answer not in display for answer in answers if answer) + + +def test_sensitive_cli_value_without_envvar_is_left_to_cli_hidden_prompt( + monkeypatch: pytest.MonkeyPatch, +) -> None: + rendered: list[str] = [] + calls: list[tuple[list[str], Path, dict[str, str] | None]] = [] + + def reject_prompt(message: str, **kwargs: Any) -> _Prompt: + raise AssertionError(f"devkit unexpectedly prompted for a delegated secret: {message}") + + def fake_print(value: object = "", *args: object, **kwargs: object) -> None: + rendered.append(str(value)) + + def fake_call( + argv: list[str], + *, + cwd: Path, + env: dict[str, str] | None, + ) -> int: + calls.append((list(argv), cwd, env)) + return 0 + + monkeypatch.setattr(devkit_cli.questionary, "password", reject_prompt) + monkeypatch.setattr(devkit_cli.questionary, "text", reject_prompt) + monkeypatch.setattr(devkit_cli.console, "print", fake_print) + monkeypatch.setattr(devkit_cli.subprocess, "call", fake_call) + + command = CliCommand( + name="example", + description="Example command", + args=["example"], + params=[ + CliParam( + label="Sensitive value", + flag="--secret", + required=True, + sensitive=True, + ) + ], + ) + + devkit_cli._execute_cli_command(command) + + assert calls == [(["sccfm-cli", "example"], devkit_cli._project_root(), None)] + display = "\n".join(rendered) + assert "sccfm-cli's hidden prompt" in display + assert "--secret" not in display diff --git a/tests/test_prepare_ansible_release.py b/tests/test_prepare_ansible_release.py index 6d75a72a..e1de352c 100644 --- a/tests/test_prepare_ansible_release.py +++ b/tests/test_prepare_ansible_release.py @@ -6,6 +6,7 @@ from __future__ import annotations +import shutil from pathlib import Path import pytest @@ -33,6 +34,7 @@ def _yaml_release( ) -> str: return f"""--- ancestor: null +# sccfm-release-retarget-seed: {_INITIAL_VERSION} releases: {version}: changes: @@ -104,6 +106,28 @@ def test_retargets_only_the_initial_release_metadata(tmp_path: Path) -> None: assert _SUMMARY in rst +def test_checked_in_changelog_can_be_prepared_once(tmp_path: Path) -> None: + repository = Path(__file__).resolve().parents[1] + source = repository / "sccfm-ansible" + root = tmp_path / "sccfm-ansible" + (root / "changelogs").mkdir(parents=True) + shutil.copy2(source / "changelogs" / "changelog.yaml", root / "changelogs") + shutil.copy2(source / "CHANGELOG.rst", root) + + result = prepare_ansible_release( + root, + _INITIAL_VERSION, + _RELEASE_VERSION, + _RELEASE_DATE, + ) + + assert result.changed + assert set( + yaml.safe_load((root / "changelogs" / "changelog.yaml").read_text())["releases"] + ) == {_RELEASE_VERSION} + assert f"v{_RELEASE_VERSION}" in (root / "CHANGELOG.rst").read_text(encoding="utf-8") + + def test_preserves_a_fragment_not_named_after_the_previous_version(tmp_path: Path) -> None: root = _collection(tmp_path, yaml_content=_yaml_release(fragment="initial-release.yml")) @@ -113,11 +137,8 @@ def test_preserves_a_fragment_not_named_after_the_previous_version(tmp_path: Pat def test_an_already_prepared_release_is_idempotent(tmp_path: Path) -> None: - root = _collection( - tmp_path, - yaml_content=_yaml_release(_RELEASE_VERSION, _RELEASE_DATE, "1.0.0.yml"), - rst_content=_rst_release(_RELEASE_VERSION), - ) + root = _collection(tmp_path) + prepare_ansible_release(root, _INITIAL_VERSION, _RELEASE_VERSION, _RELEASE_DATE) yaml_before = (root / "changelogs" / "changelog.yaml").read_bytes() rst_before = (root / "CHANGELOG.rst").read_bytes() @@ -134,11 +155,8 @@ def test_an_already_prepared_release_is_idempotent(tmp_path: Path) -> None: def test_an_already_prepared_release_only_updates_its_date(tmp_path: Path) -> None: - root = _collection( - tmp_path, - yaml_content=_yaml_release(_RELEASE_VERSION, "2026-08-01", "1.0.0.yml"), - rst_content=_rst_release(_RELEASE_VERSION), - ) + root = _collection(tmp_path) + prepare_ansible_release(root, _INITIAL_VERSION, _RELEASE_VERSION, "2026-08-01") rst_before = (root / "CHANGELOG.rst").read_bytes() result = prepare_ansible_release( @@ -171,6 +189,80 @@ def test_accepts_an_existing_target_among_historical_releases(tmp_path: Path) -> assert not result.changed +def test_rejects_a_target_that_replaced_published_history_without_writing( + tmp_path: Path, +) -> None: + root = _collection( + tmp_path, + yaml_content=_yaml_release("2.0.0", _RELEASE_DATE, "2.0.0.yml"), + rst_content=_rst_release("2.0.0"), + ) + yaml_path = root / "changelogs" / "changelog.yaml" + rst_path = root / "CHANGELOG.rst" + before = (yaml_path.read_bytes(), rst_path.read_bytes()) + + with pytest.raises(AnsibleReleaseError, match="previous release is missing.*prepare"): + prepare_ansible_release(root, _RELEASE_VERSION, "2.0.0", _RELEASE_DATE) + + assert (yaml_path.read_bytes(), rst_path.read_bytes()) == before + + +def test_rejects_an_unconsumed_seed_alongside_the_first_target(tmp_path: Path) -> None: + target = _yaml_release(_RELEASE_VERSION, _RELEASE_DATE, "1.0.0.yml").split( + "releases:\n", maxsplit=1 + )[1] + target_rst = ( + _rst_release(_RELEASE_VERSION).split(".. contents:: Topics\n", maxsplit=1)[1].lstrip() + ) + root = _collection( + tmp_path, + yaml_content=_yaml_release() + target, + rst_content=_rst_release() + "\n" + target_rst, + ) + + with pytest.raises(AnsibleReleaseError, match="seed was not retargeted.*prepare"): + prepare_ansible_release(root, _INITIAL_VERSION, _RELEASE_VERSION, _RELEASE_DATE) + + +def test_second_release_cannot_retarget_and_erase_published_history(tmp_path: Path) -> None: + root = _collection(tmp_path) + prepare_ansible_release(root, _INITIAL_VERSION, _RELEASE_VERSION, _RELEASE_DATE) + yaml_path = root / "changelogs" / "changelog.yaml" + rst_path = root / "CHANGELOG.rst" + before = (yaml_path.read_bytes(), rst_path.read_bytes()) + + with pytest.raises(AnsibleReleaseError, match="seed was already retargeted.*prepare"): + prepare_ansible_release(root, _RELEASE_VERSION, "2.0.0", "2026-09-01") + + assert (yaml_path.read_bytes(), rst_path.read_bytes()) == before + assert set(yaml.safe_load(yaml_path.read_text())["releases"]) == {_RELEASE_VERSION} + assert f"v{_RELEASE_VERSION}" in rst_path.read_text(encoding="utf-8") + + +def test_moved_seed_marker_cannot_authorize_history_retarget(tmp_path: Path) -> None: + yaml_content = _yaml_release(_RELEASE_VERSION).replace( + f"sccfm-release-retarget-seed: {_INITIAL_VERSION}", + f"sccfm-release-retarget-seed: {_RELEASE_VERSION}", + ) + root = _collection( + tmp_path, + yaml_content=yaml_content, + rst_content=_rst_release(_RELEASE_VERSION), + ) + before = ( + (root / "changelogs" / "changelog.yaml").read_bytes(), + (root / "CHANGELOG.rst").read_bytes(), + ) + + with pytest.raises(AnsibleReleaseError, match="seed marker is not immutable"): + prepare_ansible_release(root, _RELEASE_VERSION, "2.0.0", "2026-09-01") + + assert ( + (root / "changelogs" / "changelog.yaml").read_bytes(), + (root / "CHANGELOG.rst").read_bytes(), + ) == before + + @pytest.mark.parametrize( "version", ["01.2.3", "1.02.3", "1.2.03", "v1.2.3", "1.2", "1.2.3-rc.1", "1.2.3+1"], @@ -182,6 +274,31 @@ def test_rejects_noncanonical_or_unstable_versions(tmp_path: Path, version: str) prepare_ansible_release(root, _INITIAL_VERSION, version, _RELEASE_DATE) +@pytest.mark.parametrize("release_version", ["0.38.0", "0.37.9"]) +def test_rejects_non_increasing_release_versions( + tmp_path: Path, + release_version: str, +) -> None: + root = _collection(tmp_path) + + with pytest.raises(AnsibleReleaseError, match="greater than previous"): + prepare_ansible_release(root, _INITIAL_VERSION, release_version, _RELEASE_DATE) + + +def test_rejects_release_when_changelog_contains_a_newer_entry(tmp_path: Path) -> None: + target = _yaml_release("1.0.0", _RELEASE_DATE, "1.0.0.yml") + future = _yaml_release("2.0.0", "2026-09-01", "2.0.0.yml").split("releases:\n", maxsplit=1)[1] + future_rst = _rst_release("2.0.0").split(".. contents:: Topics\n", maxsplit=1)[1].lstrip() + root = _collection( + tmp_path, + yaml_content=target + future, + rst_content=_rst_release("1.0.0") + "\n" + future_rst, + ) + + with pytest.raises(AnsibleReleaseError, match="newest changelog entry"): + prepare_ansible_release(root, "0.9.0", "1.0.0", _RELEASE_DATE) + + @pytest.mark.parametrize("release_date", ["2026-02-29", "2026-8-12", "12-08-2026"]) def test_rejects_invalid_release_dates(tmp_path: Path, release_date: str) -> None: root = _collection(tmp_path) @@ -204,7 +321,12 @@ def test_rejects_mixed_yaml_and_rst_versions_without_writing(tmp_path: Path) -> def test_rejects_multiple_initial_entries_without_writing(tmp_path: Path) -> None: extra = _yaml_release("0.37.0", "2026-06-01", "0.37.0.yml").split("releases:\n", maxsplit=1)[1] - root = _collection(tmp_path, yaml_content=_yaml_release() + extra) + extra_rst = _rst_release("0.37.0").split(".. contents:: Topics\n", maxsplit=1)[1].lstrip() + root = _collection( + tmp_path, + yaml_content=_yaml_release() + extra, + rst_content=_rst_release() + "\n" + extra_rst, + ) yaml_path = root / "changelogs" / "changelog.yaml" before = yaml_path.read_bytes() diff --git a/tests/test_release_artifacts.py b/tests/test_release_artifacts.py index 09fe2c93..6619c1c3 100644 --- a/tests/test_release_artifacts.py +++ b/tests/test_release_artifacts.py @@ -7,6 +7,7 @@ from __future__ import annotations import json +import re from pathlib import Path from typing import Any @@ -216,6 +217,11 @@ def test_workflows_promote_release_assets_without_rebuilding() -> None: assert "gh release create" in draft assert "--draft" in draft + assert "--json isDraft,isPrerelease,tagName" in draft + assert "\"${RELEASE_TAG}\"$'\\ttrue\\tfalse'" in draft + assert "\"${RELEASE_TAG}\"$'\\tfalse\\tfalse'" in draft + assert "RELEASE_IS_DRAFT=false" in draft + assert "public release is missing immutable asset" in draft assert "release_artifacts verify" in draft for publisher in (pypi, galaxy): assert "actions/download-artifact" in publisher @@ -226,11 +232,118 @@ def test_workflows_promote_release_assets_without_rebuilding() -> None: assert "environment: pypi" in pypi assert "pypa/gh-action-pypi-publish" in pypi - assert "skip-existing: true" in pypi + assert "skip-existing:" not in pypi + assert 'MISSING_FILES="${PYPI_VERIFICATION##* missing=}"' in pypi + assert 'test "$(find dist -mindepth 1 -maxdepth 1 -type f' in pypi + assert '2)\n cp "${WHEEL_PATH}" "${SDIST_PATH}" dist/' in pypi + assert "3)\n MISSING_FILES=" in pypi + assert ( + 'cp "${WHEEL_PATH}" "${SDIST_PATH}" dist/' + not in pypi.split("3)\n MISSING_FILES=", maxsplit=1)[1] + ) assert "- publish-to-pypi" in galaxy assert "environment: ansible-galaxy" in galaxy assert "ansible-galaxy collection publish" in galaxy assert "--import-timeout 600" in galaxy + assert "LOOKUP_ATTEMPTS=121" in galaxy + assert "GITHUB_RUN_ATTEMPT" in galaxy assert "--no-wait" not in galaxy assert "- publish-to-galaxy" in finalizer + assert "actions: read" in finalizer + assert "actions/checkout" in finalizer + assert "actions/download-artifact" in finalizer + assert 'test "$(git rev-parse HEAD)" = "${SOURCE_COMMIT}"' in finalizer + assert finalizer.count("release_artifacts verify") == 2 + assert 'gh release download "${RELEASE_TAG}"' in finalizer + assert 'cmp -s "${local_asset}" "${RELEASE_ASSETS_DIR}/${asset_name}"' in finalizer + assert "--json isDraft,isPrerelease,tagName" in finalizer + assert 'test "${RELEASE_TAG_NAME}" = "${RELEASE_TAG}"' in finalizer + assert 'test "${IS_PRERELEASE}" = "false"' in finalizer + assert "select(.draft == false and .prerelease == false) | .tag_name" in finalizer + assert "any(version > current for version in public_versions)" in finalizer + assert '[[ "${MAKE_LATEST}" = "true" ]]' in finalizer assert "--draft=false" in finalizer + assert "--latest=false" in finalizer + assert finalizer.index("--latest=false") < finalizer.index('[[ "${IS_DRAFT}" = "false" ]]') + + +def test_release_workflow_refreshes_metadata_after_files_only_bump() -> None: + repository = Path(__file__).resolve().parents[1] + release = (repository / ".github/workflows/release.yml").read_text(encoding="utf-8") + synchronization = release.split( + " - name: Synchronize exact release version\n", maxsplit=1 + )[1].split(" - name: Build release artifacts once\n", maxsplit=1)[0] + + bump = synchronization.index('poetry run cz bump "${RELEASE_VERSION}"') + reinstall = synchronization.index("poetry install --only-root --no-interaction") + metadata_check = synchronization.index('test "${INSTALLED_VERSION}" = "${RELEASE_VERSION}"') + + assert bump < reinstall < metadata_check + assert 'version("cisco-sccfm-devkit")' in synchronization + + +def test_release_changed_path_validation_reads_tracked_and_untracked_paths() -> None: + repository = Path(__file__).resolve().parents[1] + release = (repository / ".github/workflows/release.yml").read_text(encoding="utf-8") + commit_step = release.split(" - name: Commit and tag verified source\n", maxsplit=1)[ + 1 + ].split(" - name: Create and verify release manifest\n", maxsplit=1)[0] + + validation = re.compile( + r"while IFS= read -r changed_path; do.*?done < <\(\s*\{\s*" + r"git diff --name-only\s*git ls-files --others --exclude-standard\s*" + r"\} \| sort -u\s*\)", + re.DOTALL, + ) + assert validation.search(commit_step) is not None + assert re.search(r"\} \| sort -u \| while", commit_step) is None + paired_runtime = "sccfm-ansible/plugins/module_utils/dependencies.py" + assert paired_runtime in commit_step + assert re.search(rf"git add .*?{re.escape(paired_runtime)}", commit_step, re.DOTALL) + + +def test_release_retry_resumes_only_same_run_manifest_bound_artifacts() -> None: + repository = Path(__file__).resolve().parents[1] + release = (repository / ".github/workflows/release.yml").read_text(encoding="utf-8") + build = release.split(" build-release:\n", maxsplit=1)[1].split( + " create-draft-release:\n", maxsplit=1 + )[0] + validation = build.split(" - name: Validate requested release\n", maxsplit=1)[1].split( + " - name: Synchronize exact release version\n", maxsplit=1 + )[0] + + assert "actions: read" in build + assert '[[ "${GITHUB_RUN_ATTEMPT}" -le 1 ]]' in validation + assert "actions/runs/${GITHUB_RUN_ID}/artifacts" in validation + assert 'gh run download "${GITHUB_RUN_ID}"' in validation + assert "cisco_sccfm_scripts.release_artifacts verify" in validation + assert 'git merge-base --is-ancestor "${SOURCE_COMMIT}" HEAD' in validation + assert "--json isDraft,isPrerelease,tagName" in validation + assert ".isPrerelease == false" in validation + assert "select(.isPrerelease == false) | .tagName" in validation + assert '[[ "${RELEASE_IDENTITY}" != "${RELEASE_TAG}" ]]' in validation + assert "select(.draft == true) | .tag_name" in validation + assert '[[ "${RESUME_RELEASE}" != "true" || "${draft_tag}" != "${RELEASE_TAG}" ]]' in validation + assert "unresolved draft release blocks a new production release" in validation + registry_resume = re.search( + r'200\)\s+if \[\[ "\$\{RESUME_RELEASE\}" != "true" \]\]; then\s+' + r'echo "::error::\$\{registry\} already contains version', + validation, + ) + assert registry_resume is not None + assert "steps.source.outputs.source_commit || steps.version.outputs.source_commit" in build + assert "steps.source.outputs.bundle_name || steps.version.outputs.bundle_name" in build + + +def test_release_push_reconciles_an_accepted_remote_update() -> None: + repository = Path(__file__).resolve().parents[1] + release = (repository / ".github/workflows/release.yml").read_text(encoding="utf-8") + push = release.split(" - name: Push release commit and tag atomically\n", maxsplit=1)[ + 1 + ].split("\n create-draft-release:\n", maxsplit=1)[0] + + assert "if git push --atomic origin" in push + assert "git ls-remote --refs origin" in push + assert '[[ "${REMOTE_TAG_COMMIT}" = "${SOURCE_COMMIT}" ]]' in push + assert 'git merge-base --is-ancestor "${SOURCE_COMMIT}" FETCH_HEAD' in push + assert "the atomic remote update was verified" in push diff --git a/tests/test_token_workspace.py b/tests/test_token_workspace.py index bf993493..8b531120 100644 --- a/tests/test_token_workspace.py +++ b/tests/test_token_workspace.py @@ -4,15 +4,23 @@ from __future__ import annotations +import os import stat import subprocess +import tempfile +import traceback from pathlib import Path +from typing import cast import click import pytest +import yaml from click.testing import CliRunner +import cisco_sccfm_scripts.devkit_cli as devkit_cli import cisco_sccfm_scripts.setup_tokens as setup_tokens +from cisco_sccfm_cli.models import Config +from cisco_sccfm_cli.services import ConfigService from cisco_sccfm_scripts.setup_tokens import ( _ensure_vault_pass_headless, _resolve_examples_path, @@ -23,6 +31,12 @@ from cisco_sccfm_scripts.token_store import SavedToken, VaultTokenStore +@pytest.fixture(autouse=True) +def _isolate_cli_config(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Keep every token-workspace test away from the developer's real CLI config.""" + monkeypatch.setenv("SCCFM_CONFIG", str(tmp_path / "isolated-cli" / "config.json")) + + def _mode(path: Path) -> int: return stat.S_IMODE(path.stat().st_mode) @@ -34,6 +48,48 @@ def _create_examples_layout(root: Path) -> Path: return examples +def _view_vault(workspace: Path) -> dict[str, object]: + result = subprocess.run( + [ + "ansible-vault", + "view", + str(workspace / "group_vars" / "all" / "vault.yml"), + "--vault-password-file", + str(workspace / ".vault_pass"), + ], + capture_output=True, + check=True, + text=True, + ) + return cast(dict[str, object], yaml.safe_load(result.stdout)) + + +def _write_encrypted_vault(workspace: Path, payload: object) -> Path: + plaintext_path = workspace / "vault-plaintext.yml" + plaintext_path.write_text( + "---\n" + yaml.safe_dump(payload, sort_keys=False), + encoding="utf-8", + ) + vault_path = workspace / "group_vars" / "all" / "vault.yml" + vault_path.parent.mkdir(parents=True, exist_ok=True) + subprocess.run( + [ + "ansible-vault", + "encrypt", + str(plaintext_path), + "--output", + str(vault_path), + "--vault-password-file", + str(workspace / ".vault_pass"), + ], + capture_output=True, + check=True, + text=True, + ) + plaintext_path.unlink() + return vault_path + + def test_default_path_resolves_collection_examples( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -89,6 +145,73 @@ def test_generated_files_use_private_modes(tmp_path: Path) -> None: assert _mode(vars_path) == 0o600 +def test_env_file_shell_quotes_token_without_executing_content(tmp_path: Path) -> None: + root = tmp_path / "project" + root.mkdir() + marker = tmp_path / "must-not-exist" + token = f'synthetic-token"; touch {marker}; printf "$(id)`id`\\value' + + env_path = _write_env_file(root, "us", token) + result = subprocess.run( + [ + "/bin/sh", + "-c", + '. "$1"; printf "%s\\n%s\\n" "$SCCFM_REGION" "$SCCFM_API_TOKEN"', + "sh", + str(env_path), + ], + capture_output=True, + check=True, + text=True, + ) + + assert result.stdout.splitlines() == ["us", token] + assert not marker.exists() + + +def test_env_file_replaces_exported_and_plain_assignments_without_retaining_old_secret( + tmp_path: Path, +) -> None: + root = tmp_path / "project" + root.mkdir() + old_secret = "sec-old-token-must-disappear" + env_path = root / ".env" + env_path.write_text( + f"SCCFM_API_TOKEN={old_secret}\n export SCCFM_API_TOKEN={old_secret}\n" + "SCCFM_REGION=int\n", + encoding="utf-8", + ) + + _write_env_file(root, "eu", "new-token") + + content = env_path.read_text(encoding="utf-8") + assert old_secret not in content + assert content.count("SCCFM_API_TOKEN=") == 1 + assert content.count("SCCFM_REGION=") == 1 + + +@pytest.mark.parametrize("kind", ["directory", "fifo"]) +def test_existing_vault_password_path_must_be_regular( + tmp_path: Path, + kind: str, +) -> None: + workspace = tmp_path / "workspace" + workspace.mkdir() + vault_pass = workspace / ".vault_pass" + if kind == "directory": + vault_pass.mkdir(mode=0o700) + else: + vault_pass.parent.mkdir(parents=True, exist_ok=True) + vault_pass_path = str(vault_pass) + os.mkfifo(vault_pass_path, mode=0o600) + original_mode = _mode(vault_pass) + + with pytest.raises(click.ClickException, match="not a regular file"): + _ensure_vault_pass_headless(workspace, "synthetic-password") + + assert _mode(vault_pass) == original_mode + + def test_headless_cli_keeps_path_optional(monkeypatch: pytest.MonkeyPatch) -> None: captured: dict[str, object] = {} @@ -132,6 +255,63 @@ def fake_run_headless(**kwargs: object) -> None: pytest.fail("Sensitive value was exposed by change-tokens.", pytrace=False) +def test_headless_cli_reads_vault_password_from_environment_without_exposure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + api_token = "sec005-environment-api-sentinel-115b" + vault_password = "sec009-environment-vault-sentinel-6c92" + captured: dict[str, object] = {} + + def fake_run_headless(**kwargs: object) -> None: + captured.update(kwargs) + + monkeypatch.setattr(setup_tokens, "_run_headless", fake_run_headless) + result = CliRunner().invoke( + main, + ["--region", "us"], + env={ + "SCCFM_API_TOKEN": api_token, + "SCCFM_VAULT_PASSWORD": vault_password, + }, + ) + + assert result.exit_code == 0, result.output + assert captured["vault_password"] == vault_password + assert "passing --vault-password directly" not in result.stderr + observed = f"{result.stdout}\n{result.stderr}\n{result.exception!r}" + for secret in (api_token, vault_password): + if secret in observed: + pytest.fail("Sensitive value was exposed by change-tokens.", pytrace=False) + + +def test_headless_cli_warns_for_legacy_vault_password_option_without_exposure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + api_token = "sec005-environment-api-sentinel-1d38" + vault_password = "sec009-command-vault-sentinel-80f4" + captured: dict[str, object] = {} + + def fake_run_headless(**kwargs: object) -> None: + captured.update(kwargs) + + monkeypatch.setattr(setup_tokens, "_run_headless", fake_run_headless) + result = CliRunner().invoke( + main, + ["--region", "us", "--vault-password", vault_password], + env={"SCCFM_API_TOKEN": api_token}, + ) + + assert result.exit_code == 0, result.output + assert captured["vault_password"] == vault_password + assert "passing --vault-password directly" in result.stderr + assert "SCCFM_VAULT_PASSWORD" in result.stderr + assert ".vault_pass" in result.stderr + observed = f"{result.stdout}\n{result.stderr}\n{result.exception!r}" + for secret in (api_token, vault_password): + if secret in observed: + pytest.fail("Sensitive value was exposed by change-tokens.", pytrace=False) + + def test_interactive_api_token_prompt_hides_input(monkeypatch: pytest.MonkeyPatch) -> None: captured: dict[str, object] = {} @@ -147,10 +327,61 @@ def fake_prompt(prompt: str, **kwargs: object) -> str: def test_change_tokens_help_recommends_environment_input() -> None: result = CliRunner().invoke(main, ["--help"]) + help_text = " ".join(result.output.split()) assert result.exit_code == 0, result.output - assert "SCCFM_API_TOKEN" in result.output - assert "process listings and shell history" in result.output + assert "SCCFM_API_TOKEN" in help_text + assert "SCCFM_VAULT_PASSWORD" in help_text + assert "existing private .vault_pass" in help_text + assert "process listings and shell history" in help_text + + +def test_missing_vault_password_recommends_private_inputs(tmp_path: Path) -> None: + workspace = tmp_path / "workspace" + workspace.mkdir() + + with pytest.raises(click.ClickException) as exc_info: + _ensure_vault_pass_headless(workspace, None) + + message = str(exc_info.value) + assert "SCCFM_VAULT_PASSWORD" in message + assert "private .vault_pass" in message + + +def test_whitespace_vault_password_is_rejected_without_creating_file(tmp_path: Path) -> None: + workspace = tmp_path / "workspace" + workspace.mkdir() + + with pytest.raises(click.ClickException, match="No vault password found"): + _ensure_vault_pass_headless(workspace, " \t ") + + assert not (workspace / ".vault_pass").exists() + + +def test_interactive_token_name_retries_blank_and_reserved_values( + monkeypatch: pytest.MonkeyPatch, +) -> None: + answers = iter([" ", "_new", "vault-active", " production "]) + rendered: list[str] = [] + monkeypatch.setattr( + setup_tokens.click, + "prompt", + lambda *args, **kwargs: next(answers), + ) + monkeypatch.setattr( + setup_tokens.console, + "print", + lambda value="", *args, **kwargs: rendered.append(str(value)), + ) + + assert setup_tokens._prompt_token_name() == "production" + assert sum("token name" in line for line in rendered) == 3 + + +@pytest.mark.parametrize("name", ["", " ", "_new", "back", "vault-active", "legacy-active-2"]) +def test_user_token_names_reject_invalid_and_internal_values(name: str) -> None: + with pytest.raises(click.ClickException, match="token name"): + setup_tokens._saved_token(name=name, region="us", token="synthetic-token") def test_saved_token_representation_omits_token_value() -> None: @@ -164,6 +395,187 @@ def test_saved_token_representation_omits_token_value() -> None: assert "region='us'" in rendered +def test_credential_snapshot_representation_omits_file_content(tmp_path: Path) -> None: + sentinel = b"sec-snapshot-content-sentinel" + snapshot = setup_tokens._FileSnapshot( + path=tmp_path / "credential", + content=sentinel, + mode=0o600, + ) + + assert sentinel.decode() not in repr(snapshot) + + +@pytest.mark.skipif(os.name != "posix", reason="descriptor-relative rollback is POSIX-only") +def test_credential_transaction_rolls_back_through_pinned_parent_after_ancestor_swap( + tmp_path: Path, +) -> None: + trusted_parent = tmp_path / "trusted" / "credentials" + trusted_parent.mkdir(parents=True) + credential_path = trusted_parent / "token" + original = b"trusted-original" + credential_path.write_bytes(original) + credential_path.chmod(0o640) + + attacker_parent = tmp_path / "attacker" / "credentials" + attacker_parent.mkdir(parents=True) + attacker_path = attacker_parent / "token" + attacker = b"attacker-owned" + attacker_path.write_bytes(attacker) + moved_parent = tmp_path / "moved-trusted" + + with pytest.raises(RuntimeError, match="trigger rollback"): + with setup_tokens._credential_transaction([credential_path]): + credential_path.write_bytes(b"partially-updated") + trusted_parent.rename(moved_parent) + trusted_parent.symlink_to(attacker_parent, target_is_directory=True) + raise RuntimeError("trigger rollback") + + assert (moved_parent / "token").read_bytes() == original + assert _mode(moved_parent / "token") == 0o640 + assert attacker_path.read_bytes() == attacker + + +@pytest.mark.skipif(os.name != "posix", reason="descriptor-relative rollback is POSIX-only") +def test_credential_transaction_removes_new_file_from_pinned_parent_after_ancestor_swap( + tmp_path: Path, +) -> None: + trusted_parent = tmp_path / "trusted" / "credentials" + trusted_parent.mkdir(parents=True) + credential_path = trusted_parent / "token" + attacker_parent = tmp_path / "attacker" / "credentials" + attacker_parent.mkdir(parents=True) + attacker_path = attacker_parent / "token" + attacker = b"attacker-owned" + attacker_path.write_bytes(attacker) + moved_parent = tmp_path / "moved-trusted" + + with pytest.raises(RuntimeError, match="trigger rollback"): + with setup_tokens._credential_transaction([credential_path]): + credential_path.write_bytes(b"new-credential") + trusted_parent.rename(moved_parent) + trusted_parent.symlink_to(attacker_parent, target_is_directory=True) + raise RuntimeError("trigger rollback") + + assert not (moved_parent / "token").exists() + assert attacker_path.read_bytes() == attacker + + +def test_credential_transaction_supports_missing_parent_directories(tmp_path: Path) -> None: + credential_path = tmp_path / "new" / "nested" / "token" + + with setup_tokens._credential_transaction([credential_path]): + credential_path.write_bytes(b"created") + + assert credential_path.read_bytes() == b"created" + + +@pytest.mark.skipif(os.name != "posix", reason="descriptor-relative writes are POSIX-only") +def test_credential_transaction_successful_write_stays_with_pinned_parent_after_swap( + tmp_path: Path, +) -> None: + trusted_parent = tmp_path / "trusted" / "credentials" + trusted_parent.mkdir(parents=True) + credential_path = trusted_parent / "token" + credential_path.write_bytes(b"trusted-original") + attacker_parent = tmp_path / "attacker" / "credentials" + attacker_parent.mkdir(parents=True) + attacker_path = attacker_parent / "token" + attacker = b"attacker-owned" + attacker_path.write_bytes(attacker) + moved_parent = tmp_path / "moved-trusted" + + with setup_tokens._credential_transaction([credential_path]): + trusted_parent.rename(moved_parent) + trusted_parent.symlink_to(attacker_parent, target_is_directory=True) + setup_tokens._write_private_bytes(credential_path, b"trusted-update", mode=0o600) + + assert (moved_parent / "token").read_bytes() == b"trusted-update" + assert attacker_path.read_bytes() == attacker + + +@pytest.mark.skipif(os.name != "posix", reason="descriptor-relative writes are POSIX-only") +def test_credential_transaction_normalizes_platform_temp_aliases() -> None: + alias_path = Path(tempfile.mkdtemp()) / "missing" / "credential" + + with setup_tokens._credential_transaction([alias_path]): + setup_tokens._write_private_bytes(alias_path, b"created", mode=0o600) + + assert alias_path.read_bytes() == b"created" + + +@pytest.mark.skipif(os.name != "posix", reason="descriptor path checks are POSIX-only") +def test_credential_transaction_rejects_preexisting_symlink_ancestor(tmp_path: Path) -> None: + attacker_parent = tmp_path / "attacker" + attacker_parent.mkdir() + linked_parent = tmp_path / "linked" + linked_parent.symlink_to(attacker_parent, target_is_directory=True) + + with pytest.raises(click.ClickException, match="symbolic link"): + with setup_tokens._credential_transaction([linked_parent / "token"]): + pytest.fail("transaction body must not run", pytrace=False) + + assert not (attacker_parent / "token").exists() + + +def test_platform_path_normalization_does_not_require_uname( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(setup_tokens.sys, "platform", "win32") + + assert ( + setup_tokens._platform_normalized_path(tmp_path / "token") + == (tmp_path / "token").absolute() + ) + + +def test_transaction_absent_vault_does_not_fall_back_to_injected_path( + tmp_path: Path, +) -> None: + workspace = tmp_path / "workspace" + workspace.mkdir() + _ensure_vault_pass_headless(workspace, "synthetic-password") + vault_path = workspace / "group_vars" / "all" / "vault.yml" + attacker_parent = tmp_path / "attacker-all" + attacker_parent.mkdir() + (attacker_parent / "vault.yml").write_text("attacker-injected", encoding="utf-8") + moved_parent = tmp_path / "moved-all" + + with setup_tokens._credential_transaction([vault_path, workspace / ".vault_pass"]): + vault_path.parent.rename(moved_parent) + vault_path.parent.symlink_to(attacker_parent, target_is_directory=True) + assert VaultTokenStore(workspace)._decrypt_vault() is None + + assert (attacker_parent / "vault.yml").read_text(encoding="utf-8") == "attacker-injected" + + +def test_transaction_vault_encrypt_uses_staged_password_for_both_commands( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + workspace = tmp_path / "workspace" + workspace.mkdir() + _ensure_vault_pass_headless(workspace, "synthetic-password") + store = VaultTokenStore(workspace) + token = SavedToken(name="test", region="us", token="synthetic-token") + password_arguments: list[Path] = [] + real_run = subprocess.run + + def capture_password(command: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]: + password_arguments.append(Path(command[command.index("--vault-password-file") + 1])) + return cast(subprocess.CompletedProcess[str], real_run(command, **kwargs)) + + monkeypatch.setattr("cisco_sccfm_scripts.token_store.subprocess.run", capture_password) + paths = [store._vault_path, workspace / ".vault_pass"] + with setup_tokens._credential_transaction(paths): + store.save_active_and_tokens(token, [token]) + + assert len(password_arguments) == 2 + assert password_arguments[0] == password_arguments[1] + assert password_arguments[0] != workspace / ".vault_pass" + assert not password_arguments[0].exists() + + def test_headless_cli_forwards_typed_workspace( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -200,8 +612,9 @@ def test_headless_setup_routes_env_to_project_root( captured: dict[str, Path] = {} class FakeStore: - def __init__(self, path: Path) -> None: + def __init__(self, path: Path, migration_region: str | None = None) -> None: captured["store"] = path + assert migration_region is None def list_tokens(self) -> list[SavedToken]: return [] @@ -231,6 +644,7 @@ def fake_write_env(path: Path, region: str, api_token: str) -> Path: name="default", profile="default", vault_password="synthetic-password", + legacy_region=None, path=None, ) @@ -253,33 +667,1323 @@ def test_vault_store_encrypts_atomically_with_private_mode( assert _mode(vault_path) == 0o600 assert vault_path.read_bytes().startswith(b"$ANSIBLE_VAULT;") + assert _view_vault(workspace) == { + "vault_sccfm_api_token": "synthetic-token", + "sccfm_saved_tokens": [{"name": "test", "region": "us", "token": "synthetic-token"}], + } assert store.list_tokens() == [token] assert list(vault_path.parent.glob(".vault.*.tmp")) == [] -def test_vault_store_removes_plaintext_temporary_file_on_failure( +def test_headless_setup_rolls_back_every_credential_surface_on_failure( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + root = tmp_path / "project" + workspace = root / "sccfm-ansible" / "examples" + workspace.mkdir(parents=True) + ansible_tmp = tmp_path / "ansible-tmp" + ansible_tmp.mkdir() + monkeypatch.setenv("ANSIBLE_LOCAL_TEMP", str(ansible_tmp)) + config_path = tmp_path / "cli" / "config.json" + monkeypatch.setenv("SCCFM_CONFIG", str(config_path)) + _ensure_vault_pass_headless(workspace, "synthetic-password") + old = SavedToken(name="old", region="eu", token="old-token") + VaultTokenStore(workspace).save_active_and_tokens(old, [old]) + _write_env_file(root, "eu", "old-token") + _update_vars_region(workspace, "eu") + ConfigService(config_path).save(Config(profile="staging", region="eu", api_token="old-token")) + paths = setup_tokens._credential_state_paths(root, workspace) + before = {path: path.read_bytes() for path in paths} + monkeypatch.setattr(setup_tokens, "_project_root", lambda: root) + monkeypatch.setattr(setup_tokens, "_verify_ansible_vault", lambda: None) + monkeypatch.setattr( + setup_tokens, + "_update_cli_config", + lambda *args, **kwargs: (_ for _ in ()).throw(OSError("injected config failure")), + ) + + with pytest.raises(OSError, match="injected config failure"): + setup_tokens._run_headless( + region="us", + api_token="new-token", + name="new", + profile="staging", + vault_password=None, + legacy_region=None, + path=workspace, + ) + + assert {path: path.read_bytes() for path in paths} == before + + +def test_pristine_vault_store_lists_no_tokens_without_password_file(tmp_path: Path) -> None: + workspace = tmp_path / "workspace" + workspace.mkdir() + + assert VaultTokenStore(workspace).list_tokens() == [] + + +def test_new_vault_rejects_world_readable_password_file(tmp_path: Path) -> None: + workspace = tmp_path / "workspace" + workspace.mkdir() + password_path = _ensure_vault_pass_headless(workspace, "synthetic-password") + password_path.chmod(0o644) + token = SavedToken(name="test", region="us", token="synthetic-token") + + with pytest.raises(RuntimeError, match="mode 0600"): + VaultTokenStore(workspace).save_active_and_tokens(token, [token]) + + assert not (workspace / "group_vars" / "all" / "vault.yml").exists() + + +def test_saved_token_normalizes_values_and_round_trips( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + workspace = tmp_path / "workspace" + workspace.mkdir() + ansible_tmp = tmp_path / "ansible-tmp" + ansible_tmp.mkdir() + monkeypatch.setenv("ANSIBLE_LOCAL_TEMP", str(ansible_tmp)) + _ensure_vault_pass_headless(workspace, "synthetic-password") + token = SavedToken(name=" production ", region="US", token=" synthetic-token ") + + store = VaultTokenStore(workspace) + store.save_active_and_tokens(token, [token]) + + assert token == SavedToken(name="production", region="us", token="synthetic-token") + assert store.list_tokens() == [token] + + +@pytest.mark.parametrize( + "tokens", + [ + [ + SavedToken(name="duplicate", region="us", token="first-token"), + SavedToken(name="duplicate", region="eu", token="second-token"), + ], + [ + SavedToken(name="first", region="us", token="duplicate-token"), + SavedToken(name="second", region="eu", token="duplicate-token"), + ], + ], + ids=["duplicate-name", "duplicate-value"], +) +def test_vault_store_rejects_ambiguous_tokens_without_overwriting( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + tokens: list[SavedToken], ) -> None: workspace = tmp_path / "workspace" workspace.mkdir() + ansible_tmp = tmp_path / "ansible-tmp" + ansible_tmp.mkdir() + monkeypatch.setenv("ANSIBLE_LOCAL_TEMP", str(ansible_tmp)) _ensure_vault_pass_headless(workspace, "synthetic-password") - vault_path = workspace / "group_vars" / "all" / "vault.yml" - vault_path.parent.mkdir(parents=True) - original = b"$ANSIBLE_VAULT;1.1;AES256\nexisting-ciphertext\n" - vault_path.write_bytes(original) + original_token = SavedToken(name="original", region="us", token="original-token") store = VaultTokenStore(workspace) - token = SavedToken(name="test", region="us", token="synthetic-token") - - def failed_encrypt(*args: object, **kwargs: object) -> subprocess.CompletedProcess[str]: - return subprocess.CompletedProcess(args=[], returncode=1, stdout="", stderr="failed") + vault_path = store.save_active_and_tokens(original_token, [original_token]) + original = vault_path.read_bytes() - monkeypatch.setattr("cisco_sccfm_scripts.token_store.subprocess.run", failed_encrypt) - with pytest.raises(RuntimeError, match="ansible-vault encrypt failed"): - store.save_active_and_tokens(token, [token]) + with pytest.raises(ValueError, match="unique"): + store.save_active_and_tokens(tokens[0], tokens) assert vault_path.read_bytes() == original - assert list(vault_path.parent.glob(".vault.*.tmp")) == [] + + +def test_vault_store_migrates_legacy_active_key_and_preserves_saved_tokens( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + workspace = tmp_path / "workspace" + workspace.mkdir() + ansible_tmp = tmp_path / "ansible-tmp" + ansible_tmp.mkdir() + monkeypatch.setenv("ANSIBLE_LOCAL_TEMP", str(ansible_tmp)) + _ensure_vault_pass_headless(workspace, "synthetic-password") + store = VaultTokenStore(workspace) + saved_tokens = [ + SavedToken(name="production", region="us", token="synthetic-production-token"), + SavedToken(name="staging", region="int", token="synthetic-staging-token"), + ] + store._encrypt_vault( + { + "sccfm_api_token": "synthetic-production-token", + "sccfm_saved_tokens": [ + {"name": token.name, "region": token.region, "token": token.token} + for token in saved_tokens + ], + } + ) + assert _view_vault(workspace) == { + "sccfm_api_token": "synthetic-production-token", + "sccfm_saved_tokens": [ + { + "name": "production", + "region": "us", + "token": "synthetic-production-token", + }, + {"name": "staging", "region": "int", "token": "synthetic-staging-token"}, + ], + } + + loaded_tokens = store.list_tokens() + assert loaded_tokens == saved_tokens + store.save_active_and_tokens(loaded_tokens[1], loaded_tokens) + + payload = _view_vault(workspace) + assert payload == { + "vault_sccfm_api_token": "synthetic-staging-token", + "sccfm_saved_tokens": [ + { + "name": "production", + "region": "us", + "token": "synthetic-production-token", + }, + {"name": "staging", "region": "int", "token": "synthetic-staging-token"}, + ], + } + assert "sccfm_api_token" not in payload + + +def test_existing_reserved_token_name_remains_compatible( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + workspace = tmp_path / "workspace" + workspace.mkdir() + ansible_tmp = tmp_path / "ansible-tmp" + ansible_tmp.mkdir() + monkeypatch.setenv("ANSIBLE_LOCAL_TEMP", str(ansible_tmp)) + _ensure_vault_pass_headless(workspace, "synthetic-password") + _write_encrypted_vault( + workspace, + { + "vault_sccfm_api_token": "existing-token", + "sccfm_saved_tokens": [{"name": "back", "region": "us", "token": "existing-token"}], + }, + ) + + assert VaultTokenStore(workspace).list_tokens() == [ + SavedToken(name="back", region="us", token="existing-token") + ] + + +@pytest.mark.parametrize("represented_current", [False, True]) +def test_vault_store_rejects_ambiguous_distinct_current_and_legacy_tokens( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + represented_current: bool, +) -> None: + workspace = tmp_path / "workspace" + workspace.mkdir() + ansible_tmp = tmp_path / "ansible-tmp" + ansible_tmp.mkdir() + monkeypatch.setenv("ANSIBLE_LOCAL_TEMP", str(ansible_tmp)) + _ensure_vault_pass_headless(workspace, "synthetic-password") + saved = ( + [{"name": "current", "region": "us", "token": "current-token"}] + if represented_current + else [] + ) + vault_path = _write_encrypted_vault( + workspace, + { + "vault_sccfm_api_token": "current-token", + "sccfm_api_token": "legacy-token", + "sccfm_saved_tokens": saved, + }, + ) + original = vault_path.read_bytes() + (workspace / "group_vars" / "all" / "vars.yml").write_text( + "---\nsccfm_region: us\n", + encoding="utf-8", + ) + + with pytest.raises(RuntimeError, match="migrate them manually"): + VaultTokenStore(workspace).list_tokens() + + assert vault_path.read_bytes() == original + + +def test_vault_store_rejects_duplicate_decrypted_keys_without_overwriting( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + workspace = tmp_path / "workspace" + workspace.mkdir() + _ensure_vault_pass_headless(workspace, "synthetic-password") + vault_path = workspace / "group_vars" / "all" / "vault.yml" + vault_path.parent.mkdir(parents=True) + original = b"$ANSIBLE_VAULT;1.1;AES256\nsynthetic-ciphertext\n" + vault_path.write_bytes(original) + plaintext = ( + "vault_asa_password: first-secret\n" + "vault_asa_password: second-secret\n" + "vault_sccfm_api_token: active-token\n" + ) + monkeypatch.setattr( + "cisco_sccfm_scripts.token_store.subprocess.run", + lambda *args, **kwargs: subprocess.CompletedProcess( + args=args, + returncode=0, + stdout=plaintext, + stderr="", + ), + ) + + with pytest.raises(RuntimeError, match="valid YAML"): + VaultTokenStore(workspace).list_tokens() + + assert vault_path.read_bytes() == original + + +def test_vault_store_allows_explicit_override_of_yaml_merge_key( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + workspace = tmp_path / "workspace" + workspace.mkdir() + _ensure_vault_pass_headless(workspace, "synthetic-password") + vault_path = workspace / "group_vars" / "all" / "vault.yml" + vault_path.parent.mkdir(parents=True) + vault_path.write_bytes(b"$ANSIBLE_VAULT;1.1;AES256\nsynthetic-ciphertext\n") + plaintext = ( + "shared: &shared\n" + " setting: inherited\n" + "application:\n" + " <<: *shared\n" + " setting: explicit\n" + "vault_sccfm_api_token: active-token\n" + "sccfm_saved_tokens:\n" + " - name: active\n" + " region: us\n" + " token: active-token\n" + ) + monkeypatch.setattr( + "cisco_sccfm_scripts.token_store.subprocess.run", + lambda *args, **kwargs: subprocess.CompletedProcess( + args=args, + returncode=0, + stdout=plaintext, + stderr="", + ), + ) + + assert VaultTokenStore(workspace).list_tokens() == [ + SavedToken(name="active", region="us", token="active-token") + ] + + +def test_active_only_token_rejects_duplicate_region_keys( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + workspace = tmp_path / "workspace" + workspace.mkdir() + ansible_tmp = tmp_path / "ansible-tmp" + ansible_tmp.mkdir() + monkeypatch.setenv("ANSIBLE_LOCAL_TEMP", str(ansible_tmp)) + _ensure_vault_pass_headless(workspace, "synthetic-password") + vault_path = _write_encrypted_vault( + workspace, + {"vault_sccfm_api_token": "active-token"}, + ) + original = vault_path.read_bytes() + (workspace / "group_vars" / "all" / "vars.yml").write_text( + "sccfm_region: eu\nsccfm_region: us\n", + encoding="utf-8", + ) + + with pytest.raises(RuntimeError, match="Cannot read sccfm_region"): + VaultTokenStore(workspace).list_tokens() + + assert vault_path.read_bytes() == original + + +def test_dangling_vault_symlink_fails_before_other_headless_writes( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + root = tmp_path / "project" + workspace = root / "sccfm-ansible" / "examples" + workspace.mkdir(parents=True) + _ensure_vault_pass_headless(workspace, "synthetic-password") + vault_path = workspace / "group_vars" / "all" / "vault.yml" + vault_path.parent.mkdir(parents=True, exist_ok=True) + vault_path.symlink_to(workspace / "missing-vault") + monkeypatch.setattr(setup_tokens, "_project_root", lambda: root) + monkeypatch.setattr(setup_tokens, "_verify_ansible_vault", lambda: None) + + with pytest.raises(RuntimeError, match="symlinked vault"): + setup_tokens._run_headless( + region="us", + api_token="new-token", + name="new", + profile="default", + vault_password=None, + legacy_region=None, + path=workspace, + ) + + assert vault_path.is_symlink() + assert not (root / ".env").exists() + assert not (workspace / "group_vars" / "all" / "vars.yml").exists() + + +def test_dangling_vars_symlink_fails_before_other_headless_writes( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + root = tmp_path / "project" + workspace = root / "sccfm-ansible" / "examples" + workspace.mkdir(parents=True) + _ensure_vault_pass_headless(workspace, "synthetic-password") + _write_encrypted_vault(workspace, {"vault_sccfm_api_token": "old-token"}) + vars_path = workspace / "group_vars" / "all" / "vars.yml" + vars_path.symlink_to(workspace / "missing-vars") + monkeypatch.setattr(setup_tokens, "_project_root", lambda: root) + monkeypatch.setattr(setup_tokens, "_verify_ansible_vault", lambda: None) + + with pytest.raises(RuntimeError, match="symlinked vars.yml"): + setup_tokens._run_headless( + region="us", + api_token="new-token", + name="new", + profile="default", + vault_password=None, + legacy_region="eu", + path=workspace, + ) + + assert vars_path.is_symlink() + assert not (root / ".env").exists() + + +def test_vault_store_preserves_template_device_secrets_and_arbitrary_variables( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + workspace = tmp_path / "workspace" + workspace.mkdir() + ansible_tmp = tmp_path / "ansible-tmp" + ansible_tmp.mkdir() + monkeypatch.setenv("ANSIBLE_LOCAL_TEMP", str(ansible_tmp)) + _ensure_vault_pass_headless(workspace, "synthetic-password") + existing_payload = { + "vault_sccfm_api_token": "old-active-token", + "sccfm_saved_tokens": [{"name": "old", "region": "eu", "token": "old-active-token"}], + "vault_asa_branch_office_01_password": "synthetic-branch-password", + "vault_asa_datacenter_01_password": "synthetic-datacenter-password", + "nested_application_settings": {"enabled": True, "retries": 3}, + "unrelated_list": ["alpha", "beta"], + } + _write_encrypted_vault(workspace, existing_payload) + store = VaultTokenStore(workspace) + active = SavedToken(name="new", region="us", token="new-active-token") + + store.save_active_and_tokens(active, [active]) + + assert _view_vault(workspace) == { + **{ + key: value + for key, value in existing_payload.items() + if key not in {"vault_sccfm_api_token", "sccfm_saved_tokens"} + }, + "vault_sccfm_api_token": "new-active-token", + "sccfm_saved_tokens": [{"name": "new", "region": "us", "token": "new-active-token"}], + } + + +def test_vault_store_preserves_active_only_current_token_from_template( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + workspace = tmp_path / "workspace" + workspace.mkdir() + ansible_tmp = tmp_path / "ansible-tmp" + ansible_tmp.mkdir() + monkeypatch.setenv("ANSIBLE_LOCAL_TEMP", str(ansible_tmp)) + _ensure_vault_pass_headless(workspace, "synthetic-password") + _write_encrypted_vault( + workspace, + { + "vault_sccfm_api_token": "template-active-token", + "vault_asa_branch_office_01_password": "synthetic-device-password", + }, + ) + monkeypatch.setenv("SCCFM_REGION", "apj") + (workspace / "group_vars" / "all" / "vars.yml").write_text( + "---\nsccfm_region: \"{{ lookup('env', 'SCCFM_REGION') }}\"\n", + encoding="utf-8", + ) + store = VaultTokenStore(workspace) + replacement = SavedToken(name="replacement", region="us", token="replacement-token") + + assert store.list_tokens() == [ + SavedToken(name="vault-active", region="apj", token="template-active-token") + ] + store.save_active_and_tokens(replacement, [replacement]) + + assert _view_vault(workspace) == { + "vault_sccfm_api_token": "replacement-token", + "vault_asa_branch_office_01_password": "synthetic-device-password", + "sccfm_saved_tokens": [ + {"name": "replacement", "region": "us", "token": "replacement-token"}, + {"name": "vault-active", "region": "apj", "token": "template-active-token"}, + ], + } + + +def test_headless_setup_uses_separate_legacy_region_for_dynamic_template_vault( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + root = tmp_path / "project" + workspace = root / "sccfm-ansible" / "examples" + workspace.mkdir(parents=True) + ansible_tmp = tmp_path / "ansible-tmp" + ansible_tmp.mkdir() + monkeypatch.setenv("ANSIBLE_LOCAL_TEMP", str(ansible_tmp)) + monkeypatch.delenv("SCCFM_REGION", raising=False) + _ensure_vault_pass_headless(workspace, "synthetic-password") + _write_encrypted_vault( + workspace, + { + "vault_sccfm_api_token": "template-active-token", + "vault_asa_branch_password": "synthetic-device-password", + }, + ) + vars_path = workspace / "group_vars" / "all" / "vars.yml" + vars_path.write_text( + "---\nsccfm_region: \"{{ lookup('env', 'SCCFM_REGION') }}\"\n", + encoding="utf-8", + ) + monkeypatch.setattr(setup_tokens, "_project_root", lambda: root) + monkeypatch.setattr(setup_tokens, "_verify_ansible_vault", lambda: None) + monkeypatch.setattr(setup_tokens, "_update_cli_config", lambda *args, **kwargs: None) + + setup_tokens._run_headless( + region="us", + api_token="replacement-token", + name="replacement", + profile="default", + vault_password=None, + legacy_region="eu", + path=workspace, + ) + + assert _view_vault(workspace) == { + "vault_sccfm_api_token": "replacement-token", + "vault_asa_branch_password": "synthetic-device-password", + "sccfm_saved_tokens": [ + {"name": "replacement", "region": "us", "token": "replacement-token"}, + {"name": "vault-active", "region": "eu", "token": "template-active-token"}, + ], + } + assert "sccfm_region: us" in vars_path.read_text(encoding="utf-8") + + +def test_headless_setup_does_not_infer_old_token_region_from_new_token( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + root = tmp_path / "project" + workspace = root / "sccfm-ansible" / "examples" + workspace.mkdir(parents=True) + ansible_tmp = tmp_path / "ansible-tmp" + ansible_tmp.mkdir() + monkeypatch.setenv("ANSIBLE_LOCAL_TEMP", str(ansible_tmp)) + monkeypatch.delenv("SCCFM_REGION", raising=False) + _ensure_vault_pass_headless(workspace, "synthetic-password") + vault_path = _write_encrypted_vault( + workspace, + {"vault_sccfm_api_token": "old-eu-token"}, + ) + vars_path = workspace / "group_vars" / "all" / "vars.yml" + dynamic_vars = "---\nsccfm_region: \"{{ lookup('env', 'SCCFM_REGION') }}\"\n" + vars_path.write_text(dynamic_vars, encoding="utf-8") + original = vault_path.read_bytes() + monkeypatch.setattr(setup_tokens, "_project_root", lambda: root) + monkeypatch.setattr(setup_tokens, "_verify_ansible_vault", lambda: None) + + with pytest.raises(click.ClickException, match="SCCFM_LEGACY_REGION"): + setup_tokens._run_headless( + region="us", + api_token="new-us-token", + name="replacement", + profile="default", + vault_password=None, + legacy_region=None, + path=workspace, + ) + + assert vault_path.read_bytes() == original + assert vars_path.read_text(encoding="utf-8") == dynamic_vars + assert not (root / ".env").exists() + + +def test_explicit_legacy_region_overrides_ambient_region_for_old_token( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + workspace = tmp_path / "workspace" + workspace.mkdir() + ansible_tmp = tmp_path / "ansible-tmp" + ansible_tmp.mkdir() + monkeypatch.setenv("ANSIBLE_LOCAL_TEMP", str(ansible_tmp)) + monkeypatch.setenv("SCCFM_REGION", "us") + _ensure_vault_pass_headless(workspace, "synthetic-password") + _write_encrypted_vault( + workspace, + {"vault_sccfm_api_token": "old-eu-token"}, + ) + (workspace / "group_vars" / "all" / "vars.yml").write_text( + "---\nsccfm_region: \"{{ lookup('env', 'SCCFM_REGION') }}\"\n", + encoding="utf-8", + ) + + assert VaultTokenStore(workspace, migration_region="eu").list_tokens() == [ + SavedToken(name="vault-active", region="eu", token="old-eu-token") + ] + + +def test_vault_store_migrates_active_only_legacy_vault_without_stranding_token( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + workspace = tmp_path / "workspace" + workspace.mkdir() + ansible_tmp = tmp_path / "ansible-tmp" + ansible_tmp.mkdir() + monkeypatch.setenv("ANSIBLE_LOCAL_TEMP", str(ansible_tmp)) + _ensure_vault_pass_headless(workspace, "synthetic-password") + _write_encrypted_vault( + workspace, + { + "sccfm_api_token": "legacy-active-token", + "vault_asa_branch_office_01_password": "synthetic-device-password", + }, + ) + vars_path = workspace / "group_vars" / "all" / "vars.yml" + vars_path.write_text("---\nsccfm_region: eu\n", encoding="utf-8") + store = VaultTokenStore(workspace) + replacement = SavedToken(name="replacement", region="us", token="replacement-token") + + legacy_ciphertext = (workspace / "group_vars" / "all" / "vault.yml").read_bytes() + assert store.list_tokens() == [ + SavedToken(name="legacy-active", region="eu", token="legacy-active-token") + ] + assert (workspace / "group_vars" / "all" / "vault.yml").read_bytes() == legacy_ciphertext + store.save_active_and_tokens(replacement, [replacement]) + + assert _view_vault(workspace) == { + "vault_asa_branch_office_01_password": "synthetic-device-password", + "vault_sccfm_api_token": "replacement-token", + "sccfm_saved_tokens": [ + { + "name": "legacy-active", + "region": "eu", + "token": "legacy-active-token", + }, + {"name": "replacement", "region": "us", "token": "replacement-token"}, + ], + } + + +def test_vault_store_uses_collision_safe_name_for_legacy_active_token( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + workspace = tmp_path / "workspace" + workspace.mkdir() + ansible_tmp = tmp_path / "ansible-tmp" + ansible_tmp.mkdir() + monkeypatch.setenv("ANSIBLE_LOCAL_TEMP", str(ansible_tmp)) + _ensure_vault_pass_headless(workspace, "synthetic-password") + _write_encrypted_vault( + workspace, + { + "sccfm_api_token": "legacy-active-token", + "sccfm_saved_tokens": [ + { + "name": "legacy-active", + "region": "us", + "token": "different-token", + } + ], + }, + ) + (workspace / "group_vars" / "all" / "vars.yml").write_text( + "---\nsccfm_region: ci\n", + encoding="utf-8", + ) + + assert VaultTokenStore(workspace).list_tokens() == [ + SavedToken(name="legacy-active", region="us", token="different-token"), + SavedToken(name="legacy-active-2", region="ci", token="legacy-active-token"), + ] + + +def test_vault_store_allows_explicit_update_of_synthesized_legacy_token( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + workspace = tmp_path / "workspace" + workspace.mkdir() + ansible_tmp = tmp_path / "ansible-tmp" + ansible_tmp.mkdir() + monkeypatch.setenv("ANSIBLE_LOCAL_TEMP", str(ansible_tmp)) + _ensure_vault_pass_headless(workspace, "synthetic-password") + _write_encrypted_vault( + workspace, + {"sccfm_api_token": "legacy-active-token"}, + ) + (workspace / "group_vars" / "all" / "vars.yml").write_text( + "---\nsccfm_region: us\n", + encoding="utf-8", + ) + store = VaultTokenStore(workspace) + updated = SavedToken(name="legacy-active", region="us", token="updated-token") + + store.save_active_and_tokens( + updated, + [updated], + preserve_omitted_active=False, + ) + + assert _view_vault(workspace) == { + "vault_sccfm_api_token": "updated-token", + "sccfm_saved_tokens": [{"name": "legacy-active", "region": "us", "token": "updated-token"}], + } + + +@pytest.mark.parametrize( + ("active_key", "synthetic_name"), + [ + ("vault_sccfm_api_token", "vault-active"), + ("sccfm_api_token", "legacy-active"), + ], + ids=["current", "legacy"], +) +def test_manage_tokens_can_remove_a_synthesized_active_only_token( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + active_key: str, + synthetic_name: str, +) -> None: + workspace = tmp_path / "workspace" + workspace.mkdir() + ansible_tmp = tmp_path / "ansible-tmp" + ansible_tmp.mkdir() + monkeypatch.setenv("ANSIBLE_LOCAL_TEMP", str(ansible_tmp)) + _ensure_vault_pass_headless(workspace, "synthetic-password") + _write_encrypted_vault( + workspace, + { + active_key: "removed-token", + "sccfm_saved_tokens": [{"name": "kept", "region": "us", "token": "kept-token"}], + "vault_asa_branch_password": "synthetic-device-password", + }, + ) + (workspace / "group_vars" / "all" / "vars.yml").write_text( + "---\nsccfm_region: us\n", + encoding="utf-8", + ) + + class _Confirmed: + def unsafe_ask(self) -> bool: + return True + + monkeypatch.setattr(setup_tokens, "_resolve_examples_path", lambda path: workspace) + monkeypatch.setattr(devkit_cli, "_ask", lambda choices, message: "token:1") + monkeypatch.setattr( + devkit_cli.questionary, + "confirm", + lambda *args, **kwargs: _Confirmed(), + ) + synced: list[SavedToken] = [] + monkeypatch.setattr( + devkit_cli, + "_sync_active_token", + lambda path, token, profiles: synced.append(cast(SavedToken, token)), + ) + monkeypatch.setattr(devkit_cli, "_matching_cli_profiles", lambda token: ["staging"]) + + devkit_cli._remove_token() + + assert _view_vault(workspace) == { + "vault_sccfm_api_token": "kept-token", + "sccfm_saved_tokens": [{"name": "kept", "region": "us", "token": "kept-token"}], + "vault_asa_branch_password": "synthetic-device-password", + } + assert synced == [SavedToken(name="kept", region="us", token="kept-token")] + + +@pytest.mark.parametrize( + ("selected_name", "expected_active_value", "expected_sync"), + [ + ("secondary", "primary-token", []), + ( + "primary", + "updated-token", + [SavedToken(name="primary", region="us", token="updated-token")], + ), + ], + ids=["non-active", "active"], +) +def test_manage_token_update_preserves_active_identity( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + selected_name: str, + expected_active_value: str, + expected_sync: list[SavedToken], +) -> None: + workspace = tmp_path / "workspace" + workspace.mkdir() + ansible_tmp = tmp_path / "ansible-tmp" + ansible_tmp.mkdir() + monkeypatch.setenv("ANSIBLE_LOCAL_TEMP", str(ansible_tmp)) + _ensure_vault_pass_headless(workspace, "synthetic-password") + primary = SavedToken(name="primary", region="us", token="primary-token") + secondary = SavedToken(name="secondary", region="eu", token="secondary-token") + store = VaultTokenStore(workspace) + store.save_active_and_tokens(primary, [primary, secondary]) + + class _Password: + def unsafe_ask(self) -> str: + return "updated-token" + + synced: list[SavedToken] = [] + monkeypatch.setattr(setup_tokens, "_resolve_examples_path", lambda path: workspace) + selected_index = 0 if selected_name == "primary" else 1 + monkeypatch.setattr( + devkit_cli, + "_ask", + lambda choices, message: f"token:{selected_index}", + ) + monkeypatch.setattr( + devkit_cli.questionary, + "password", + lambda *args, **kwargs: _Password(), + ) + monkeypatch.setattr( + devkit_cli, + "_sync_active_token", + lambda path, token, profiles: synced.append(cast(SavedToken, token)), + ) + monkeypatch.setattr(devkit_cli, "_matching_cli_profiles", lambda token: ["staging"]) + + devkit_cli._update_token() + + payload = _view_vault(workspace) + assert payload["vault_sccfm_api_token"] == expected_active_value + assert synced == expected_sync + + +def test_active_token_update_rolls_back_vault_and_surfaces_on_sync_failure( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + root = tmp_path / "project" + workspace = root / "sccfm-ansible" / "examples" + workspace.mkdir(parents=True) + ansible_tmp = tmp_path / "ansible-tmp" + ansible_tmp.mkdir() + monkeypatch.setenv("ANSIBLE_LOCAL_TEMP", str(ansible_tmp)) + config_path = tmp_path / "cli" / "config.json" + monkeypatch.setenv("SCCFM_CONFIG", str(config_path)) + _ensure_vault_pass_headless(workspace, "synthetic-password") + old = SavedToken(name="active", region="eu", token="old-token") + VaultTokenStore(workspace).save_active_and_tokens(old, [old]) + _write_env_file(root, "eu", "old-token") + _update_vars_region(workspace, "eu") + ConfigService(config_path).save(Config(profile="staging", region="eu", api_token="old-token")) + paths = setup_tokens._credential_state_paths(root, workspace) + before = {path: path.read_bytes() for path in paths} + + class _Password: + def unsafe_ask(self) -> str: + return "new-token" + + monkeypatch.setattr(devkit_cli, "_project_root", lambda: root) + monkeypatch.setattr(setup_tokens, "_resolve_examples_path", lambda path: workspace) + monkeypatch.setattr(setup_tokens, "_verify_ansible_vault", lambda: None) + monkeypatch.setattr(devkit_cli, "_ask", lambda choices, message: "token:0") + monkeypatch.setattr( + devkit_cli.questionary, + "password", + lambda *args, **kwargs: _Password(), + ) + monkeypatch.setattr( + setup_tokens, + "_update_vars_region", + lambda *args, **kwargs: (_ for _ in ()).throw(OSError("injected vars failure")), + ) + + with pytest.raises(OSError, match="injected vars failure"): + devkit_cli._update_token() + + assert {path: path.read_bytes() for path in paths} == before + + +@pytest.mark.parametrize( + ("selected_name", "expected_active", "expected_sync"), + [ + ("secondary", "primary-token", []), + ( + "primary", + "secondary-token", + [SavedToken(name="secondary", region="eu", token="secondary-token")], + ), + ], + ids=["non-active", "active"], +) +def test_manage_token_removal_changes_active_only_when_required( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + selected_name: str, + expected_active: str, + expected_sync: list[SavedToken], +) -> None: + workspace = tmp_path / "workspace" + workspace.mkdir() + ansible_tmp = tmp_path / "ansible-tmp" + ansible_tmp.mkdir() + monkeypatch.setenv("ANSIBLE_LOCAL_TEMP", str(ansible_tmp)) + _ensure_vault_pass_headless(workspace, "synthetic-password") + primary = SavedToken(name="primary", region="us", token="primary-token") + secondary = SavedToken(name="secondary", region="eu", token="secondary-token") + store = VaultTokenStore(workspace) + store.save_active_and_tokens(primary, [primary, secondary]) + + class _Confirmed: + def unsafe_ask(self) -> bool: + return True + + synced: list[SavedToken] = [] + monkeypatch.setattr(setup_tokens, "_resolve_examples_path", lambda path: workspace) + selected_index = 0 if selected_name == "primary" else 1 + monkeypatch.setattr( + devkit_cli, + "_ask", + lambda choices, message: f"token:{selected_index}", + ) + monkeypatch.setattr( + devkit_cli.questionary, + "confirm", + lambda *args, **kwargs: _Confirmed(), + ) + monkeypatch.setattr( + devkit_cli, + "_sync_active_token", + lambda path, token, profiles: synced.append(cast(SavedToken, token)), + ) + monkeypatch.setattr(devkit_cli, "_matching_cli_profiles", lambda token: ["staging"]) + + devkit_cli._remove_token() + + payload = _view_vault(workspace) + assert payload["vault_sccfm_api_token"] == expected_active + assert synced == expected_sync + + +def test_removing_active_token_prompts_for_replacement( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + workspace = tmp_path / "workspace" + workspace.mkdir() + ansible_tmp = tmp_path / "ansible-tmp" + ansible_tmp.mkdir() + monkeypatch.setenv("ANSIBLE_LOCAL_TEMP", str(ansible_tmp)) + _ensure_vault_pass_headless(workspace, "synthetic-password") + tokens = [ + SavedToken(name="active", region="us", token="active-token"), + SavedToken(name="eu", region="eu", token="eu-token"), + SavedToken(name="ci", region="ci", token="ci-token"), + ] + VaultTokenStore(workspace).save_active_and_tokens(tokens[0], tokens) + + class _Confirmed: + def unsafe_ask(self) -> bool: + return True + + prompts: list[str] = [] + + def choose(choices: object, message: str) -> str: + prompts.append(message) + return "token:0" if len(prompts) == 1 else "token:0" + + synced: list[SavedToken] = [] + monkeypatch.setattr(setup_tokens, "_resolve_examples_path", lambda path: workspace) + monkeypatch.setattr(devkit_cli, "_ask", choose) + monkeypatch.setattr( + devkit_cli.questionary, + "confirm", + lambda *args, **kwargs: _Confirmed(), + ) + monkeypatch.setattr(devkit_cli, "_matching_cli_profiles", lambda token: ["staging"]) + monkeypatch.setattr( + devkit_cli, + "_sync_active_token", + lambda path, token, profiles: synced.append(cast(SavedToken, token)), + ) + + devkit_cli._remove_token() + + assert prompts == ["Select a token to remove:", "Select the replacement active token:"] + assert _view_vault(workspace)["vault_sccfm_api_token"] == "ci-token" + assert synced == [SavedToken(name="ci", region="ci", token="ci-token")] + + +def test_manage_tokens_prompts_for_unresolved_active_region( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + workspace = tmp_path / "workspace" + workspace.mkdir() + ansible_tmp = tmp_path / "ansible-tmp" + ansible_tmp.mkdir() + monkeypatch.setenv("ANSIBLE_LOCAL_TEMP", str(ansible_tmp)) + monkeypatch.delenv("SCCFM_REGION", raising=False) + _ensure_vault_pass_headless(workspace, "synthetic-password") + _write_encrypted_vault(workspace, {"vault_sccfm_api_token": "eu-token"}) + (workspace / "group_vars" / "all" / "vars.yml").write_text( + "---\nsccfm_region: \"{{ lookup('env', 'SCCFM_REGION') }}\"\n", + encoding="utf-8", + ) + monkeypatch.setattr(setup_tokens, "_prompt_region", lambda: "eu") + monkeypatch.setattr(setup_tokens, "_verify_ansible_vault", lambda: None) + + _store, active, tokens = devkit_cli._load_managed_tokens(workspace) + + expected = SavedToken(name="vault-active", region="eu", token="eu-token") + assert active == expected + assert tokens == [expected] + + +def test_manage_tokens_preflights_ansible_vault( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + workspace = tmp_path / "workspace" + workspace.mkdir() + called: list[bool] = [] + + def unavailable() -> None: + called.append(True) + raise click.ClickException("ansible-vault not found; poetry install --with dev") + + monkeypatch.setattr(setup_tokens, "_verify_ansible_vault", unavailable) + + with pytest.raises(click.ClickException, match="poetry install --with dev"): + devkit_cli._load_managed_tokens(workspace) + + assert called == [True] + + +def test_cli_profile_matching_preserves_non_default_association( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from cisco_sccfm_cli import models, services + + class _ConfigService: + def __init__(self, path: Path | None = None) -> None: + assert path is not None + + def list_profiles(self) -> list[models.Config]: + return [ + models.Config(profile="default", region="us", api_token="other-token"), + models.Config(profile="staging", region="eu", api_token="active-token"), + ] + + monkeypatch.setattr(services, "ConfigService", _ConfigService) + + assert devkit_cli._matching_cli_profiles( + SavedToken(name="active", region="eu", token="active-token") + ) == ["staging"] + + +def test_cli_profile_matching_honors_config_path_environment( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + custom_config = tmp_path / "custom" / "config.json" + monkeypatch.setenv("SCCFM_CONFIG", str(custom_config)) + ConfigService(custom_config).save( + Config(profile="custom", region="eu", api_token="active-token") + ) + + assert devkit_cli._matching_cli_profiles( + SavedToken(name="active", region="eu", token="active-token") + ) == ["custom"] + + +@pytest.mark.parametrize( + "vars_content", + [ + None, + "---\nsccfm_region: \"{{ lookup('env', 'SCCFM_REGION') }}\"\n", + "---\nsccfm_region: invalid\n", + ], + ids=["missing-region-file", "unresolved-region", "invalid-region"], +) +def test_vault_store_refuses_to_discard_unrepresentable_legacy_active_token( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + vars_content: str | None, +) -> None: + workspace = tmp_path / "workspace" + workspace.mkdir() + ansible_tmp = tmp_path / "ansible-tmp" + ansible_tmp.mkdir() + monkeypatch.setenv("ANSIBLE_LOCAL_TEMP", str(ansible_tmp)) + _ensure_vault_pass_headless(workspace, "synthetic-password") + vault_path = _write_encrypted_vault( + workspace, + {"sccfm_api_token": "legacy-active-token"}, + ) + original = vault_path.read_bytes() + if vars_content is not None: + (workspace / "group_vars" / "all" / "vars.yml").write_text( + vars_content, + encoding="utf-8", + ) + replacement = SavedToken(name="replacement", region="us", token="replacement-token") + + with pytest.raises(RuntimeError, match="Cannot preserve active-only sccfm_api_token"): + VaultTokenStore(workspace).save_active_and_tokens(replacement, [replacement]) + + assert vault_path.read_bytes() == original + + +def test_vault_store_refuses_to_discard_unrepresentable_current_active_token( + tmp_path: Path, +) -> None: + workspace = tmp_path / "workspace" + workspace.mkdir() + _ensure_vault_pass_headless(workspace, "synthetic-password") + vault_path = _write_encrypted_vault( + workspace, + {"vault_sccfm_api_token": "current-active-token"}, + ) + original = vault_path.read_bytes() + replacement = SavedToken(name="replacement", region="us", token="replacement-token") + + with pytest.raises(RuntimeError, match="Cannot preserve active-only vault_sccfm_api_token"): + VaultTokenStore(workspace).save_active_and_tokens(replacement, [replacement]) + + assert vault_path.read_bytes() == original + + +@pytest.mark.parametrize( + "payload", + [ + {"vault_sccfm_api_token": "current-token"}, + {"sccfm_api_token": "legacy-token"}, + ], + ids=["current", "legacy"], +) +def test_cli_e2e_bootstrap_accepts_current_and_legacy_vault_keys( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + payload: dict[str, str], +) -> None: + from cisco_sccfm_cli.e2e import _profile + + examples = tmp_path / "examples" + vault_file = examples / "group_vars" / "all" / "vault.yml" + vault_pass = examples / ".vault_pass" + vars_file = examples / "group_vars" / "all" / "vars.yml" + vault_file.parent.mkdir(parents=True) + vault_file.write_text("encrypted-placeholder", encoding="utf-8") + vault_pass.write_text("placeholder", encoding="utf-8") + vars_file.write_text("sccfm_region: us\n", encoding="utf-8") + monkeypatch.setattr(_profile, "_default_examples_dir", lambda: examples) + monkeypatch.setattr(_profile, "_decode_vault", lambda *args: payload) + + context = _profile.bootstrap_profile(tmp_path / "cli-config") + + expected_token = next(iter(payload.values())) + loaded = ConfigService(path=context.config_path).load(context.profile) + assert loaded is not None + assert loaded.api_token == expected_token + + +def test_cli_e2e_bootstrap_resolves_packaged_region_environment( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from cisco_sccfm_cli.e2e import _profile + + examples = tmp_path / "examples" + vault_file = examples / "group_vars" / "all" / "vault.yml" + vault_pass = examples / ".vault_pass" + vars_file = examples / "group_vars" / "all" / "vars.yml" + vault_file.parent.mkdir(parents=True) + vault_file.write_text("encrypted-placeholder", encoding="utf-8") + vault_pass.write_text("placeholder", encoding="utf-8") + vars_file.write_text( + "sccfm_region: \"{{ lookup('env', 'SCCFM_REGION') }}\"\n", + encoding="utf-8", + ) + monkeypatch.setenv("SCCFM_REGION", "eu") + monkeypatch.setattr(_profile, "_default_examples_dir", lambda: examples) + monkeypatch.setattr( + _profile, + "_decode_vault", + lambda *args: {"vault_sccfm_api_token": "synthetic-token"}, + ) + + context = _profile.bootstrap_profile(tmp_path / "cli-config") + + assert context.region == "eu" + loaded = ConfigService(path=context.config_path).load(context.profile) + assert loaded is not None + assert loaded.region == "eu" + + +def test_cli_e2e_vault_failure_does_not_expose_subprocess_streams( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from cisco_sccfm_cli.e2e import _profile + + sentinel = "sec-e2e-vault-output-sentinel" + monkeypatch.setattr( + _profile.subprocess, + "run", + lambda *args, **kwargs: subprocess.CompletedProcess( + args=args, + returncode=1, + stdout=f"vault_sccfm_api_token: {sentinel}", + stderr=f"partial decrypt: {sentinel}", + ), + ) + + with pytest.raises(RuntimeError, match="could not decrypt") as exc_info: + _profile._decode_vault(tmp_path / "vault.yml", tmp_path / ".vault_pass") + + assert sentinel not in str(exc_info.value) + assert sentinel not in repr(exc_info.value) + + +def test_vault_store_removes_plaintext_temporary_file_on_failure( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + workspace = tmp_path / "workspace" + workspace.mkdir() + _ensure_vault_pass_headless(workspace, "synthetic-password") + vault_path = _write_encrypted_vault( + workspace, + {"vault_sccfm_api_token": "existing-token"}, + ) + (workspace / "group_vars" / "all" / "vars.yml").write_text( + "---\nsccfm_region: us\n", + encoding="utf-8", + ) + original = vault_path.read_bytes() + store = VaultTokenStore(workspace) + token = SavedToken(name="test", region="us", token="synthetic-token") + existing_payload = yaml.safe_dump( + {"vault_sccfm_api_token": "existing-token"}, + sort_keys=False, + ) + + def failed_encrypt(command: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]: + del kwargs + if command[1] == "view": + return subprocess.CompletedProcess( + args=command, + returncode=0, + stdout=existing_payload, + stderr="", + ) + return subprocess.CompletedProcess( + args=command, + returncode=1, + stdout="", + stderr="failed", + ) + + monkeypatch.setattr("cisco_sccfm_scripts.token_store.subprocess.run", failed_encrypt) + with pytest.raises(RuntimeError, match="ansible-vault encrypt failed"): + store.save_active_and_tokens(token, [token]) + + assert vault_path.read_bytes() == original + assert list(vault_path.parent.glob(".vault.*.tmp")) == [] + + +def test_vault_store_wrong_password_fails_closed_and_preserves_ciphertext( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + workspace = tmp_path / "workspace" + workspace.mkdir() + ansible_tmp = tmp_path / "ansible-tmp" + ansible_tmp.mkdir() + monkeypatch.setenv("ANSIBLE_LOCAL_TEMP", str(ansible_tmp)) + _ensure_vault_pass_headless(workspace, "correct-password") + vault_path = _write_encrypted_vault( + workspace, + {"vault_sccfm_api_token": "existing-token"}, + ) + original = vault_path.read_bytes() + (workspace / ".vault_pass").write_text("wrong-password\n", encoding="utf-8") + store = VaultTokenStore(workspace) + token = SavedToken(name="test", region="us", token="synthetic-token") + + with pytest.raises(RuntimeError, match="refusing to overwrite"): + store.save_active_and_tokens(token, [token]) + + assert vault_path.read_bytes() == original + assert list(vault_path.parent.glob(".vault.*.tmp")) == [] + + +@pytest.mark.parametrize( + "ciphertext", + [ + b"not-an-ansible-vault\n", + b"$ANSIBLE_VAULT;1.1;AES256\n0123456789abcdef\n", + ], + ids=["corrupt", "truncated"], +) +def test_vault_store_corrupt_vault_fails_closed_and_preserves_ciphertext( + tmp_path: Path, + ciphertext: bytes, +) -> None: + workspace = tmp_path / "workspace" + workspace.mkdir() + _ensure_vault_pass_headless(workspace, "synthetic-password") + vault_path = workspace / "group_vars" / "all" / "vault.yml" + vault_path.parent.mkdir(parents=True) + vault_path.write_bytes(ciphertext) + store = VaultTokenStore(workspace) + token = SavedToken(name="test", region="us", token="synthetic-token") + + with pytest.raises(RuntimeError, match="refusing to overwrite"): + store.save_active_and_tokens(token, [token]) + + assert vault_path.read_bytes() == ciphertext + assert list(vault_path.parent.glob(".vault.*.tmp")) == [] + + +def test_malformed_decrypted_vault_does_not_expose_secret_in_exception_chain( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + workspace = tmp_path / "workspace" + workspace.mkdir() + _ensure_vault_pass_headless(workspace, "synthetic-password") + vault_path = workspace / "group_vars" / "all" / "vault.yml" + vault_path.parent.mkdir(parents=True) + original = b"$ANSIBLE_VAULT;1.1;AES256\nsynthetic-ciphertext\n" + vault_path.write_bytes(original) + sentinel = "sec999-malformed-yaml-sentinel" + + monkeypatch.setattr( + "cisco_sccfm_scripts.token_store.subprocess.run", + lambda *args, **kwargs: subprocess.CompletedProcess( + args=args, + returncode=0, + stdout=f"vault_sccfm_api_token: [{sentinel}\n", + stderr="", + ), + ) + + with pytest.raises(RuntimeError, match="valid YAML") as exc_info: + VaultTokenStore(workspace).list_tokens() + + rendered = "".join(traceback.format_exception(exc_info.value)) + assert sentinel not in rendered + assert sentinel not in repr(exc_info.value) + assert exc_info.value.__cause__ is None + assert vault_path.read_bytes() == original def test_vault_store_rejects_success_without_ciphertext( @@ -341,6 +2045,21 @@ def test_vault_store_uses_separate_private_temporary_files( captured: dict[str, Path] = {} def inspect_encrypt(command: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]: + if command[1] == "view": + return subprocess.CompletedProcess( + args=command, + returncode=0, + stdout=yaml.safe_dump( + { + "vault_sccfm_api_token": "synthetic-token", + "sccfm_saved_tokens": [ + {"name": "test", "region": "us", "token": "synthetic-token"} + ], + }, + sort_keys=False, + ), + stderr="", + ) plaintext_path = Path(command[2]) ciphertext_path = Path(command[command.index("--output") + 1]) assert plaintext_path != ciphertext_path diff --git a/tests/test_verify_ansible_collection.py b/tests/test_verify_ansible_collection.py index 6e702a5c..dc7720bf 100644 --- a/tests/test_verify_ansible_collection.py +++ b/tests/test_verify_ansible_collection.py @@ -50,7 +50,7 @@ "changelogs/changelog.yaml": b"---\nancestor: null\nreleases: {}\n", "changelogs/config.yaml": b"---\ntitle: Cisco SCCFM Collection\n", "examples/.vault_pass.example": b"replace-me\n", - "examples/group_vars/all/vault.yml.example": b"---\nsccfm_api_token: placeholder\n", + "examples/group_vars/all/vault.yml.example": (b"---\nvault_sccfm_api_token: placeholder\n"), "examples/show_devices.yml": b"---\n- name: Synthetic example\n hosts: localhost\n", "meta/execution-environment.yml": b"---\ndependencies:\n python: requirements.txt\n", "meta/runtime.yml": b"requires_ansible: '>=2.20.0,<2.22.0'\n", @@ -347,11 +347,16 @@ def test_real_build_excludes_sentinels_and_remains_installable(tmp_path: Path) - artifact = output_dir / f"cisco-sccfm-{_COLLECTION_VERSION}.tar.gz" with tarfile.open(artifact, mode="r:gz") as archive: member_names = {member.name for member in archive.getmembers()} + vault_template_member = archive.extractfile("examples/group_vars/all/vault.yml.example") + assert vault_template_member is not None + packaged_vault_template = yaml.safe_load(vault_template_member.read()) for sentinel in sentinel_paths: assert sentinel.relative_to(collection_copy).as_posix() not in member_names assert "examples/.vault_pass.example" in member_names assert "examples/group_vars/all/vault.yml.example" in member_names + assert "vault_sccfm_api_token" in packaged_vault_template + assert "sccfm_api_token" not in packaged_vault_template verify_collection_artifact(artifact, expected_version=_COLLECTION_VERSION) @@ -373,6 +378,15 @@ def test_real_build_excludes_sentinels_and_remains_installable(tmp_path: Path) - ) assert install.returncode == 0, install.stderr + installed_collection = install_root / "ansible_collections" / "cisco" / "sccfm" + installed_vault_template = yaml.safe_load( + (installed_collection / "examples" / "group_vars" / "all" / "vault.yml.example").read_text( + encoding="utf-8" + ) + ) + assert "vault_sccfm_api_token" in installed_vault_template + assert "sccfm_api_token" not in installed_vault_template + discovery_environment = { **environment, "ANSIBLE_COLLECTIONS_PATH": str(install_root), @@ -393,6 +407,42 @@ def test_real_build_excludes_sentinels_and_remains_installable(tmp_path: Path) - } assert set(discovered_modules) == expected_modules + documentation = subprocess.run( + ["ansible-doc", "-j", *sorted(expected_modules)], + capture_output=True, + text=True, + env=discovery_environment, + check=False, + ) + assert documentation.returncode == 0, documentation.stderr + module_documentation = json.loads(documentation.stdout) + assert set(module_documentation) == expected_modules + + expected_auth_examples = ( + "region: \"{{ lookup('env', 'SCCFM_REGION') }}\"", + "api_token: \"{{ lookup('env', 'SCCFM_API_TOKEN') }}\"", + ) + undocumented_auth = { + module_name: [ + expected + for expected in expected_auth_examples + if expected not in details.get("examples", "") + ] + for module_name, details in module_documentation.items() + if any(expected not in details.get("examples", "") for expected in expected_auth_examples) + } + assert undocumented_auth == {} + + legacy_auth_variables = ("{{ sccfm_region }}", "{{ sccfm_api_token }}") + legacy_auth_examples = { + module_name: [ + legacy for legacy in legacy_auth_variables if legacy in details.get("examples", "") + ] + for module_name, details in module_documentation.items() + if any(legacy in details.get("examples", "") for legacy in legacy_auth_variables) + } + assert legacy_auth_examples == {} + inventory_discovery = subprocess.run( ["ansible-doc", "-j", "-l", "-t", "inventory", "cisco.sccfm"], capture_output=True, diff --git a/tests/test_verify_pypi_release.py b/tests/test_verify_pypi_release.py index 1dbc242b..f7ff40e4 100644 --- a/tests/test_verify_pypi_release.py +++ b/tests/test_verify_pypi_release.py @@ -184,6 +184,7 @@ def test_matching_nonempty_subset_is_safely_resumable( version=_VERSION, file_count=1, status=PyPIReleaseStatus.PARTIAL, + missing_filenames=(_SDIST if filename == _WHEEL else _WHEEL,), ) @@ -274,7 +275,12 @@ def fail(request: Request, timeout: float) -> _Response: "PyPI release verified", ), ( - PyPIReleaseVerification(_VERSION, 1, PyPIReleaseStatus.PARTIAL), + PyPIReleaseVerification( + _VERSION, + 1, + PyPIReleaseStatus.PARTIAL, + (_SDIST,), + ), 3, "partially published", ), From 5765498e1dbcbd116277838d419fb8b178a209fe Mon Sep 17 00:00:00 2001 From: Andrei Huides Date: Wed, 12 Aug 2026 17:32:20 +0300 Subject: [PATCH 15/19] fix(lh-102436): use current vault key in CLI E2E Update the CLI vASA onboarding and cleanup playbooks to consume the vault_sccfm_api_token field written by change-tokens. Add regression tests that reject the legacy token reference before Jenkins reaches the CLI E2E suite. --- .../e2e/playbooks/onboard_vasa.yml | 4 +- cisco_sccfm_cli/e2e/playbooks/remove_vasa.yml | 6 +-- tests/test_cli_e2e_playbooks.py | 46 +++++++++++++++++++ 3 files changed, 51 insertions(+), 5 deletions(-) create mode 100644 tests/test_cli_e2e_playbooks.py diff --git a/cisco_sccfm_cli/e2e/playbooks/onboard_vasa.yml b/cisco_sccfm_cli/e2e/playbooks/onboard_vasa.yml index 5ec864c9..c7541475 100644 --- a/cisco_sccfm_cli/e2e/playbooks/onboard_vasa.yml +++ b/cisco_sccfm_cli/e2e/playbooks/onboard_vasa.yml @@ -14,7 +14,7 @@ hosts: localhost gather_facts: false - # sccfm_region / sccfm_api_token are supplied by run_e2e.sh via + # sccfm_region / vault_sccfm_api_token are supplied by run_e2e.sh via # -e "@${VARS_FILE}" -e "@${VAULT_FILE}". Do NOT add vars_files here: # those are loaded/decrypted at parse time before the -e overrides # apply, which would force the default example files to exist and share @@ -23,7 +23,7 @@ module_defaults: group/cisco.sccfm.all: region: "{{ sccfm_region }}" - api_token: "{{ sccfm_api_token }}" + api_token: "{{ vault_sccfm_api_token }}" tasks: # Onboarding here must agree with removal in remove_vasa.yml, whose diff --git a/cisco_sccfm_cli/e2e/playbooks/remove_vasa.yml b/cisco_sccfm_cli/e2e/playbooks/remove_vasa.yml index 9c661cec..04d6ce80 100644 --- a/cisco_sccfm_cli/e2e/playbooks/remove_vasa.yml +++ b/cisco_sccfm_cli/e2e/playbooks/remove_vasa.yml @@ -11,7 +11,7 @@ hosts: localhost gather_facts: false - # sccfm_region / sccfm_api_token are supplied by run_e2e.sh via + # sccfm_region / vault_sccfm_api_token are supplied by run_e2e.sh via # -e "@${VARS_FILE}" -e "@${VAULT_FILE}". Do NOT add vars_files here: # those are loaded/decrypted at parse time before the -e overrides # apply, which would force the default example files to exist and share @@ -40,7 +40,7 @@ url: "{{ sccfm_api_base }}/v1/inventory/devices?limit=50&offset=0&q={{ asa_test_query_all | urlencode }}" method: GET headers: - Authorization: "Bearer {{ sccfm_api_token }}" + Authorization: "Bearer {{ vault_sccfm_api_token }}" Content-Type: "application/json" status_code: [200] register: device_list @@ -50,7 +50,7 @@ url: "{{ sccfm_api_base }}/v1/inventory/devices/{{ item.uid }}" method: DELETE headers: - Authorization: "Bearer {{ sccfm_api_token }}" + Authorization: "Bearer {{ vault_sccfm_api_token }}" Content-Type: "application/json" status_code: [200, 202, 204, 404] loop: "{{ device_list.json['items'] | default([]) }}" diff --git a/tests/test_cli_e2e_playbooks.py b/tests/test_cli_e2e_playbooks.py new file mode 100644 index 00000000..1eee9666 --- /dev/null +++ b/tests/test_cli_e2e_playbooks.py @@ -0,0 +1,46 @@ +# Copyright 2026 Cisco Systems, Inc. and its affiliates +# +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from pathlib import Path +from typing import Any, cast + +import yaml + +_REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +_PLAYBOOKS_DIR = _REPOSITORY_ROOT / "cisco_sccfm_cli" / "e2e" / "playbooks" +_CURRENT_VAULT_TOKEN = "{{ vault_sccfm_api_token }}" +_LEGACY_VAULT_TOKEN = "{{ sccfm_api_token }}" + + +def _load_playbook(filename: str) -> tuple[dict[str, Any], str]: + path = _PLAYBOOKS_DIR / filename + content = path.read_text(encoding="utf-8") + plays = yaml.safe_load(content) + + assert isinstance(plays, list) + assert len(plays) == 1 + assert isinstance(plays[0], dict) + return cast(dict[str, Any], plays[0]), content + + +def test_cli_vasa_onboarding_uses_current_vault_key() -> None: + play, content = _load_playbook("onboard_vasa.yml") + module_defaults = play["module_defaults"]["group/cisco.sccfm.all"] + + assert module_defaults["api_token"] == _CURRENT_VAULT_TOKEN + assert _LEGACY_VAULT_TOKEN not in content + + +def test_cli_vasa_cleanup_uses_current_vault_key() -> None: + play, content = _load_playbook("remove_vasa.yml") + authorizations = [ + task["ansible.builtin.uri"]["headers"]["Authorization"] + for task in play["tasks"] + if "ansible.builtin.uri" in task + ] + + assert authorizations == [f"Bearer {_CURRENT_VAULT_TOKEN}"] * 2 + assert _LEGACY_VAULT_TOKEN not in content From f2e8f47b338df3c37d13086464b672431fb34154 Mon Sep 17 00:00:00 2001 From: Andrei Huides Date: Wed, 12 Aug 2026 17:46:31 +0300 Subject: [PATCH 16/19] fix(lh-102436): close remaining release blockers --- .github/workflows/ci.yml | 9 +- .github/workflows/release.yml | 8 +- .gitignore | 2 + .pre-commit-config.yaml | 4 +- .../devices/asa/smartlicense/command.py | 1 + cisco_sccfm_cli/e2e/README.md | 13 +++ cisco_sccfm_cli/services/config_service.py | 6 +- .../tests/test_consistency_check_script.py | 69 +++++++++++++++ .../tests/test_packaging_metadata.py | 2 + cisco_sccfm_scripts/consistency_check.py | 31 +++++-- cisco_sccfm_scripts/setup_tokens.py | 38 ++++---- cisco_sccfm_scripts/token_store.py | 6 +- .../verify_ansible_collection.py | 3 + .../verify_clean_controller.py | 86 +++++++++++++++---- sccfm-ansible/e2e/README.md | 13 ++- .../e2e/objects/playbooks/vars/test_data.yml | 2 +- .../plugins/module_utils/dependencies.py | 2 +- .../modules/list_asa_not_on_version.py | 8 +- .../modules/list_ftd_not_on_version.py | 12 +-- tests/test_release_artifacts.py | 7 ++ tests/test_token_workspace.py | 29 +++++-- tests/test_verify_ansible_collection.py | 14 +++ 22 files changed, 278 insertions(+), 87 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3b4a3bcf..901148d4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,8 @@ permissions: env: PIP_AUDIT_VERSION: "2.10.1" - DEP002_EXCEPTION_EXPIRES: "2026-09-10" + # The pinned SCCFM SDK requires urllib3<2.1; keep these explicit until the SDK + # permits a patched urllib3 release. DEP002_PIP_AUDIT_EXCEPTIONS: >- --ignore-vuln PYSEC-2026-141 --ignore-vuln PYSEC-2026-1994 @@ -47,11 +48,6 @@ jobs: - name: Audit locked runtime dependencies run: | set -euo pipefail - TODAY_UTC="$(date -u +%F)" - if [[ "${TODAY_UTC}" > "${DEP002_EXCEPTION_EXPIRES}" ]]; then - echo "::error::DEP-002 exceptions expired on ${DEP002_EXCEPTION_EXPIRES}" - exit 1 - fi RUNTIME_REQUIREMENTS="${RUNNER_TEMP}/sccfm-runtime-requirements.txt" poetry show --only main --no-ansi \ | awk 'NF >= 2 {print $1 "==" $2}' \ @@ -100,6 +96,7 @@ jobs: mkdir -p "${COLLECTION_ROOT}" "${SANITY_ROOT}/home" "${SANITY_ROOT}/local" git archive HEAD:sccfm-ansible | tar -x -C "${COLLECTION_ROOT}" rm -rf \ + "${COLLECTION_ROOT}/build.sh" \ "${COLLECTION_ROOT}/ci" \ "${COLLECTION_ROOT}/e2e" \ "${COLLECTION_ROOT}/plugins/modules/tests" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 548dbc74..0ed5c7df 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,7 +17,8 @@ concurrency: env: PIP_AUDIT_VERSION: "2.10.1" - DEP002_EXCEPTION_EXPIRES: "2026-09-10" + # The pinned SCCFM SDK requires urllib3<2.1; keep these explicit until the SDK + # permits a patched urllib3 release. DEP002_PIP_AUDIT_EXCEPTIONS: >- --ignore-vuln PYSEC-2026-141 --ignore-vuln PYSEC-2026-1994 @@ -329,11 +330,6 @@ jobs: RELEASE_VERSION: ${{ steps.version.outputs.version }} run: | set -euo pipefail - TODAY_UTC="$(date -u +%F)" - if [[ "${TODAY_UTC}" > "${DEP002_EXCEPTION_EXPIRES}" ]]; then - echo "::error::DEP-002 exceptions expired on ${DEP002_EXCEPTION_EXPIRES}" - exit 1 - fi read -r -a AUDIT_EXCEPTION_ARGS <<< "${DEP002_PIP_AUDIT_EXCEPTIONS}" verify_python_distribution() { diff --git a/.gitignore b/.gitignore index f140bbb6..ecea10c7 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,8 @@ results/ /.env /.env.* !/.env.example +/..env.* +**/.vault.*.tmp /.tox/ /.eggs/ .poetry_cache/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4c2f4a7a..1da5530d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -15,7 +15,7 @@ repos: hooks: - id: black - repo: https://github.com/pycqa/isort - rev: 5.13.2 + rev: 7.0.0 hooks: - id: isort - repo: https://github.com/pycqa/flake8 @@ -23,7 +23,7 @@ repos: hooks: - id: flake8 - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.12.0 + rev: v1.18.2 hooks: - id: mypy additional_dependencies: diff --git a/cisco_sccfm_cli/commands/inventory/devices/asa/smartlicense/command.py b/cisco_sccfm_cli/commands/inventory/devices/asa/smartlicense/command.py index 5fd09001..f5bc0901 100644 --- a/cisco_sccfm_cli/commands/inventory/devices/asa/smartlicense/command.py +++ b/cisco_sccfm_cli/commands/inventory/devices/asa/smartlicense/command.py @@ -156,6 +156,7 @@ def _validate_token_sources(self, ctx: click.Context, **kwargs: Any) -> None: ) def _read_token_file(self, ctx: click.Context, token_file: Path) -> str: + contents: str try: if token_file == Path("-"): contents = click.get_text_stream("stdin").read() diff --git a/cisco_sccfm_cli/e2e/README.md b/cisco_sccfm_cli/e2e/README.md index 40f6ddd8..77ea162e 100644 --- a/cisco_sccfm_cli/e2e/README.md +++ b/cisco_sccfm_cli/e2e/README.md @@ -1,3 +1,16 @@ + + +## Table of Contents + +- [sccfm-cli E2E Integration Tests](#sccfm-cli-e2e-integration-tests) + - [Structure](#structure) + - [Why This Shape](#why-this-shape) + - [Prerequisites](#prerequisites) + - [Running](#running) + - [Opt-in upgrade phases](#opt-in-upgrade-phases) + + + # sccfm-cli E2E Integration Tests Tenant-backed integration tests for the `sccfm-cli` binary. The suite mirrors `sccfm-ansible/e2e/` 1:1 so the same scenarios are exercised through both surfaces. diff --git a/cisco_sccfm_cli/services/config_service.py b/cisco_sccfm_cli/services/config_service.py index 8549b7c6..56969bb0 100644 --- a/cisco_sccfm_cli/services/config_service.py +++ b/cisco_sccfm_cli/services/config_service.py @@ -9,7 +9,7 @@ import stat from errno import ELOOP, ENOTDIR from pathlib import Path -from typing import Any, Dict, Mapping, TextIO, cast +from typing import Any, Mapping, TextIO, cast from cisco_sccfm_cli.models import Config @@ -55,7 +55,7 @@ def list_profiles(self) -> list[Config]: for name, data in sorted(profiles.items()) ] - def _load_profiles(self) -> Dict[str, Dict[str, Any]]: + def _load_profiles(self) -> dict[str, dict[str, Any]]: self._validate_storage_path() self._validate_read_permissions() self._prepare_default_directory_permissions(repair=False) @@ -67,7 +67,7 @@ def _load_profiles(self) -> Dict[str, Dict[str, Any]]: data = json.load(handle) return dict(data.get("profiles", {})) - def _read_profiles_for_update(self, handle: TextIO) -> Dict[str, Dict[str, Any]]: + def _read_profiles_for_update(self, handle: TextIO) -> dict[str, dict[str, Any]]: data = json.load(handle) return dict(data.get("profiles", {})) diff --git a/cisco_sccfm_core/tests/test_consistency_check_script.py b/cisco_sccfm_core/tests/test_consistency_check_script.py index af2db07b..0715aeba 100644 --- a/cisco_sccfm_core/tests/test_consistency_check_script.py +++ b/cisco_sccfm_core/tests/test_consistency_check_script.py @@ -24,6 +24,11 @@ def _patch_roots(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: "ANSIBLE_MODULES", tmp_path / "sccfm-ansible" / "plugins" / "modules", ) + monkeypatch.setattr( + consistency_check, + "_RUNTIME_YML", + tmp_path / "sccfm-ansible" / "meta" / "runtime.yml", + ) def _write_file(tmp_path: Path, relative_path: str, content: str) -> Path: @@ -92,6 +97,70 @@ def run_module() -> None: assert any("device_count" in message for message in messages) +def test_direct_device_module_is_exempt_from_shared_api_contract( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + _patch_roots(monkeypatch, tmp_path) + module_path = _write_file( + tmp_path, + "sccfm-ansible/plugins/modules/configure_manager.py", + ''' + from ansible.module_utils.basic import AnsibleModule + + DOCUMENTATION = r""" + --- + module: configure_manager + options: + ftd_host: + type: str + cli_key: + type: str + """ + + EXAMPLES = r""" + - name: Onboard through the API + cisco.sccfm.onboard_cdfmc_ftd: {} + register: onboard_result + + - name: Configure the device directly + cisco.sccfm.configure_manager: + ftd_host: "203.0.113.10" + cli_key: "{{ onboard_result.cli_key }}" + """ + + RETURN = r""" + msg: + description: Result message + returned: always + type: str + """ + + def run_module() -> None: + module = AnsibleModule(argument_spec={}, supports_check_mode=True) + module.exit_json(changed=False, msg="ok") + ''', + ) + _write_file( + tmp_path, + "sccfm-ansible/meta/runtime.yml", + """ + action_groups: + cisco.sccfm.all: [] + """, + ) + + metadata = consistency_check._build_ansible_metadata(module_path) + issues = ( + consistency_check.check_ansible_examples(module_path, metadata) + + consistency_check.check_ansible_return_contract(module_path, metadata) + + consistency_check.check_ansible_module_contract(module_path) + + consistency_check.check_ansible_runtime_membership([module_path]) + ) + + assert issues == [] + + def test_cli_command_name_must_match_directory( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, diff --git a/cisco_sccfm_core/tests/test_packaging_metadata.py b/cisco_sccfm_core/tests/test_packaging_metadata.py index fb3ca7fb..5d242598 100644 --- a/cisco_sccfm_core/tests/test_packaging_metadata.py +++ b/cisco_sccfm_core/tests/test_packaging_metadata.py @@ -4,6 +4,8 @@ """Tests for the published Python package metadata.""" +from __future__ import annotations + import tomllib from pathlib import Path from typing import Any diff --git a/cisco_sccfm_scripts/consistency_check.py b/cisco_sccfm_scripts/consistency_check.py index 590a61eb..046bbdad 100644 --- a/cisco_sccfm_scripts/consistency_check.py +++ b/cisco_sccfm_scripts/consistency_check.py @@ -707,14 +707,17 @@ def _parse_example_tasks(source: str) -> list[tuple[int, dict[str, Any]]]: return tasks -def _extract_example_return_lines(source: str) -> dict[str, int]: +def _extract_example_return_lines(source: str, module_name: str) -> dict[str, int]: block = _extract_triple_quoted_assignment(source, "EXAMPLES") if block is None: return {} lines: dict[str, int] = {} - register_names = set( - re.findall(r"^\s*register:\s*([A-Za-z_]\w*)\s*$", block.body, re.MULTILINE) - ) + register_names = { + register + for _, task in _parse_example_tasks(source) + if _task_module_options(task, module_name) is not None + and isinstance((register := task.get("register")), str) + } if not register_names: return lines @@ -748,7 +751,7 @@ def _build_ansible_metadata(file: Path) -> AnsibleModuleMetadata: option_lines=option_lines, return_lines=return_lines, example_option_lines=example_option_lines, - example_return_lines=_extract_example_return_lines(source), + example_return_lines=_extract_example_return_lines(source, file.stem), exit_json_keys=exit_json_keys, operation_key=_ansible_operation_key(file), device_family=_ansible_device_family(file), @@ -791,7 +794,7 @@ def check_ansible_examples(file: Path, metadata: AnsibleModuleMetadata) -> list[ def check_ansible_return_contract(file: Path, metadata: AnsibleModuleMetadata) -> list[Issue]: issues: list[Issue] = [] - documented = set(metadata.return_lines) + documented = set(metadata.return_lines) - _ANSIBLE_META_RETURN_KEYS actual = set(metadata.exit_json_keys) - _ANSIBLE_META_RETURN_KEYS undocumented = sorted(actual - documented) @@ -2017,6 +2020,12 @@ def _task_uses_module_defaults(task: dict[str, Any]) -> bool: return "module_defaults" in block.body and "group/cisco.sccfm.all" in block.body +def _module_uses_shared_sccfm_auth(file: Path) -> bool: + """Return whether a module declares the shared SCCFM API authentication options.""" + metadata = _build_ansible_metadata(file) + return {"region", "api_token"} <= set(metadata.option_lines) + + def check_ansible_module_contract(file: Path) -> list[Issue]: """Check G — new/edited Ansible modules must follow the shared module contract.""" if not _is_ansible_module(file): @@ -2039,7 +2048,9 @@ def check_ansible_module_contract(file: Path) -> list[Issue]: if not has_ansible_module_instantiation: return [] - if not _module_uses_helper(tree, "base_argument_spec"): + uses_shared_auth = _module_uses_shared_sccfm_auth(file) + + if uses_shared_auth and not _module_uses_helper(tree, "base_argument_spec"): issues.append( Issue( file=file, @@ -2052,7 +2063,7 @@ def check_ansible_module_contract(file: Path) -> list[Issue]: ) ) - if not _module_uses_config(tree): + if uses_shared_auth and not _module_uses_config(tree): issues.append( Issue( file=file, @@ -2078,7 +2089,7 @@ def check_ansible_module_contract(file: Path) -> list[Issue]: ) ) - if not _examples_use_shared_module_defaults(source): + if uses_shared_auth and not _examples_use_shared_module_defaults(source): issues.append( Issue( file=file, @@ -2106,6 +2117,8 @@ def check_ansible_runtime_membership(files: Sequence[Path]) -> list[Issue]: for file in files: if not _is_ansible_module(file) or not file.exists(): continue + if not _module_uses_shared_sccfm_auth(file): + continue module_name = file.stem if module_name not in action_group: issues.append( diff --git a/cisco_sccfm_scripts/setup_tokens.py b/cisco_sccfm_scripts/setup_tokens.py index eb75e87a..091de785 100644 --- a/cisco_sccfm_scripts/setup_tokens.py +++ b/cisco_sccfm_scripts/setup_tokens.py @@ -172,21 +172,13 @@ def _resolve_examples_path(path: str | Path | None) -> Path: ) -def _secure_directory(path: Path) -> None: - """Create a user-private directory and enforce mode 0700.""" - if path.is_symlink(): - raise click.ClickException(f"Refusing to use a symlinked private directory: {path}") - path.mkdir(parents=True, exist_ok=True, mode=stat.S_IRWXU) - path.chmod(stat.S_IRWXU) - - def _write_private_text(path: Path, content: str) -> None: """Atomically write UTF-8 text with mode 0600.""" - _write_private_bytes(path, content.encode("utf-8"), mode=0o600) + _write_bytes(path, content.encode("utf-8"), mode=0o600) -def _write_private_bytes(path: Path, content: bytes, *, mode: int) -> None: - """Atomically write private bytes without following a final symlink.""" +def _write_bytes(path: Path, content: bytes, *, mode: int) -> None: + """Atomically write bytes at an explicit mode without following a final symlink.""" if _active_credential_transaction is not None and _active_credential_transaction.write_bytes( path, content, mode=mode ): @@ -234,7 +226,7 @@ def _restore_file(snapshot: _FileSnapshot) -> None: raise RuntimeError("credential rollback encountered a symlink") snapshot.path.unlink(missing_ok=True) return - _write_private_bytes( + _write_bytes( snapshot.path, snapshot.content, mode=snapshot.mode or 0o600, @@ -866,8 +858,7 @@ def _update_vars_region(examples_path: Path, region: str) -> None: ) if vars_path.exists() and not vars_path.is_file(): raise click.ClickException(f"Workspace path is not a regular file: {vars_path}") - _secure_directory(examples_path / "group_vars") - _secure_directory(vars_path.parent) + vars_path.parent.mkdir(parents=True, exist_ok=True, mode=0o755) content = vars_path.read_text() if vars_path.exists() else None if content is not None: @@ -880,16 +871,19 @@ def _update_vars_region(examples_path: Path, region: str) -> None: ) else: updated = content.rstrip() + f"\nsccfm_region: {region}\n" - _write_private_text(vars_path, updated) + _write_bytes(vars_path, updated.encode("utf-8"), mode=0o644) else: - _write_private_text( + _write_bytes( vars_path, - "---\n" - "# Plain variables (not sensitive)\n" - "# These can be committed to version control\n" - "\n" - "# SCCFM connection settings\n" - f"sccfm_region: {region}\n", + ( + "---\n" + "# Plain variables (not sensitive)\n" + "# These can be committed to version control\n" + "\n" + "# SCCFM connection settings\n" + f"sccfm_region: {region}\n" + ).encode("utf-8"), + mode=0o644, ) console.print(f"[green]Set region to '{region}' in:[/green] {vars_path}") diff --git a/cisco_sccfm_scripts/token_store.py b/cisco_sccfm_scripts/token_store.py index af804f97..b572a493 100644 --- a/cisco_sccfm_scripts/token_store.py +++ b/cisco_sccfm_scripts/token_store.py @@ -500,10 +500,8 @@ def _encrypt_vault(self, payload: dict[str, object]) -> Path: self._validate_vault_password_file() if self._vault_path.exists() and not self._vault_path.is_file(): raise RuntimeError("Vault path must be a regular file") - group_vars_path.mkdir(parents=True, exist_ok=True, mode=stat.S_IRWXU) - group_vars_path.chmod(stat.S_IRWXU) - self._vault_path.parent.mkdir(parents=True, exist_ok=True, mode=stat.S_IRWXU) - self._vault_path.parent.chmod(stat.S_IRWXU) + group_vars_path.mkdir(parents=True, exist_ok=True, mode=0o755) + self._vault_path.parent.mkdir(parents=True, exist_ok=True, mode=0o755) content = "---\n" + yaml.dump(payload, default_flow_style=False, sort_keys=False) staging_directory, staged_password_path = self._transaction_staging_directory() diff --git a/cisco_sccfm_scripts/verify_ansible_collection.py b/cisco_sccfm_scripts/verify_ansible_collection.py index e3660f3a..5e203840 100644 --- a/cisco_sccfm_scripts/verify_ansible_collection.py +++ b/cisco_sccfm_scripts/verify_ansible_collection.py @@ -157,6 +157,8 @@ ".keystore", ".log", ".orig", + ".pyc", + ".pyo", ".p12", ".pem", ".pfx", @@ -165,6 +167,7 @@ ".sqlite3", ".swo", ".swp", + ".tmp", ) _CONTENT_RULES: tuple[tuple[str, re.Pattern[bytes]], ...] = ( ( diff --git a/cisco_sccfm_scripts/verify_clean_controller.py b/cisco_sccfm_scripts/verify_clean_controller.py index 1fe4ad66..14a206c3 100644 --- a/cisco_sccfm_scripts/verify_clean_controller.py +++ b/cisco_sccfm_scripts/verify_clean_controller.py @@ -138,26 +138,12 @@ def _documented_probe(controller: _Controller, modules: dict[str, str]) -> str: return probe -def _install_artifacts( +def _install_controller_and_collection( controller: _Controller, - wheel: Path, collection: Path, - expected_version: str, ) -> None: python = controller.binaries / "python" - _run( - controller, - [python, "-I", "-m", "pip", "install", "--no-cache-dir", _ANSIBLE_CORE, wheel], - ) - _run(controller, [python, "-I", "-m", "pip", "check"]) - import_check = """\ -import importlib, importlib.metadata, importlib.util, sys -assert importlib.metadata.version("cisco-sccfm-devkit") == sys.argv[1] -for name in ("cisco_sccfm_cli", "cisco_sccfm_core", "scc_firewall_manager_sdk"): - importlib.import_module(name) -assert importlib.util.find_spec("cisco_sccfm_scripts") is None -""" - _run(controller, [python, "-I", "-c", import_check, expected_version]) + _run(controller, [python, "-I", "-m", "pip", "install", "--no-cache-dir", _ANSIBLE_CORE]) _run( controller, [ @@ -172,6 +158,70 @@ def _install_artifacts( ) +def _verify_missing_devkit_dependency( + controller: _Controller, + expected_version: str, +) -> None: + """Require modules to emit one actionable failure without the paired wheel.""" + probes = { + "list_asa_not_on_version": 'version: "9.20(3)13"', + "list_ftd_not_on_version": 'version: "7.4.1"', + } + requirement = f"cisco-sccfm-devkit=={expected_version}" + forbidden = ( + "ApiException' is not defined", + "Module result deserialization failed", + "Extra data: line", + ) + for module_name, argument in probes.items(): + playbook = controller.work / f"missing-{module_name}.yml" + playbook.write_text( + "---\n" + "- hosts: localhost\n" + " connection: local\n" + " gather_facts: false\n" + " vars:\n" + f" ansible_python_interpreter: {controller.binaries / 'python'}\n" + " tasks:\n" + " - name: Verify missing paired runtime dependency\n" + f" cisco.sccfm.{module_name}:\n" + f" {argument}\n", + encoding="utf-8", + ) + result = _run( + controller, + [controller.binaries / "ansible-playbook", playbook], + check=False, + ) + rendered = f"{result.stdout}\n{result.stderr}" + if ( + result.returncode == 0 + or requirement not in rendered + or any(message in rendered for message in forbidden) + ): + raise CleanControllerVerificationError( + f"{module_name} did not report the missing paired runtime cleanly" + ) + + +def _install_wheel( + controller: _Controller, + wheel: Path, + expected_version: str, +) -> None: + python = controller.binaries / "python" + _run(controller, [python, "-I", "-m", "pip", "install", "--no-cache-dir", wheel]) + _run(controller, [python, "-I", "-m", "pip", "check"]) + import_check = """\ +import importlib, importlib.metadata, importlib.util, sys +assert importlib.metadata.version("cisco-sccfm-devkit") == sys.argv[1] +for name in ("cisco_sccfm_cli", "cisco_sccfm_core", "scc_firewall_manager_sdk"): + importlib.import_module(name) +assert importlib.util.find_spec("cisco_sccfm_scripts") is None +""" + _run(controller, [python, "-I", "-c", import_check, expected_version]) + + def _discover(controller: _Controller) -> tuple[int, int, str]: ansible_doc = controller.binaries / "ansible-doc" modules = _discovered_plugins( @@ -229,7 +279,9 @@ def verify_clean_controller( collection = collection.resolve() with tempfile.TemporaryDirectory(prefix="sccfm-clean-controller-") as temporary: controller = _create_controller(Path(temporary)) - _install_artifacts(controller, wheel, collection, expected_version) + _install_controller_and_collection(controller, collection) + _verify_missing_devkit_dependency(controller, expected_version) + _install_wheel(controller, wheel, expected_version) module_count, inventory_count, probe = _discover(controller) _offline_checks(controller, probe) return module_count, inventory_count, probe diff --git a/sccfm-ansible/e2e/README.md b/sccfm-ansible/e2e/README.md index ccfe2176..d77dd68a 100644 --- a/sccfm-ansible/e2e/README.md +++ b/sccfm-ansible/e2e/README.md @@ -1,3 +1,14 @@ + + +## Table of Contents + +- [Ansible E2E Integration Tests](#ansible-e2e-integration-tests) + - [Structure](#structure) + - [Why This Shape](#why-this-shape) + - [Running](#running) + + + # Ansible E2E Integration Tests This directory contains the tenant-backed integration tests for the Ansible collection. @@ -24,4 +35,4 @@ Run the full integration suite with: sccfm-ansible/e2e/run_e2e.sh ``` -JUnit output is written to `results/ci-ansible-tests.xml` for Jenkins to ingest. \ No newline at end of file +JUnit output is written to `results/ci-ansible-tests.xml` for Jenkins to ingest. diff --git a/sccfm-ansible/e2e/objects/playbooks/vars/test_data.yml b/sccfm-ansible/e2e/objects/playbooks/vars/test_data.yml index 6e1d88fc..146c2dd6 100644 --- a/sccfm-ansible/e2e/objects/playbooks/vars/test_data.yml +++ b/sccfm-ansible/e2e/objects/playbooks/vars/test_data.yml @@ -43,4 +43,4 @@ updated_subnet_labels: - ci-test - monitored -test_query: "name:ci-test-*" \ No newline at end of file +test_query: "name:ci-test-*" diff --git a/sccfm-ansible/plugins/module_utils/dependencies.py b/sccfm-ansible/plugins/module_utils/dependencies.py index f21edcfe..cc40ad2b 100644 --- a/sccfm-ansible/plugins/module_utils/dependencies.py +++ b/sccfm-ansible/plugins/module_utils/dependencies.py @@ -29,7 +29,7 @@ def ensure_required_dependencies(module: "AnsibleModule") -> None: if not _IMPORT_ERRORS: return - _, import_traceback = _IMPORT_ERRORS[0] + import_traceback = _IMPORT_ERRORS[0][1] module.fail_json( msg=missing_required_lib( _PAIRED_DEVKIT_REQUIREMENT, diff --git a/sccfm-ansible/plugins/modules/list_asa_not_on_version.py b/sccfm-ansible/plugins/modules/list_asa_not_on_version.py index 27150e85..f05035b0 100644 --- a/sccfm-ansible/plugins/modules/list_asa_not_on_version.py +++ b/sccfm-ansible/plugins/modules/list_asa_not_on_version.py @@ -160,7 +160,7 @@ from ansible.module_utils.basic import AnsibleModule -from ..module_utils.config import base_argument_spec, create_config +from ..module_utils.config import Config, base_argument_spec, create_config from ..module_utils.dependencies import record_import_error try: @@ -195,8 +195,7 @@ def _validate_version(module: AnsibleModule, version: str) -> None: ) -def _fetch_devices(module: AnsibleModule) -> list[Device]: - config = create_config(module) +def _fetch_devices(module: AnsibleModule, config: Config) -> list[Device]: inventory_service = InventoryService(config=config) uids: list[str] | None = module.params.get("uids") @@ -245,9 +244,10 @@ def run_module() -> None: version: str = module.params["version"] _validate_version(module, version) + config = create_config(module) try: - all_devices = _fetch_devices(module) + all_devices = _fetch_devices(module, config) matched_device_count = len(all_devices) if matched_device_count == 0: diff --git a/sccfm-ansible/plugins/modules/list_ftd_not_on_version.py b/sccfm-ansible/plugins/modules/list_ftd_not_on_version.py index dc4a60e0..a17585f8 100644 --- a/sccfm-ansible/plugins/modules/list_ftd_not_on_version.py +++ b/sccfm-ansible/plugins/modules/list_ftd_not_on_version.py @@ -182,7 +182,7 @@ from ansible.module_utils.basic import AnsibleModule -from ..module_utils.config import base_argument_spec, create_config +from ..module_utils.config import Config, base_argument_spec, create_config from ..module_utils.dependencies import record_import_error try: @@ -225,8 +225,7 @@ def _validate_mode(module: AnsibleModule) -> None: ) -def _fetch_devices(module: AnsibleModule) -> list[Device]: - config = create_config(module) +def _fetch_devices(module: AnsibleModule, config: Config) -> list[Device]: inventory_service = InventoryService(config=config) uids: list[str] | None = module.params.get("uids") @@ -270,9 +269,9 @@ def _serialize_device(device: Device, *, recommended_version: str | None = None) def _check_recommended( module: AnsibleModule, + config: Config, devices: list[Device], ) -> tuple[list[dict[str, Any]], dict[str, str]]: - config = create_config(module) device_uids = [d.uid for d in devices] if not device_uids: return [], {} @@ -314,13 +313,14 @@ def run_module() -> None: version: str | None = module.params.get("version") recommended: bool = module.params.get("recommended", False) mode = "recommended" if recommended else "specified" + config = create_config(module) try: - all_devices = _fetch_devices(module) + all_devices = _fetch_devices(module, config) matched_device_count = len(all_devices) if recommended: - serialized, skipped = _check_recommended(module, all_devices) + serialized, skipped = _check_recommended(module, config, all_devices) evaluated_count = matched_device_count - len(skipped) else: devices_not_on_version = [d for d in all_devices if d.software_version != version] diff --git a/tests/test_release_artifacts.py b/tests/test_release_artifacts.py index 6619c1c3..cd5d16a0 100644 --- a/tests/test_release_artifacts.py +++ b/tests/test_release_artifacts.py @@ -185,10 +185,17 @@ def test_workflows_promote_release_assets_without_rebuilding() -> None: assert " release:\n" not in ci assert " publish-to-pypi:\n" not in ci assert " publish-to-galaxy:\n" not in ci + assert '"${COLLECTION_ROOT}/build.sh"' in ci assert "workflow_dispatch:" in release assert "release:\n types:" not in release assert release.count("${{ inputs.version }}") == 1 assert "RELEASE_VERSION: ${{ inputs.version }}" in release + assert "DEP002_EXCEPTION_EXPIRES" not in ci + assert "DEP002_EXCEPTION_EXPIRES" not in release + assert "exceptions expired" not in ci + assert "exceptions expired" not in release + assert ci.count("--ignore-vuln PYSEC-2026-") == 6 + assert release.count("--ignore-vuln PYSEC-2026-") == 6 build = release.split(" build-release:\n", maxsplit=1)[1].split( " create-draft-release:\n", maxsplit=1 diff --git a/tests/test_token_workspace.py b/tests/test_token_workspace.py index 8b531120..7471e241 100644 --- a/tests/test_token_workspace.py +++ b/tests/test_token_workspace.py @@ -138,11 +138,30 @@ def test_generated_files_use_private_modes(tmp_path: Path) -> None: assert _mode(root) == root_mode assert _mode(workspace) == workspace_mode - assert _mode(workspace / "group_vars") == 0o700 - assert _mode(workspace / "group_vars" / "all") == 0o700 + assert _mode(workspace / "group_vars") == 0o755 + assert _mode(workspace / "group_vars" / "all") == 0o755 assert _mode(env_path) == 0o600 assert _mode(vault_pass) == 0o600 - assert _mode(vars_path) == 0o600 + assert _mode(vars_path) == 0o644 + + +@pytest.mark.parametrize( + "path", + [ + "..env.synthetic-crash-leftover", + "sccfm-ansible/examples/group_vars/all/.vault.plaintext.synthetic.tmp", + ], +) +def test_plaintext_crash_leftovers_are_gitignored(path: str) -> None: + repository = Path(__file__).resolve().parents[1] + + result = subprocess.run( + ["git", "check-ignore", "--no-index", "--quiet", path], + cwd=repository, + check=False, + ) + + assert result.returncode == 0 def test_env_file_shell_quotes_token_without_executing_content(tmp_path: Path) -> None: @@ -488,7 +507,7 @@ def test_credential_transaction_successful_write_stays_with_pinned_parent_after_ with setup_tokens._credential_transaction([credential_path]): trusted_parent.rename(moved_parent) trusted_parent.symlink_to(attacker_parent, target_is_directory=True) - setup_tokens._write_private_bytes(credential_path, b"trusted-update", mode=0o600) + setup_tokens._write_bytes(credential_path, b"trusted-update", mode=0o600) assert (moved_parent / "token").read_bytes() == b"trusted-update" assert attacker_path.read_bytes() == attacker @@ -499,7 +518,7 @@ def test_credential_transaction_normalizes_platform_temp_aliases() -> None: alias_path = Path(tempfile.mkdtemp()) / "missing" / "credential" with setup_tokens._credential_transaction([alias_path]): - setup_tokens._write_private_bytes(alias_path, b"created", mode=0o600) + setup_tokens._write_bytes(alias_path, b"created", mode=0o600) assert alias_path.read_bytes() == b"created" diff --git a/tests/test_verify_ansible_collection.py b/tests/test_verify_ansible_collection.py index dc7720bf..93c03669 100644 --- a/tests/test_verify_ansible_collection.py +++ b/tests/test_verify_ansible_collection.py @@ -172,6 +172,20 @@ def test_verifier_rejects_sensitive_paths(tmp_path: Path, path: str) -> None: verify_collection_artifact(artifact, expected_version=_VERSION) +@pytest.mark.parametrize( + "path", + [ + "plugins/modules/.vault.plaintext.synthetic.tmp", + "plugins/modules/synthetic.pyc", + ], +) +def test_verifier_rejects_runtime_temporary_files(tmp_path: Path, path: str) -> None: + artifact = _build_synthetic_artifact(tmp_path, extra_files={path: b"synthetic\n"}) + + with pytest.raises(ArtifactVerificationError, match="forbidden"): + verify_collection_artifact(artifact, expected_version=_VERSION) + + def test_verifier_rejects_unreviewed_test_content(tmp_path: Path) -> None: artifact = _build_synthetic_artifact( tmp_path, From ee0e8c406cbd9f3f6ea9e2087f5cf546ad7214cc Mon Sep 17 00:00:00 2001 From: Andrei Huides Date: Thu, 13 Aug 2026 14:15:17 +0300 Subject: [PATCH 17/19] fix(lh-102436): use repository secrets for publishing --- .github/workflows/generated-docs.yml | 1 - .github/workflows/release.yml | 5 ----- RELEASING.md | 21 ++++++++++----------- tests/test_release_artifacts.py | 6 ++++-- 4 files changed, 14 insertions(+), 19 deletions(-) diff --git a/.github/workflows/generated-docs.yml b/.github/workflows/generated-docs.yml index 748526ec..c8048cee 100644 --- a/.github/workflows/generated-docs.yml +++ b/.github/workflows/generated-docs.yml @@ -21,7 +21,6 @@ jobs: github.event.workflow_run.head_branch == 'main' ) runs-on: ubuntu-latest - environment: release-bot steps: - name: Checkout main uses: actions/checkout@v7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0ed5c7df..96e2d31d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -30,7 +30,6 @@ env: jobs: build-release: runs-on: ubuntu-latest - environment: release-bot permissions: actions: read contents: write @@ -527,7 +526,6 @@ jobs: create-draft-release: needs: build-release runs-on: ubuntu-latest - environment: release-bot permissions: contents: write steps: @@ -622,7 +620,6 @@ jobs: - build-release - create-draft-release runs-on: ubuntu-latest - environment: pypi permissions: contents: read steps: @@ -766,7 +763,6 @@ jobs: - build-release - publish-to-pypi runs-on: ubuntu-latest - environment: ansible-galaxy permissions: contents: read steps: @@ -936,7 +932,6 @@ jobs: - build-release - publish-to-galaxy runs-on: ubuntu-latest - environment: release-bot permissions: actions: read contents: write diff --git a/RELEASING.md b/RELEASING.md index 3bb58d60..55af0a7d 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -10,17 +10,17 @@ verified files to GitHub Releases, PyPI, and Ansible Galaxy without rebuilding t ## One-time repository setup -Configure these protected GitHub environments, ideally with required reviewers: +Configure these GitHub Actions repository secrets under **Settings > Secrets and variables > +Actions**: -- `release-bot`: `SCCFM_CI_DEPLOY_KEY`, with permission to push the release commit and tag. -- `pypi`: `PYPI_API_TOKEN`, authorized to publish `cisco-sccfm-devkit`. For the first release, the - token must be allowed to create the project. -- `ansible-galaxy`: `GALAXY_API_KEY`, owned by an account authorized to publish in the `cisco` - namespace. +- `SCCFM_CI_DEPLOY_KEY`, with permission to push the release commit and tag. +- `PYPI_API_TOKEN`, authorized to publish `cisco-sccfm-devkit`. For the first release, the token + must be allowed to create the project. +- `GALAXY_API_KEY`, owned by an account authorized to publish in the `cisco` namespace. -Store credentials only as environment secrets. Do not put them in workflow inputs or repository -files. Protect `main`, the release environments, and release tags according to the repository's -maintainer policy. +Store credentials only as repository Actions secrets. Do not put them in workflow inputs or +repository files. Protect `main` and release tags, and limit repository write access to maintainers +authorized to release. Repository secrets do not add a separate publication approval step. ## Before a release @@ -44,8 +44,7 @@ Published registry versions are immutable. Never reuse a version for different c 1. Open **Actions** in `CiscoDevNet/sccfm-devkit` and select **Release**. 2. Select **Run workflow**, choose the `main` branch, and enter the exact `version`. -3. Review and approve the protected environments as each publication stage is reached. -4. Keep the run open until every job succeeds. +3. Keep the run open until every job succeeds. The workflow performs these operations in order: diff --git a/tests/test_release_artifacts.py b/tests/test_release_artifacts.py index cd5d16a0..b956ffb6 100644 --- a/tests/test_release_artifacts.py +++ b/tests/test_release_artifacts.py @@ -237,8 +237,10 @@ def test_workflows_promote_release_assets_without_rebuilding() -> None: assert "poetry build" not in publisher assert "build-ansible-collection" not in publisher - assert "environment: pypi" in pypi + assert "\n environment:" not in release + assert "secrets.SCCFM_CI_DEPLOY_KEY" in build assert "pypa/gh-action-pypi-publish" in pypi + assert "secrets.PYPI_API_TOKEN" in pypi assert "skip-existing:" not in pypi assert 'MISSING_FILES="${PYPI_VERIFICATION##* missing=}"' in pypi assert 'test "$(find dist -mindepth 1 -maxdepth 1 -type f' in pypi @@ -249,7 +251,7 @@ def test_workflows_promote_release_assets_without_rebuilding() -> None: not in pypi.split("3)\n MISSING_FILES=", maxsplit=1)[1] ) assert "- publish-to-pypi" in galaxy - assert "environment: ansible-galaxy" in galaxy + assert "secrets.GALAXY_API_KEY" in galaxy assert "ansible-galaxy collection publish" in galaxy assert "--import-timeout 600" in galaxy assert "LOOKUP_ATTEMPTS=121" in galaxy From 3d3e50d256b08e643f2f184ba84f13674d182e77 Mon Sep 17 00:00:00 2001 From: Andrei Huides Date: Thu, 13 Aug 2026 15:11:33 +0300 Subject: [PATCH 18/19] fix(lh-102436): fix doc styling --- docs/_config.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/_config.yml b/docs/_config.yml index 57b2de1e..7c0e18b9 100644 --- a/docs/_config.yml +++ b/docs/_config.yml @@ -1,7 +1,7 @@ title: SCCFM Devkit Documentation description: Generated references for sccfm-cli and the cisco.sccfm Ansible collection. -url: "" -baseurl: "" +url: "https://ciscodevnet.github.io" +baseurl: "/sccfm-devkit" theme: minima markdown: kramdown From 081eff8b33e6b921d857383b833dfed66bb86d24 Mon Sep 17 00:00:00 2001 From: Andrei Huides Date: Thu, 13 Aug 2026 16:18:38 +0300 Subject: [PATCH 19/19] fix(lh-102436): proper release --- .github/workflows/ci.yml | 548 ++++++++++++++++++ .github/workflows/release.yml | 793 +++++--------------------- RELEASING.md | 122 ++-- tests/test_prepare_ansible_release.py | 40 +- tests/test_release_artifacts.py | 266 +++++---- 5 files changed, 947 insertions(+), 822 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 901148d4..05663006 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,10 @@ on: permissions: contents: read +concurrency: + group: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && 'production-release' || format('ci-{0}', github.run_id) }} + cancel-in-progress: false + env: PIP_AUDIT_VERSION: "2.10.1" # The pinned SCCFM SDK requires urllib3<2.1; keep these explicit until the SDK @@ -124,3 +128,547 @@ jobs: test -f "${COLLECTION_PATH}" poetry run python -m cisco_sccfm_scripts.verify_clean_controller \ "${WHEEL_PATH}" "${COLLECTION_PATH}" --expected-version "${PACKAGE_VERSION}" + + prepare-release: + needs: lint-and-test + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + outputs: + bumped: ${{ steps.version.outputs.bumped }} + version: ${{ steps.version.outputs.version }} + tag: ${{ steps.version.outputs.tag }} + source_commit: ${{ steps.source.outputs.source_commit || steps.version.outputs.source_commit }} + bundle_name: ${{ steps.source.outputs.bundle_name || steps.version.outputs.bundle_name }} + steps: + - name: Checkout main + uses: actions/checkout@v7 + with: + fetch-depth: 0 + ssh-key: ${{ secrets.SCCFM_CI_DEPLOY_KEY }} + + - name: Set up Python + uses: actions/setup-python@v7 + with: + python-version: "3.12" + + - name: Install pipx and Poetry + run: | + python -m pip install --upgrade pip pipx + python -m pipx ensurepath + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + pipx install poetry + + - name: Install dependencies + run: poetry install --no-interaction --with dev,build + + - name: Infer and synchronize release version + id: version + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + test "${GITHUB_REPOSITORY}" = "CiscoDevNet/sccfm-devkit" + test "${GITHUB_REF}" = "refs/heads/main" + test "$(git rev-parse HEAD)" = "${GITHUB_SHA}" + test -z "$(git status --porcelain)" + + PREVIOUS_VERSION="$(poetry version -s)" + REMOTE_MAIN="$(git rev-parse origin/main)" + RECOVERY_SOURCE="" + RECOVERY_VERSION="" + if [[ "${GITHUB_RUN_ATTEMPT}" -gt 1 && "${REMOTE_MAIN}" != "${GITHUB_SHA}" ]]; then + mapfile -t ANCESTRY_COMMITS < <( + git rev-list --ancestry-path --reverse "${GITHUB_SHA}..${REMOTE_MAIN}" + ) + if [[ "${#ANCESTRY_COMMITS[@]}" -gt 0 ]]; then + CANDIDATE_SOURCE="${ANCESTRY_COMMITS[0]}" + CANDIDATE_SUBJECT="$(git show -s --format=%s "${CANDIDATE_SOURCE}")" + if [[ "${CANDIDATE_SUBJECT}" =~ ^bump:\ version\ ((0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*))$ ]]; then + RECOVERY_SOURCE="${CANDIDATE_SOURCE}" + RECOVERY_VERSION="${BASH_REMATCH[1]}" + fi + fi + fi + if [[ -n "${RECOVERY_SOURCE}" ]]; then + RECOVERY_TAG="v${RECOVERY_VERSION}" + test "$(git rev-parse "${RECOVERY_SOURCE}^")" = "${GITHUB_SHA}" + git merge-base --is-ancestor "${RECOVERY_SOURCE}" "${REMOTE_MAIN}" + test "$(git rev-parse "refs/tags/${RECOVERY_TAG}^{commit}")" = "${RECOVERY_SOURCE}" + BUNDLE_PREFIX="sccfm-release-${RECOVERY_VERSION}-${RECOVERY_SOURCE}-attempt-" + RESUME_BUNDLES=() + while IFS= read -r artifact_name; do + if [[ "${artifact_name}" = "${BUNDLE_PREFIX}"* ]] \ + && [[ "${artifact_name#${BUNDLE_PREFIX}}" =~ ^[1-9][0-9]*$ ]]; then + RESUME_BUNDLES+=("${artifact_name}") + fi + done < <( + gh api --paginate \ + "repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/artifacts?per_page=100" \ + --jq '.artifacts[] | select(.expired == false) | .name' + ) + if [[ "${#RESUME_BUNDLES[@]}" -ne 1 ]]; then + echo "::error::expected one unexpired manifest-bound bundle for release recovery" + exit 1 + fi + BUNDLE_NAME="${RESUME_BUNDLES[0]}" + RESUME_ROOT="${RUNNER_TEMP}/release-resume-bundle" + mkdir -p "${RESUME_ROOT}" + gh run download "${GITHUB_RUN_ID}" \ + --repo "${GITHUB_REPOSITORY}" \ + --name "${BUNDLE_NAME}" \ + --dir "${RESUME_ROOT}" + poetry run python -m cisco_sccfm_scripts.release_artifacts verify \ + "${RESUME_ROOT}" \ + --version "${RECOVERY_VERSION}" \ + --tag "${RECOVERY_TAG}" \ + --source-commit "${RECOVERY_SOURCE}" + RECOVERY_MANIFEST_SHA256="$(sha256sum \ + "${RESUME_ROOT}/release-manifest.json" | awk '{print $1}')" + RECOVERY_TAG_MESSAGE="$(git for-each-ref \ + --format='%(contents)' "refs/tags/${RECOVERY_TAG}")" + test "${RECOVERY_TAG_MESSAGE}" \ + = "release-manifest-sha256: ${RECOVERY_MANIFEST_SHA256}" + echo "Recovered the verified ${RECOVERY_TAG} bundle from this workflow run." + echo "bumped=true" >> "$GITHUB_OUTPUT" + echo "resume=true" >> "$GITHUB_OUTPUT" + echo "version=${RECOVERY_VERSION}" >> "$GITHUB_OUTPUT" + echo "tag=${RECOVERY_TAG}" >> "$GITHUB_OUTPUT" + echo "source_commit=${RECOVERY_SOURCE}" >> "$GITHUB_OUTPUT" + echo "bundle_name=${BUNDLE_NAME}" >> "$GITHUB_OUTPUT" + exit 0 + fi + + test "${REMOTE_MAIN}" = "${GITHUB_SHA}" + + set +e + RELEASE_VERSION="$(poetry run cz bump --get-next --yes --check-consistency 2>&1)" + CZ_STATUS=$? + set -e + case "${CZ_STATUS}" in + 0) ;; + 3|21) + printf '%s\n' "${RELEASE_VERSION}" + echo "No release-eligible conventional commits were found." + echo "bumped=false" >> "$GITHUB_OUTPUT" + echo "resume=false" >> "$GITHUB_OUTPUT" + exit 0 + ;; + *) + printf '%s\n' "${RELEASE_VERSION}" + exit "${CZ_STATUS}" + ;; + esac + + if [[ ! "${RELEASE_VERSION}" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then + echo "::error::Commitizen inferred a non-stable or invalid release version" + exit 1 + fi + RELEASE_TAG="v${RELEASE_VERSION}" + if git show-ref --verify --quiet "refs/tags/${RELEASE_TAG}" \ + || gh release view "${RELEASE_TAG}" --repo "${GITHUB_REPOSITORY}" >/dev/null 2>&1; then + echo "::error::inferred release ${RELEASE_TAG} already exists" + exit 1 + fi + + poetry run cz bump --yes --changelog --files-only --check-consistency + test "$(poetry version -s)" = "${RELEASE_VERSION}" + poetry install --only-root --no-interaction + INSTALLED_VERSION="$(poetry run python -c \ + 'from importlib.metadata import version; print(version("cisco-sccfm-devkit"))')" + test "${INSTALLED_VERSION}" = "${RELEASE_VERSION}" + poetry run python -m cisco_sccfm_scripts.prepare_ansible_release \ + sccfm-ansible \ + --previous-version "${PREVIOUS_VERSION}" \ + --release-version "${RELEASE_VERSION}" \ + --release-date "$(date -u +%F)" + poetry run generate-cli-docs + poetry run generate-cli-man-docs + poetry run generate-ansible-docs + + echo "bumped=true" >> "$GITHUB_OUTPUT" + echo "resume=false" >> "$GITHUB_OUTPUT" + echo "previous_version=${PREVIOUS_VERSION}" >> "$GITHUB_OUTPUT" + echo "version=${RELEASE_VERSION}" >> "$GITHUB_OUTPUT" + echo "tag=${RELEASE_TAG}" >> "$GITHUB_OUTPUT" + + - name: Build release artifacts once + if: steps.version.outputs.bumped == 'true' && steps.version.outputs.resume != 'true' + id: artifacts + env: + RELEASE_VERSION: ${{ steps.version.outputs.version }} + run: | + set -euo pipefail + test ! -e dist + poetry run build-ansible-collection + poetry build + + WHEEL_PATH="dist/cisco_sccfm_devkit-${RELEASE_VERSION}-py3-none-any.whl" + SDIST_PATH="dist/cisco_sccfm_devkit-${RELEASE_VERSION}.tar.gz" + COLLECTION_PATH="dist/cisco-sccfm-${RELEASE_VERSION}.tar.gz" + test -f "${WHEEL_PATH}" + test -f "${SDIST_PATH}" + test -f "${COLLECTION_PATH}" + + ARTIFACT_COUNT="$(find dist -mindepth 1 -maxdepth 1 | wc -l | tr -d ' ')" + if [[ "${ARTIFACT_COUNT}" != "3" ]]; then + echo "::error::expected exactly three release artifacts, found ${ARTIFACT_COUNT}" + exit 1 + fi + + poetry run python -m cisco_sccfm_scripts.verify_python_artifacts \ + "${WHEEL_PATH}" "${SDIST_PATH}" + pipx run --spec "twine==6.2.0" twine check --strict \ + "${WHEEL_PATH}" "${SDIST_PATH}" + poetry run python -m cisco_sccfm_scripts.verify_ansible_collection \ + "${COLLECTION_PATH}" --expected-version "${RELEASE_VERSION}" + + echo "wheel_path=${WHEEL_PATH}" >> "$GITHUB_OUTPUT" + echo "sdist_path=${SDIST_PATH}" >> "$GITHUB_OUTPUT" + echo "collection_path=${COLLECTION_PATH}" >> "$GITHUB_OUTPUT" + + - name: Run source gates + if: steps.version.outputs.bumped == 'true' && steps.version.outputs.resume != 'true' + run: | + set -euo pipefail + poetry check --strict --lock + git ls-files '*.py' | xargs poetry run reuse lint-file + poetry run black --check . + poetry run isort --check-only . + poetry run mypy \ + cisco_sccfm_cli \ + cisco_sccfm_core \ + cisco_sccfm_scripts/build_ansible_collection.py \ + cisco_sccfm_scripts/prepare_ansible_release.py \ + cisco_sccfm_scripts/release_artifacts.py \ + cisco_sccfm_scripts/verify_ansible_collection.py \ + cisco_sccfm_scripts/verify_clean_controller.py \ + cisco_sccfm_scripts/verify_pypi_release.py \ + cisco_sccfm_scripts/verify_python_artifacts.py + poetry run pytest --color=yes + poetry run check-doc-links + poetry run check-doc-artifacts + + - name: Install pinned Gitleaks + if: steps.version.outputs.bumped == 'true' && steps.version.outputs.resume != 'true' + run: | + GITLEAKS_BIN_DIR="${RUNNER_TEMP}/gitleaks-bin" + mkdir -p "${GITLEAKS_BIN_DIR}" + GOBIN="${GITLEAKS_BIN_DIR}" go install github.com/gitleaks/gitleaks/v8@v8.30.1 + echo "${GITLEAKS_BIN_DIR}" >> "$GITHUB_PATH" + + - name: Scan exact release artifacts + if: steps.version.outputs.bumped == 'true' && steps.version.outputs.resume != 'true' + run: | + set -euo pipefail + WHEEL_SCAN_ROOT="$(mktemp -d "${RUNNER_TEMP}/sccfm-wheel-scan.XXXXXX")" + python -m zipfile -e \ + "${{ steps.artifacts.outputs.wheel_path }}" \ + "${WHEEL_SCAN_ROOT}" + gitleaks dir --no-banner --no-color --redact=100 "${WHEEL_SCAN_ROOT}" + + for artifact in \ + "${{ steps.artifacts.outputs.sdist_path }}" \ + "${{ steps.artifacts.outputs.collection_path }}"; do + gitleaks dir \ + --no-banner \ + --no-color \ + --redact=100 \ + --max-archive-depth=1 \ + "${artifact}" + done + + - name: Verify exact wheel and sdist installations + if: steps.version.outputs.bumped == 'true' && steps.version.outputs.resume != 'true' + env: + RELEASE_VERSION: ${{ steps.version.outputs.version }} + run: | + set -euo pipefail + read -r -a AUDIT_EXCEPTION_ARGS <<< "${DEP002_PIP_AUDIT_EXCEPTIONS}" + + verify_python_distribution() { + local artifact_path="$1" + local artifact_kind="$2" + local smoke_root + smoke_root="$(mktemp -d "${RUNNER_TEMP}/sccfm-${artifact_kind}-smoke.XXXXXX")" + python -m venv "${smoke_root}/venv" + local smoke_python="${smoke_root}/venv/bin/python" + local smoke_cli="${smoke_root}/venv/bin/sccfm-cli" + + cd "${smoke_root}" + "${smoke_python}" -I -m pip install --no-cache-dir "${artifact_path}" + "${smoke_python}" -I -m pip check + local requirements="${smoke_root}/runtime-requirements.txt" + "${smoke_python}" -I -m pip freeze \ + --exclude cisco-sccfm-devkit \ + > "${requirements}" + test -s "${requirements}" + pipx run --spec "pip-audit==${PIP_AUDIT_VERSION}" pip-audit \ + --strict \ + --no-deps \ + --disable-pip \ + --vulnerability-service osv \ + --progress-spinner off \ + --aliases on \ + --desc off \ + "${AUDIT_EXCEPTION_ARGS[@]}" \ + --requirement "${requirements}" + "${smoke_python}" -I - "${artifact_kind}" <<'PY' + import importlib + import sys + from importlib.metadata import distribution, version + from importlib.util import find_spec + + artifact_kind = sys.argv[1] + if version("scc-firewall-manager-sdk") != "1.17.27": + raise SystemExit("the installed SDK version is not the supported release pin") + for package in ( + "scc_firewall_manager_sdk", + "cisco_sccfm_cli", + "cisco_sccfm_core", + ): + importlib.import_module(package) + if find_spec("cisco_sccfm_scripts") is not None: + raise SystemExit(f"repository scripts leaked into the public {artifact_kind}") + console_scripts = { + entry.name: entry.value + for entry in distribution("cisco-sccfm-devkit").entry_points + if entry.group == "console_scripts" + } + expected = {"sccfm-cli": "cisco_sccfm_cli.cli:cli"} + if console_scripts != expected: + raise SystemExit(f"unexpected public console scripts: {console_scripts}") + PY + "${smoke_cli}" --help >/dev/null + "${smoke_cli}" schema export --format json | "${smoke_python}" -I -c \ + 'from importlib.metadata import version; import json, sys; payload = json.load(sys.stdin); commands = payload.get("commands"); assert payload.get("version") == version("cisco-sccfm-devkit"); assert isinstance(commands, list) and len(commands) == 57' + } + + unset PYTHONHOME PYTHONPATH POETRY_ACTIVE + verify_python_distribution \ + "${GITHUB_WORKSPACE}/${{ steps.artifacts.outputs.wheel_path }}" wheel + verify_python_distribution \ + "${GITHUB_WORKSPACE}/${{ steps.artifacts.outputs.sdist_path }}" sdist + + - name: Verify exact wheel and collection pair + if: steps.version.outputs.bumped == 'true' && steps.version.outputs.resume != 'true' + run: | + poetry run python -m cisco_sccfm_scripts.verify_clean_controller \ + "${{ steps.artifacts.outputs.wheel_path }}" \ + "${{ steps.artifacts.outputs.collection_path }}" \ + --expected-version "${{ steps.version.outputs.version }}" + + - name: Run sanity against exact collection artifact + if: steps.version.outputs.bumped == 'true' && steps.version.outputs.resume != 'true' + run: | + set -euo pipefail + VENV_PATH="$(poetry env info --path)" + SANITY_ROOT="$(mktemp -d "${RUNNER_TEMP}/sccfm-release-sanity.XXXXXX")" + COLLECTION_ROOT="${SANITY_ROOT}/ansible_collections/cisco/sccfm" + mkdir -p "${COLLECTION_ROOT}" "${SANITY_ROOT}/home" "${SANITY_ROOT}/local" + tar -xzf "${{ steps.artifacts.outputs.collection_path }}" -C "${COLLECTION_ROOT}" + cd "${COLLECTION_ROOT}" + HOME="${SANITY_ROOT}/home" \ + XDG_CACHE_HOME="${SANITY_ROOT}/home/.cache" \ + ANSIBLE_LOCAL_TEMP="${SANITY_ROOT}/local" \ + "${VENV_PATH}/bin/ansible-test" sanity --local --truncate 0 + + - name: Commit verified source + if: steps.version.outputs.bumped == 'true' && steps.version.outputs.resume != 'true' + id: source + env: + RELEASE_TAG: ${{ steps.version.outputs.tag }} + RELEASE_VERSION: ${{ steps.version.outputs.version }} + run: | + set -euo pipefail + while IFS= read -r changed_path; do + case "${changed_path}" in + CHANGELOG.md|pyproject.toml|sccfm-ansible/CHANGELOG.rst|\ + sccfm-ansible/changelogs/changelog.yaml|sccfm-ansible/galaxy.yml|\ + sccfm-ansible/plugins/module_utils/dependencies.py|\ + sccfm-ansible/requirements.txt|docs/cli/*|docs/man/*|docs/ansible/*) + ;; + *) + echo "::error::release preparation changed unexpected path: ${changed_path}" + exit 1 + ;; + esac + done < <( + { + git diff --name-only + git ls-files --others --exclude-standard + } | sort -u + ) + + git config user.name "github-actions" + git config user.email "github-actions@users.noreply.cisco.com" + git add \ + CHANGELOG.md \ + pyproject.toml \ + sccfm-ansible/CHANGELOG.rst \ + sccfm-ansible/changelogs/changelog.yaml \ + sccfm-ansible/galaxy.yml \ + sccfm-ansible/plugins/module_utils/dependencies.py \ + sccfm-ansible/requirements.txt \ + docs/cli \ + docs/man \ + docs/ansible + git commit -m "bump: version ${RELEASE_VERSION}" -m "[skip ci]" + test -z "$(git status --porcelain)" + + SOURCE_COMMIT="$(git rev-parse HEAD)" + BUNDLE_NAME="sccfm-release-${RELEASE_VERSION}-${SOURCE_COMMIT}-attempt-${GITHUB_RUN_ATTEMPT}" + echo "source_commit=${SOURCE_COMMIT}" >> "$GITHUB_OUTPUT" + echo "bundle_name=${BUNDLE_NAME}" >> "$GITHUB_OUTPUT" + + - name: Create and verify release manifest + if: steps.version.outputs.bumped == 'true' && steps.version.outputs.resume != 'true' + id: manifest + run: | + poetry run python -m cisco_sccfm_scripts.release_artifacts create dist \ + --version "${{ steps.version.outputs.version }}" \ + --tag "${{ steps.version.outputs.tag }}" \ + --source-commit "${{ steps.source.outputs.source_commit }}" + MANIFEST_SHA256="$(sha256sum dist/release-manifest.json | awk '{print $1}')" + git tag -a "${{ steps.version.outputs.tag }}" \ + -m "release-manifest-sha256: ${MANIFEST_SHA256}" + TAG_MESSAGE="$(git for-each-ref \ + --format='%(contents)' "refs/tags/${{ steps.version.outputs.tag }}")" + test "${TAG_MESSAGE}" = "release-manifest-sha256: ${MANIFEST_SHA256}" + echo "path=dist/release-manifest.json" >> "$GITHUB_OUTPUT" + + - name: Preserve exact release bundle + if: steps.version.outputs.bumped == 'true' && steps.version.outputs.resume != 'true' + uses: actions/upload-artifact@v4 + with: + name: ${{ steps.source.outputs.bundle_name }} + path: | + ${{ steps.artifacts.outputs.wheel_path }} + ${{ steps.artifacts.outputs.sdist_path }} + ${{ steps.artifacts.outputs.collection_path }} + ${{ steps.manifest.outputs.path }} + if-no-files-found: error + compression-level: 0 + retention-days: 30 + + - name: Push release commit and tag atomically + if: steps.version.outputs.bumped == 'true' && steps.version.outputs.resume != 'true' + env: + RELEASE_TAG: ${{ steps.version.outputs.tag }} + SOURCE_COMMIT: ${{ steps.source.outputs.source_commit }} + run: | + set -euo pipefail + if git push --atomic origin \ + HEAD:refs/heads/main \ + "refs/tags/${RELEASE_TAG}:refs/tags/${RELEASE_TAG}"; then + exit 0 + fi + + for attempt in 1 2 3; do + if git fetch --no-tags origin \ + refs/heads/main:refs/remotes/origin/main \ + && git fetch --no-tags origin \ + "refs/tags/${RELEASE_TAG}:refs/tags/${RELEASE_TAG}" \ + && [[ "$(git rev-parse "refs/tags/${RELEASE_TAG}^{commit}")" = "${SOURCE_COMMIT}" ]] \ + && git merge-base --is-ancestor \ + "${SOURCE_COMMIT}" refs/remotes/origin/main; then + echo "::warning::push reported failure, but the atomic remote update was verified" + exit 0 + fi + if [[ "${attempt}" -lt 3 ]]; then + sleep 2 + fi + done + echo "::error::atomic push failed and the intended remote state could not be verified" + exit 1 + + create-draft-release: + needs: prepare-release + if: needs.prepare-release.outputs.bumped == 'true' + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout verified release tag + uses: actions/checkout@v7 + with: + ref: ${{ needs.prepare-release.outputs.tag }} + + - name: Set up Python + uses: actions/setup-python@v7 + with: + python-version: "3.12" + + - name: Download exact release bundle + uses: actions/download-artifact@v4 + with: + name: ${{ needs.prepare-release.outputs.bundle_name }} + path: ${{ runner.temp }}/release-bundle + + - name: Verify bundle and upload draft assets + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_TAG: ${{ needs.prepare-release.outputs.tag }} + RELEASE_VERSION: ${{ needs.prepare-release.outputs.version }} + SOURCE_COMMIT: ${{ needs.prepare-release.outputs.source_commit }} + BUNDLE_DIR: ${{ runner.temp }}/release-bundle + run: | + set -euo pipefail + test "$(git rev-parse HEAD)" = "${SOURCE_COMMIT}" + MANIFEST_SHA256="$(sha256sum \ + "${BUNDLE_DIR}/release-manifest.json" | awk '{print $1}')" + TAG_MESSAGE="$(git for-each-ref \ + --format='%(contents)' "refs/tags/${RELEASE_TAG}")" + test "${TAG_MESSAGE}" = "release-manifest-sha256: ${MANIFEST_SHA256}" + python -m cisco_sccfm_scripts.release_artifacts verify "${BUNDLE_DIR}" \ + --version "${RELEASE_VERSION}" \ + --tag "${RELEASE_TAG}" \ + --source-commit "${SOURCE_COMMIT}" + + if gh release view "${RELEASE_TAG}" --repo "${GITHUB_REPOSITORY}" \ + --json isDraft,isPrerelease,tagName \ + --jq '.tagName + "\t" + (.isDraft | tostring) + "\t" + (.isPrerelease | tostring)' \ + > "${RUNNER_TEMP}/release-identity" 2>/dev/null; then + RELEASE_IDENTITY="$(cat "${RUNNER_TEMP}/release-identity")" + if [[ "${RELEASE_IDENTITY}" != "${RELEASE_TAG}"$'\ttrue\tfalse' ]]; then + echo "::error::existing release is not the expected stable draft release" + exit 1 + fi + else + gh release create "${RELEASE_TAG}" \ + --repo "${GITHUB_REPOSITORY}" \ + --verify-tag \ + --draft \ + --generate-notes \ + --title "${RELEASE_TAG}" + fi + + for local_asset in "${BUNDLE_DIR}"/*; do + asset_name="$(basename "${local_asset}")" + existing_root="${RUNNER_TEMP}/existing-${asset_name}" + mkdir -p "${existing_root}" + if gh release download "${RELEASE_TAG}" \ + --repo "${GITHUB_REPOSITORY}" \ + --pattern "${asset_name}" \ + --dir "${existing_root}" >/dev/null 2>&1; then + cmp -s "${local_asset}" "${existing_root}/${asset_name}" || { + echo "::error::draft release asset differs: ${asset_name}" + exit 1 + } + else + gh release upload "${RELEASE_TAG}" "${local_asset}" \ + --repo "${GITHUB_REPOSITORY}" + fi + done + + VERIFY_ROOT="${RUNNER_TEMP}/verified-draft-assets" + mkdir -p "${VERIFY_ROOT}" + gh release download "${RELEASE_TAG}" \ + --repo "${GITHUB_REPOSITORY}" \ + --dir "${VERIFY_ROOT}" + python -m cisco_sccfm_scripts.release_artifacts verify "${VERIFY_ROOT}" \ + --version "${RELEASE_VERSION}" \ + --tag "${RELEASE_TAG}" \ + --source-commit "${SOURCE_COMMIT}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 96e2d31d..aa85b987 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,7 +4,7 @@ on: workflow_dispatch: inputs: version: - description: "Exact stable version to release (X.Y.Z, without a leading v)" + description: "Existing stable version or tag to deploy (X.Y.Z or vX.Y.Z)" required: true type: string @@ -15,618 +15,126 @@ concurrency: group: production-release cancel-in-progress: false -env: - PIP_AUDIT_VERSION: "2.10.1" - # The pinned SCCFM SDK requires urllib3<2.1; keep these explicit until the SDK - # permits a patched urllib3 release. - DEP002_PIP_AUDIT_EXCEPTIONS: >- - --ignore-vuln PYSEC-2026-141 - --ignore-vuln PYSEC-2026-1994 - --ignore-vuln PYSEC-2026-1995 - --ignore-vuln PYSEC-2026-1996 - --ignore-vuln PYSEC-2026-1998 - --ignore-vuln PYSEC-2026-1999 - jobs: - build-release: + validate-release: runs-on: ubuntu-latest permissions: - actions: read - contents: write + contents: read outputs: - version: ${{ steps.version.outputs.version }} - tag: ${{ steps.version.outputs.tag }} - source_commit: ${{ steps.source.outputs.source_commit || steps.version.outputs.source_commit }} - bundle_name: ${{ steps.source.outputs.bundle_name || steps.version.outputs.bundle_name }} + version: ${{ steps.release.outputs.version }} + tag: ${{ steps.release.outputs.tag }} + source_commit: ${{ steps.bundle.outputs.source_commit }} + manifest_sha256: ${{ steps.bundle.outputs.manifest_sha256 }} + is_draft: ${{ steps.release.outputs.is_draft }} steps: - - name: Checkout main - uses: actions/checkout@v7 - with: - ref: main - fetch-depth: 0 - ssh-key: ${{ secrets.SCCFM_CI_DEPLOY_KEY }} - - - name: Set up Python - uses: actions/setup-python@v7 - with: - python-version: "3.12" - - - name: Install pipx and Poetry - run: | - python -m pip install --upgrade pip pipx - python -m pipx ensurepath - echo "$HOME/.local/bin" >> "$GITHUB_PATH" - pipx install poetry - - - name: Install dependencies - run: poetry install --no-interaction --with dev,build - - - name: Validate requested release - id: version + - name: Validate selected draft release + id: release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - RELEASE_VERSION: ${{ inputs.version }} + REQUESTED_RELEASE: ${{ inputs.version }} run: | set -euo pipefail test "${GITHUB_REPOSITORY}" = "CiscoDevNet/sccfm-devkit" test "${GITHUB_REF}" = "refs/heads/main" - test "$(git rev-parse HEAD)" = "$(git rev-parse origin/main)" - test -z "$(git status --porcelain)" + RELEASE_VERSION="${REQUESTED_RELEASE#v}" if [[ ! "${RELEASE_VERSION}" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then - echo "::error::version must be canonical stable X.Y.Z without a leading v" + echo "::error::release must be an existing stable X.Y.Z version or vX.Y.Z tag" exit 1 fi - - CURRENT_VERSION="$(poetry version -s)" RELEASE_TAG="v${RELEASE_VERSION}" - RESUME_RELEASE=false - if [[ "${CURRENT_VERSION}" = "${RELEASE_VERSION}" ]]; then - if [[ "${GITHUB_RUN_ATTEMPT}" -le 1 ]]; then - echo "::error::version ${RELEASE_VERSION} is already current; only a retry of the original workflow run can resume it" - exit 1 - fi - RESUME_RELEASE=true - else - python - "${CURRENT_VERSION}" "${RELEASE_VERSION}" <<'PY' - import re - import sys - - pattern = re.compile(r"^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$") - current, requested = sys.argv[1:] - if pattern.fullmatch(current) is None: - raise SystemExit(f"current project version is not stable SemVer: {current}") - current_parts = tuple(int(part) for part in current.split(".")) - requested_parts = tuple(int(part) for part in requested.split(".")) - if requested_parts <= current_parts: - raise SystemExit( - f"release version must be greater than current version {current}" - ) - PY - fi - - if [[ "${RESUME_RELEASE}" = "true" ]]; then - if ! git show-ref --verify --quiet "refs/tags/${RELEASE_TAG}"; then - echo "::error::cannot resume without existing tag ${RELEASE_TAG}" - exit 1 - fi - elif git show-ref --verify --quiet "refs/tags/${RELEASE_TAG}"; then - echo "::error::tag ${RELEASE_TAG} already exists" + if [[ "${REQUESTED_RELEASE}" != "${RELEASE_VERSION}" ]] \ + && [[ "${REQUESTED_RELEASE}" != "${RELEASE_TAG}" ]]; then + echo "::error::release must be canonical X.Y.Z or vX.Y.Z" exit 1 fi - if gh release view "${RELEASE_TAG}" --repo "${GITHUB_REPOSITORY}" >/dev/null 2>&1; then - if [[ "${RESUME_RELEASE}" != "true" ]]; then - echo "::error::GitHub release ${RELEASE_TAG} already exists" - exit 1 - fi - RELEASE_IDENTITY="$(gh release view "${RELEASE_TAG}" \ - --repo "${GITHUB_REPOSITORY}" \ - --json isDraft,isPrerelease,tagName \ - --jq 'select(.isPrerelease == false) | .tagName')" - if [[ "${RELEASE_IDENTITY}" != "${RELEASE_TAG}" ]]; then - echo "::error::existing GitHub release is not the expected stable release for ${RELEASE_TAG}" - exit 1 - fi - fi - - DRAFT_RELEASE_TAGS="$(gh api --paginate \ - "repos/${GITHUB_REPOSITORY}/releases?per_page=100" \ - --jq '.[] | select(.draft == true) | .tag_name')" - while IFS= read -r draft_tag; do - [[ -z "${draft_tag}" ]] && continue - if [[ "${RESUME_RELEASE}" != "true" || "${draft_tag}" != "${RELEASE_TAG}" ]]; then - echo "::error::unresolved draft release blocks a new production release: ${draft_tag}" - exit 1 - fi - done <<< "${DRAFT_RELEASE_TAGS}" - - for registry_and_url in \ - "PyPI|https://pypi.org/pypi/cisco-sccfm-devkit/${RELEASE_VERSION}/json" \ - "Ansible Galaxy|https://galaxy.ansible.com/api/v3/plugin/ansible/content/published/collections/index/cisco/sccfm/versions/${RELEASE_VERSION}/"; do - registry="${registry_and_url%%|*}" - registry_url="${registry_and_url#*|}" - http_status="$(curl --silent --show-error --location \ - --retry 3 --retry-all-errors \ - --max-filesize 1048576 \ - --output /dev/null \ - --write-out '%{http_code}' \ - "${registry_url}")" - case "${http_status}" in - 404) - ;; - 200) - if [[ "${RESUME_RELEASE}" != "true" ]]; then - echo "::error::${registry} already contains version ${RELEASE_VERSION}" - exit 1 - fi - ;; - *) - echo "::error::${registry} preflight failed with HTTP ${http_status}" - exit 1 - ;; - esac - done - if [[ "${RESUME_RELEASE}" = "true" ]]; then - SOURCE_COMMIT="$(git rev-parse "refs/tags/${RELEASE_TAG}^{commit}")" - if ! git merge-base --is-ancestor "${SOURCE_COMMIT}" HEAD; then - echo "::error::tag ${RELEASE_TAG} is not contained in checked-out main" - exit 1 - fi - - BUNDLE_PREFIX="sccfm-release-${RELEASE_VERSION}-${SOURCE_COMMIT}-attempt-" - RESUME_BUNDLES=() - while IFS= read -r artifact_name; do - if [[ "${artifact_name}" = "${BUNDLE_PREFIX}"* ]] \ - && [[ "${artifact_name#${BUNDLE_PREFIX}}" =~ ^[1-9][0-9]*$ ]]; then - RESUME_BUNDLES+=("${artifact_name}") - fi - done < <( - gh api --paginate \ - "repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/artifacts?per_page=100" \ - --jq '.artifacts[] | select(.expired == false) | .name' - ) - if [[ "${#RESUME_BUNDLES[@]}" -ne 1 ]]; then - echo "::error::expected exactly one unexpired manifest-bound bundle from this workflow run" + RELEASE_IDENTITY="$(gh release view "${RELEASE_TAG}" \ + --repo "${GITHUB_REPOSITORY}" \ + --json isDraft,isPrerelease,tagName \ + --jq '.tagName + "\t" + (.isDraft | tostring) + "\t" + (.isPrerelease | tostring)')" + case "${RELEASE_IDENTITY}" in + "${RELEASE_TAG}"$'\ttrue\tfalse') RELEASE_IS_DRAFT=true ;; + "${RELEASE_TAG}"$'\tfalse\tfalse') RELEASE_IS_DRAFT=false ;; + *) + echo "::error::${RELEASE_TAG} must identify an existing stable GitHub release" exit 1 - fi - BUNDLE_NAME="${RESUME_BUNDLES[0]}" - RESUME_ROOT="${RUNNER_TEMP}/release-resume-bundle" - mkdir -p "${RESUME_ROOT}" - gh run download "${GITHUB_RUN_ID}" \ - --repo "${GITHUB_REPOSITORY}" \ - --name "${BUNDLE_NAME}" \ - --dir "${RESUME_ROOT}" - poetry run python -m cisco_sccfm_scripts.release_artifacts verify \ - "${RESUME_ROOT}" \ - --version "${RELEASE_VERSION}" \ - --tag "${RELEASE_TAG}" \ - --source-commit "${SOURCE_COMMIT}" - echo "source_commit=${SOURCE_COMMIT}" >> "$GITHUB_OUTPUT" - echo "bundle_name=${BUNDLE_NAME}" >> "$GITHUB_OUTPUT" - fi + ;; + esac echo "version=${RELEASE_VERSION}" >> "$GITHUB_OUTPUT" echo "tag=${RELEASE_TAG}" >> "$GITHUB_OUTPUT" - echo "previous_version=${CURRENT_VERSION}" >> "$GITHUB_OUTPUT" - echo "resume=${RESUME_RELEASE}" >> "$GITHUB_OUTPUT" - - - name: Synchronize exact release version - if: steps.version.outputs.resume != 'true' - env: - RELEASE_VERSION: ${{ steps.version.outputs.version }} - PREVIOUS_VERSION: ${{ steps.version.outputs.previous_version }} - run: | - set -euo pipefail - poetry run cz bump "${RELEASE_VERSION}" \ - --yes \ - --changelog \ - --files-only \ - --check-consistency - test "$(poetry version -s)" = "${RELEASE_VERSION}" - poetry install --only-root --no-interaction - INSTALLED_VERSION="$(poetry run python -c \ - 'from importlib.metadata import version; print(version("cisco-sccfm-devkit"))')" - test "${INSTALLED_VERSION}" = "${RELEASE_VERSION}" - poetry run python -m cisco_sccfm_scripts.prepare_ansible_release \ - sccfm-ansible \ - --previous-version "${PREVIOUS_VERSION}" \ - --release-version "${RELEASE_VERSION}" \ - --release-date "$(date -u +%F)" - poetry run generate-cli-docs - poetry run generate-cli-man-docs - poetry run generate-ansible-docs + echo "is_draft=${RELEASE_IS_DRAFT}" >> "$GITHUB_OUTPUT" - - name: Build release artifacts once - if: steps.version.outputs.resume != 'true' - id: artifacts - env: - RELEASE_VERSION: ${{ steps.version.outputs.version }} - run: | - set -euo pipefail - test ! -e dist - poetry run build-ansible-collection - poetry build - - WHEEL_PATH="dist/cisco_sccfm_devkit-${RELEASE_VERSION}-py3-none-any.whl" - SDIST_PATH="dist/cisco_sccfm_devkit-${RELEASE_VERSION}.tar.gz" - COLLECTION_PATH="dist/cisco-sccfm-${RELEASE_VERSION}.tar.gz" - test -f "${WHEEL_PATH}" - test -f "${SDIST_PATH}" - test -f "${COLLECTION_PATH}" - - ARTIFACT_COUNT="$(find dist -mindepth 1 -maxdepth 1 | wc -l | tr -d ' ')" - if [[ "${ARTIFACT_COUNT}" != "3" ]]; then - echo "::error::expected exactly three release artifacts, found ${ARTIFACT_COUNT}" - exit 1 - fi - - poetry run python -m cisco_sccfm_scripts.verify_python_artifacts \ - "${WHEEL_PATH}" "${SDIST_PATH}" - pipx run --spec "twine==6.2.0" twine check --strict \ - "${WHEEL_PATH}" "${SDIST_PATH}" - poetry run python -m cisco_sccfm_scripts.verify_ansible_collection \ - "${COLLECTION_PATH}" --expected-version "${RELEASE_VERSION}" - - echo "wheel_path=${WHEEL_PATH}" >> "$GITHUB_OUTPUT" - echo "sdist_path=${SDIST_PATH}" >> "$GITHUB_OUTPUT" - echo "collection_path=${COLLECTION_PATH}" >> "$GITHUB_OUTPUT" - - - name: Run source gates - if: steps.version.outputs.resume != 'true' - run: | - set -euo pipefail - poetry check --strict --lock - git ls-files '*.py' | xargs poetry run reuse lint-file - poetry run black --check . - poetry run isort --check-only . - poetry run mypy \ - cisco_sccfm_cli \ - cisco_sccfm_core \ - cisco_sccfm_scripts/build_ansible_collection.py \ - cisco_sccfm_scripts/prepare_ansible_release.py \ - cisco_sccfm_scripts/release_artifacts.py \ - cisco_sccfm_scripts/verify_ansible_collection.py \ - cisco_sccfm_scripts/verify_clean_controller.py \ - cisco_sccfm_scripts/verify_pypi_release.py \ - cisco_sccfm_scripts/verify_python_artifacts.py - poetry run pytest --color=yes - poetry run check-doc-links - poetry run check-doc-artifacts - - - name: Install pinned Gitleaks - if: steps.version.outputs.resume != 'true' - run: | - GITLEAKS_BIN_DIR="${RUNNER_TEMP}/gitleaks-bin" - mkdir -p "${GITLEAKS_BIN_DIR}" - GOBIN="${GITLEAKS_BIN_DIR}" go install github.com/gitleaks/gitleaks/v8@v8.30.1 - echo "${GITLEAKS_BIN_DIR}" >> "$GITHUB_PATH" - - - name: Scan exact release artifacts - if: steps.version.outputs.resume != 'true' - run: | - set -euo pipefail - WHEEL_SCAN_ROOT="$(mktemp -d "${RUNNER_TEMP}/sccfm-wheel-scan.XXXXXX")" - python -m zipfile -e \ - "${{ steps.artifacts.outputs.wheel_path }}" \ - "${WHEEL_SCAN_ROOT}" - gitleaks dir --no-banner --no-color --redact=100 "${WHEEL_SCAN_ROOT}" - - for artifact in \ - "${{ steps.artifacts.outputs.sdist_path }}" \ - "${{ steps.artifacts.outputs.collection_path }}"; do - gitleaks dir \ - --no-banner \ - --no-color \ - --redact=100 \ - --max-archive-depth=1 \ - "${artifact}" - done - - - name: Verify exact wheel and sdist installations - if: steps.version.outputs.resume != 'true' - env: - RELEASE_VERSION: ${{ steps.version.outputs.version }} - run: | - set -euo pipefail - read -r -a AUDIT_EXCEPTION_ARGS <<< "${DEP002_PIP_AUDIT_EXCEPTIONS}" - - verify_python_distribution() { - local artifact_path="$1" - local artifact_kind="$2" - local smoke_root - smoke_root="$(mktemp -d "${RUNNER_TEMP}/sccfm-${artifact_kind}-smoke.XXXXXX")" - python -m venv "${smoke_root}/venv" - local smoke_python="${smoke_root}/venv/bin/python" - local smoke_cli="${smoke_root}/venv/bin/sccfm-cli" - - cd "${smoke_root}" - "${smoke_python}" -I -m pip install --no-cache-dir "${artifact_path}" - "${smoke_python}" -I -m pip check - local requirements="${smoke_root}/runtime-requirements.txt" - "${smoke_python}" -I -m pip freeze \ - --exclude cisco-sccfm-devkit \ - > "${requirements}" - test -s "${requirements}" - pipx run --spec "pip-audit==${PIP_AUDIT_VERSION}" pip-audit \ - --strict \ - --no-deps \ - --disable-pip \ - --vulnerability-service osv \ - --progress-spinner off \ - --aliases on \ - --desc off \ - "${AUDIT_EXCEPTION_ARGS[@]}" \ - --requirement "${requirements}" - "${smoke_python}" -I - "${artifact_kind}" <<'PY' - import importlib - import sys - from importlib.metadata import distribution, version - from importlib.util import find_spec - - artifact_kind = sys.argv[1] - if version("scc-firewall-manager-sdk") != "1.17.27": - raise SystemExit("the installed SDK version is not the supported release pin") - for package in ( - "scc_firewall_manager_sdk", - "cisco_sccfm_cli", - "cisco_sccfm_core", - ): - importlib.import_module(package) - if find_spec("cisco_sccfm_scripts") is not None: - raise SystemExit(f"repository scripts leaked into the public {artifact_kind}") - console_scripts = { - entry.name: entry.value - for entry in distribution("cisco-sccfm-devkit").entry_points - if entry.group == "console_scripts" - } - expected = {"sccfm-cli": "cisco_sccfm_cli.cli:cli"} - if console_scripts != expected: - raise SystemExit(f"unexpected public console scripts: {console_scripts}") - PY - "${smoke_cli}" --help >/dev/null - "${smoke_cli}" schema export --format json | "${smoke_python}" -I -c \ - 'from importlib.metadata import version; import json, sys; payload = json.load(sys.stdin); commands = payload.get("commands"); assert payload.get("version") == version("cisco-sccfm-devkit"); assert isinstance(commands, list) and len(commands) == 57' - } - - unset PYTHONHOME PYTHONPATH POETRY_ACTIVE - verify_python_distribution \ - "${GITHUB_WORKSPACE}/${{ steps.artifacts.outputs.wheel_path }}" wheel - verify_python_distribution \ - "${GITHUB_WORKSPACE}/${{ steps.artifacts.outputs.sdist_path }}" sdist - - - name: Verify exact wheel and collection pair - if: steps.version.outputs.resume != 'true' - run: | - poetry run python -m cisco_sccfm_scripts.verify_clean_controller \ - "${{ steps.artifacts.outputs.wheel_path }}" \ - "${{ steps.artifacts.outputs.collection_path }}" \ - --expected-version "${{ steps.version.outputs.version }}" - - - name: Run sanity against exact collection artifact - if: steps.version.outputs.resume != 'true' - run: | - set -euo pipefail - VENV_PATH="$(poetry env info --path)" - SANITY_ROOT="$(mktemp -d "${RUNNER_TEMP}/sccfm-release-sanity.XXXXXX")" - COLLECTION_ROOT="${SANITY_ROOT}/ansible_collections/cisco/sccfm" - mkdir -p "${COLLECTION_ROOT}" "${SANITY_ROOT}/home" "${SANITY_ROOT}/local" - tar -xzf "${{ steps.artifacts.outputs.collection_path }}" -C "${COLLECTION_ROOT}" - cd "${COLLECTION_ROOT}" - HOME="${SANITY_ROOT}/home" \ - XDG_CACHE_HOME="${SANITY_ROOT}/home/.cache" \ - ANSIBLE_LOCAL_TEMP="${SANITY_ROOT}/local" \ - "${VENV_PATH}/bin/ansible-test" sanity --local --truncate 0 - - - name: Commit and tag verified source - if: steps.version.outputs.resume != 'true' - id: source - env: - RELEASE_TAG: ${{ steps.version.outputs.tag }} - RELEASE_VERSION: ${{ steps.version.outputs.version }} - run: | - set -euo pipefail - while IFS= read -r changed_path; do - case "${changed_path}" in - CHANGELOG.md|pyproject.toml|sccfm-ansible/CHANGELOG.rst|\ - sccfm-ansible/changelogs/changelog.yaml|sccfm-ansible/galaxy.yml|\ - sccfm-ansible/plugins/module_utils/dependencies.py|\ - sccfm-ansible/requirements.txt|docs/cli/*|docs/man/*|docs/ansible/*) - ;; - *) - echo "::error::release preparation changed unexpected path: ${changed_path}" - exit 1 - ;; - esac - done < <( - { - git diff --name-only - git ls-files --others --exclude-standard - } | sort -u - ) - - git config user.name "github-actions" - git config user.email "github-actions@users.noreply.cisco.com" - git add \ - CHANGELOG.md \ - pyproject.toml \ - sccfm-ansible/CHANGELOG.rst \ - sccfm-ansible/changelogs/changelog.yaml \ - sccfm-ansible/galaxy.yml \ - sccfm-ansible/plugins/module_utils/dependencies.py \ - sccfm-ansible/requirements.txt \ - docs/cli \ - docs/man \ - docs/ansible - git commit -m "bump: version ${RELEASE_VERSION}" -m "[skip ci]" - git tag "${RELEASE_TAG}" - test -z "$(git status --porcelain)" - - SOURCE_COMMIT="$(git rev-parse HEAD)" - BUNDLE_NAME="sccfm-release-${RELEASE_VERSION}-${SOURCE_COMMIT}-attempt-${GITHUB_RUN_ATTEMPT}" - echo "source_commit=${SOURCE_COMMIT}" >> "$GITHUB_OUTPUT" - echo "bundle_name=${BUNDLE_NAME}" >> "$GITHUB_OUTPUT" - - - name: Create and verify release manifest - if: steps.version.outputs.resume != 'true' - id: manifest - run: | - poetry run python -m cisco_sccfm_scripts.release_artifacts create dist \ - --version "${{ steps.version.outputs.version }}" \ - --tag "${{ steps.version.outputs.tag }}" \ - --source-commit "${{ steps.source.outputs.source_commit }}" - echo "path=dist/release-manifest.json" >> "$GITHUB_OUTPUT" - - - name: Preserve exact release bundle - if: steps.version.outputs.resume != 'true' - uses: actions/upload-artifact@v4 - with: - name: ${{ steps.source.outputs.bundle_name }} - path: | - ${{ steps.artifacts.outputs.wheel_path }} - ${{ steps.artifacts.outputs.sdist_path }} - ${{ steps.artifacts.outputs.collection_path }} - ${{ steps.manifest.outputs.path }} - if-no-files-found: error - compression-level: 0 - retention-days: 30 - - - name: Push release commit and tag atomically - if: steps.version.outputs.resume != 'true' - env: - RELEASE_TAG: ${{ steps.version.outputs.tag }} - SOURCE_COMMIT: ${{ steps.source.outputs.source_commit }} - run: | - set -euo pipefail - if git push --atomic origin \ - HEAD:refs/heads/main \ - "refs/tags/${RELEASE_TAG}:refs/tags/${RELEASE_TAG}"; then - exit 0 - fi - - for attempt in 1 2 3; do - if REMOTE_REFS="$(git ls-remote --refs origin \ - refs/heads/main "refs/tags/${RELEASE_TAG}")"; then - REMOTE_TAG_COMMIT="$(awk -v ref="refs/tags/${RELEASE_TAG}" \ - '$2 == ref { print $1 }' <<< "${REMOTE_REFS}")" - if [[ "${REMOTE_TAG_COMMIT}" = "${SOURCE_COMMIT}" ]] \ - && git fetch --no-tags origin refs/heads/main \ - && git merge-base --is-ancestor "${SOURCE_COMMIT}" FETCH_HEAD; then - echo "::warning::push reported failure, but the atomic remote update was verified" - exit 0 - fi - fi - if [[ "${attempt}" -lt 3 ]]; then - sleep 2 - fi - done - echo "::error::atomic push failed and the intended remote state could not be verified" - exit 1 - - create-draft-release: - needs: build-release - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - name: Checkout verified release tag + - name: Checkout selected release tag uses: actions/checkout@v7 with: - ref: ${{ needs.build-release.outputs.tag }} + ref: ${{ steps.release.outputs.tag }} + fetch-depth: 0 - name: Set up Python uses: actions/setup-python@v7 with: python-version: "3.12" - - name: Download exact release bundle - uses: actions/download-artifact@v4 - with: - name: ${{ needs.build-release.outputs.bundle_name }} - path: ${{ runner.temp }}/release-bundle - - - name: Verify bundle and upload draft assets + - name: Download exact draft release bundle env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - RELEASE_TAG: ${{ needs.build-release.outputs.tag }} - RELEASE_VERSION: ${{ needs.build-release.outputs.version }} - SOURCE_COMMIT: ${{ needs.build-release.outputs.source_commit }} + RELEASE_TAG: ${{ steps.release.outputs.tag }} BUNDLE_DIR: ${{ runner.temp }}/release-bundle run: | set -euo pipefail - test "$(git rev-parse HEAD)" = "${SOURCE_COMMIT}" - python -m cisco_sccfm_scripts.release_artifacts verify "${BUNDLE_DIR}" \ - --version "${RELEASE_VERSION}" \ - --tag "${RELEASE_TAG}" \ - --source-commit "${SOURCE_COMMIT}" - - RELEASE_IS_DRAFT=true - if gh release view "${RELEASE_TAG}" --repo "${GITHUB_REPOSITORY}" \ - --json isDraft,isPrerelease,tagName \ - --jq '.tagName + "\t" + (.isDraft | tostring) + "\t" + (.isPrerelease | tostring)' \ - > "${RUNNER_TEMP}/release-identity" 2>/dev/null; then - RELEASE_IDENTITY="$(cat "${RUNNER_TEMP}/release-identity")" - case "${RELEASE_IDENTITY}" in - "${RELEASE_TAG}"$'\ttrue\tfalse') RELEASE_IS_DRAFT=true ;; - "${RELEASE_TAG}"$'\tfalse\tfalse') RELEASE_IS_DRAFT=false ;; - *) - echo "::error::existing release is not the expected stable release" - exit 1 - ;; - esac - else - gh release create "${RELEASE_TAG}" \ - --repo "${GITHUB_REPOSITORY}" \ - --verify-tag \ - --draft \ - --generate-notes \ - --title "${RELEASE_TAG}" - fi - - for local_asset in "${BUNDLE_DIR}"/*; do - asset_name="$(basename "${local_asset}")" - existing_root="${RUNNER_TEMP}/existing-${asset_name}" - mkdir -p "${existing_root}" - if gh release download "${RELEASE_TAG}" \ - --repo "${GITHUB_REPOSITORY}" \ - --pattern "${asset_name}" \ - --dir "${existing_root}" >/dev/null 2>&1; then - cmp -s "${local_asset}" "${existing_root}/${asset_name}" || { - echo "::error::draft release asset differs: ${asset_name}" - exit 1 - } - else - if [[ "${RELEASE_IS_DRAFT}" != "true" ]]; then - echo "::error::public release is missing immutable asset ${asset_name}" - exit 1 - fi - gh release upload "${RELEASE_TAG}" "${local_asset}" \ - --repo "${GITHUB_REPOSITORY}" - fi - done - - VERIFY_ROOT="${RUNNER_TEMP}/verified-draft-assets" - mkdir -p "${VERIFY_ROOT}" + mkdir -p "${BUNDLE_DIR}" gh release download "${RELEASE_TAG}" \ --repo "${GITHUB_REPOSITORY}" \ - --dir "${VERIFY_ROOT}" - python -m cisco_sccfm_scripts.release_artifacts verify "${VERIFY_ROOT}" \ + --dir "${BUNDLE_DIR}" + + - name: Verify selected tag, source, manifest, and artifacts + id: bundle + env: + RELEASE_TAG: ${{ steps.release.outputs.tag }} + RELEASE_VERSION: ${{ steps.release.outputs.version }} + BUNDLE_DIR: ${{ runner.temp }}/release-bundle + run: | + set -euo pipefail + test ! -L "${BUNDLE_DIR}/release-manifest.json" + test "$(wc -c < "${BUNDLE_DIR}/release-manifest.json" | tr -d ' ')" -le 65536 + SOURCE_COMMIT="$(jq -er '.source_commit' "${BUNDLE_DIR}/release-manifest.json")" + if [[ ! "${SOURCE_COMMIT}" =~ ^[0-9a-f]{40}$ ]]; then + echo "::error::release manifest contains an invalid source commit" + exit 1 + fi + test "$(git rev-parse HEAD)" = "${SOURCE_COMMIT}" + test "$(git rev-parse "refs/tags/${RELEASE_TAG}^{commit}")" = "${SOURCE_COMMIT}" + MANIFEST_SHA256="$(sha256sum \ + "${BUNDLE_DIR}/release-manifest.json" | awk '{print $1}')" + TAG_MESSAGE="$(git for-each-ref \ + --format='%(contents)' "refs/tags/${RELEASE_TAG}")" + test "${TAG_MESSAGE}" = "release-manifest-sha256: ${MANIFEST_SHA256}" + git fetch --no-tags origin refs/heads/main + git merge-base --is-ancestor "${SOURCE_COMMIT}" FETCH_HEAD + test "$(python -c \ + 'from pathlib import Path; import tomllib; print(tomllib.loads(Path("pyproject.toml").read_text())["project"]["version"])')" \ + = "${RELEASE_VERSION}" + python -m cisco_sccfm_scripts.release_artifacts verify "${BUNDLE_DIR}" \ --version "${RELEASE_VERSION}" \ --tag "${RELEASE_TAG}" \ --source-commit "${SOURCE_COMMIT}" + echo "source_commit=${SOURCE_COMMIT}" >> "$GITHUB_OUTPUT" + echo "manifest_sha256=${MANIFEST_SHA256}" >> "$GITHUB_OUTPUT" publish-to-pypi: - needs: - - build-release - - create-draft-release + needs: validate-release runs-on: ubuntu-latest permissions: contents: read + outputs: + already_published: ${{ steps.pypi.outputs.already_published }} steps: - name: Checkout verified release tag uses: actions/checkout@v7 with: - ref: ${{ needs.build-release.outputs.tag }} + ref: ${{ needs.validate-release.outputs.tag }} - name: Set up Python uses: actions/setup-python@v7 @@ -638,22 +146,31 @@ jobs: python -m pip install --upgrade pip python -m pip install twine==6.2.0 - - name: Download exact release bundle - uses: actions/download-artifact@v4 - with: - name: ${{ needs.build-release.outputs.bundle_name }} - path: ${{ runner.temp }}/release-bundle + - name: Download exact draft release bundle + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_TAG: ${{ needs.validate-release.outputs.tag }} + BUNDLE_DIR: ${{ runner.temp }}/release-bundle + run: | + set -euo pipefail + mkdir -p "${BUNDLE_DIR}" + gh release download "${RELEASE_TAG}" \ + --repo "${GITHUB_REPOSITORY}" \ + --dir "${BUNDLE_DIR}" - name: Verify bundle and inspect PyPI state id: pypi env: - RELEASE_TAG: ${{ needs.build-release.outputs.tag }} - RELEASE_VERSION: ${{ needs.build-release.outputs.version }} - SOURCE_COMMIT: ${{ needs.build-release.outputs.source_commit }} + RELEASE_TAG: ${{ needs.validate-release.outputs.tag }} + RELEASE_VERSION: ${{ needs.validate-release.outputs.version }} + SOURCE_COMMIT: ${{ needs.validate-release.outputs.source_commit }} + EXPECTED_MANIFEST_SHA256: ${{ needs.validate-release.outputs.manifest_sha256 }} BUNDLE_DIR: ${{ runner.temp }}/release-bundle run: | set -euo pipefail test "$(git rev-parse HEAD)" = "${SOURCE_COMMIT}" + test "$(sha256sum "${BUNDLE_DIR}/release-manifest.json" | awk '{print $1}')" \ + = "${EXPECTED_MANIFEST_SHA256}" python -m cisco_sccfm_scripts.release_artifacts verify "${BUNDLE_DIR}" \ --version "${RELEASE_VERSION}" \ --tag "${RELEASE_TAG}" \ @@ -666,7 +183,8 @@ jobs: python -m twine check --strict "${WHEEL_PATH}" "${SDIST_PATH}" set +e - PYPI_VERIFICATION="$(python -m cisco_sccfm_scripts.verify_pypi_release "${BUNDLE_DIR}" \ + PYPI_VERIFICATION="$(python -m cisco_sccfm_scripts.verify_pypi_release \ + "${BUNDLE_DIR}" \ --version "${RELEASE_VERSION}" \ --tag "${RELEASE_TAG}" \ --source-commit "${SOURCE_COMMIT}")" @@ -679,10 +197,12 @@ jobs: case "${PYPI_STATUS}" in 0) echo "publish=false" >> "$GITHUB_OUTPUT" + echo "already_published=true" >> "$GITHUB_OUTPUT" ;; 2) cp "${WHEEL_PATH}" "${SDIST_PATH}" dist/ echo "publish=true" >> "$GITHUB_OUTPUT" + echo "already_published=false" >> "$GITHUB_OUTPUT" ;; 3) MISSING_FILES="${PYPI_VERIFICATION##* missing=}" @@ -695,12 +215,8 @@ jobs: IFS=',' read -r -a MISSING_ARTIFACTS <<< "${MISSING_FILES}" for missing_artifact in "${MISSING_ARTIFACTS[@]}"; do case "${missing_artifact}" in - "$(basename "${WHEEL_PATH}")") - cp "${WHEEL_PATH}" dist/ - ;; - "$(basename "${SDIST_PATH}")") - cp "${SDIST_PATH}" dist/ - ;; + "$(basename "${WHEEL_PATH}")") cp "${WHEEL_PATH}" dist/ ;; + "$(basename "${SDIST_PATH}")") cp "${SDIST_PATH}" dist/ ;; *) echo "::error::partial PyPI verification named an unexpected artifact" exit 1 @@ -709,12 +225,10 @@ jobs: done test "$(find dist -mindepth 1 -maxdepth 1 -type f | wc -l | tr -d ' ')" = "1" echo "publish=true" >> "$GITHUB_OUTPUT" + echo "already_published=false" >> "$GITHUB_OUTPUT" ;; - *) - exit "${PYPI_STATUS}" - ;; + *) exit "${PYPI_STATUS}" ;; esac - echo "packages_dir=dist/" >> "$GITHUB_OUTPUT" - name: Publish exact Python artifacts @@ -726,9 +240,9 @@ jobs: - name: Verify published PyPI release env: - RELEASE_TAG: ${{ needs.build-release.outputs.tag }} - RELEASE_VERSION: ${{ needs.build-release.outputs.version }} - SOURCE_COMMIT: ${{ needs.build-release.outputs.source_commit }} + RELEASE_TAG: ${{ needs.validate-release.outputs.tag }} + RELEASE_VERSION: ${{ needs.validate-release.outputs.version }} + SOURCE_COMMIT: ${{ needs.validate-release.outputs.source_commit }} BUNDLE_DIR: ${{ runner.temp }}/release-bundle run: | set -euo pipefail @@ -760,7 +274,7 @@ jobs: publish-to-galaxy: needs: - - build-release + - validate-release - publish-to-pypi runs-on: ubuntu-latest permissions: @@ -769,7 +283,7 @@ jobs: - name: Checkout verified release tag uses: actions/checkout@v7 with: - ref: ${{ needs.build-release.outputs.tag }} + ref: ${{ needs.validate-release.outputs.tag }} - name: Set up Python uses: actions/setup-python@v7 @@ -781,22 +295,32 @@ jobs: python -m pip install --upgrade pip python -m pip install "ansible-core>=2.20,<2.22" - - name: Download exact release bundle - uses: actions/download-artifact@v4 - with: - name: ${{ needs.build-release.outputs.bundle_name }} - path: ${{ runner.temp }}/release-bundle + - name: Download exact draft release bundle + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_TAG: ${{ needs.validate-release.outputs.tag }} + BUNDLE_DIR: ${{ runner.temp }}/release-bundle + run: | + set -euo pipefail + mkdir -p "${BUNDLE_DIR}" + gh release download "${RELEASE_TAG}" \ + --repo "${GITHUB_REPOSITORY}" \ + --dir "${BUNDLE_DIR}" - name: Verify bundle and inspect Galaxy state id: galaxy env: - RELEASE_TAG: ${{ needs.build-release.outputs.tag }} - RELEASE_VERSION: ${{ needs.build-release.outputs.version }} - SOURCE_COMMIT: ${{ needs.build-release.outputs.source_commit }} + RELEASE_TAG: ${{ needs.validate-release.outputs.tag }} + RELEASE_VERSION: ${{ needs.validate-release.outputs.version }} + SOURCE_COMMIT: ${{ needs.validate-release.outputs.source_commit }} + PYPI_WAS_ALREADY_PUBLISHED: ${{ needs.publish-to-pypi.outputs.already_published }} + EXPECTED_MANIFEST_SHA256: ${{ needs.validate-release.outputs.manifest_sha256 }} BUNDLE_DIR: ${{ runner.temp }}/release-bundle run: | set -euo pipefail test "$(git rev-parse HEAD)" = "${SOURCE_COMMIT}" + test "$(sha256sum "${BUNDLE_DIR}/release-manifest.json" | awk '{print $1}')" \ + = "${EXPECTED_MANIFEST_SHA256}" python -m cisco_sccfm_scripts.release_artifacts verify "${BUNDLE_DIR}" \ --version "${RELEASE_VERSION}" \ --tag "${RELEASE_TAG}" \ @@ -809,10 +333,8 @@ jobs: GALAXY_RESPONSE="${RUNNER_TEMP}/galaxy-version.json" GALAXY_URL="https://galaxy.ansible.com/api/v3/plugin/ansible/content/published/collections/index/cisco/sccfm/versions/${RELEASE_VERSION}/" LOOKUP_ATTEMPTS=1 - if [[ "${GITHUB_RUN_ATTEMPT}" -gt 1 ]]; then - # A previous upload can be accepted while Galaxy is still importing it. - # Wait through the same ten-minute window used by collection publish before - # treating a retry-time 404 as proof that the immutable version is absent. + if [[ "${GITHUB_RUN_ATTEMPT}" -gt 1 ]] \ + || [[ "${PYPI_WAS_ALREADY_PUBLISHED}" = "true" ]]; then LOOKUP_ATTEMPTS=121 fi for attempt in $(seq 1 "${LOOKUP_ATTEMPTS}"); do @@ -835,9 +357,7 @@ jobs: test "$(jq -er '.artifact.sha256' "${GALAXY_RESPONSE}")" = "${LOCAL_SHA256}" echo "publish=false" >> "$GITHUB_OUTPUT" ;; - 404) - echo "publish=true" >> "$GITHUB_OUTPUT" - ;; + 404) echo "publish=true" >> "$GITHUB_OUTPUT" ;; *) echo "::error::Galaxy version lookup failed with HTTP ${HTTP_STATUS}" exit 1 @@ -862,7 +382,7 @@ jobs: - name: Verify published Galaxy collection env: - RELEASE_VERSION: ${{ needs.build-release.outputs.version }} + RELEASE_VERSION: ${{ needs.validate-release.outputs.version }} COLLECTION_PATH: ${{ steps.galaxy.outputs.collection_path }} ANSIBLE_LOCAL_TEMP: ${{ runner.temp }}/ansible-local run: | @@ -929,71 +449,67 @@ jobs: publish-github-release: needs: - - build-release + - validate-release - publish-to-galaxy runs-on: ubuntu-latest permissions: - actions: read contents: write steps: - name: Checkout verified release tag uses: actions/checkout@v7 with: - ref: ${{ needs.build-release.outputs.tag }} + ref: ${{ needs.validate-release.outputs.tag }} - name: Set up Python uses: actions/setup-python@v7 with: python-version: "3.12" - - name: Download exact release bundle - uses: actions/download-artifact@v4 - with: - name: ${{ needs.build-release.outputs.bundle_name }} - path: ${{ runner.temp }}/release-bundle + - name: Download exact draft release bundle + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_TAG: ${{ needs.validate-release.outputs.tag }} + BUNDLE_DIR: ${{ runner.temp }}/release-bundle + run: | + set -euo pipefail + mkdir -p "${BUNDLE_DIR}" + gh release download "${RELEASE_TAG}" \ + --repo "${GITHUB_REPOSITORY}" \ + --dir "${BUNDLE_DIR}" - name: Reverify assets and publish GitHub release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - RELEASE_TAG: ${{ needs.build-release.outputs.tag }} - RELEASE_VERSION: ${{ needs.build-release.outputs.version }} - SOURCE_COMMIT: ${{ needs.build-release.outputs.source_commit }} + RELEASE_TAG: ${{ needs.validate-release.outputs.tag }} + RELEASE_VERSION: ${{ needs.validate-release.outputs.version }} + SOURCE_COMMIT: ${{ needs.validate-release.outputs.source_commit }} + EXPECTED_MANIFEST_SHA256: ${{ needs.validate-release.outputs.manifest_sha256 }} BUNDLE_DIR: ${{ runner.temp }}/release-bundle run: | set -euo pipefail test "$(git rev-parse HEAD)" = "${SOURCE_COMMIT}" + test "$(sha256sum "${BUNDLE_DIR}/release-manifest.json" | awk '{print $1}')" \ + = "${EXPECTED_MANIFEST_SHA256}" python -m cisco_sccfm_scripts.release_artifacts verify "${BUNDLE_DIR}" \ --version "${RELEASE_VERSION}" \ --tag "${RELEASE_TAG}" \ --source-commit "${SOURCE_COMMIT}" - RELEASE_ASSETS_DIR="${RUNNER_TEMP}/final-release-assets" - mkdir -p "${RELEASE_ASSETS_DIR}" - gh release download "${RELEASE_TAG}" \ - --repo "${GITHUB_REPOSITORY}" \ - --dir "${RELEASE_ASSETS_DIR}" - python -m cisco_sccfm_scripts.release_artifacts verify "${RELEASE_ASSETS_DIR}" \ - --version "${RELEASE_VERSION}" \ - --tag "${RELEASE_TAG}" \ - --source-commit "${SOURCE_COMMIT}" - for local_asset in "${BUNDLE_DIR}"/*; do - asset_name="$(basename "${local_asset}")" - cmp -s "${local_asset}" "${RELEASE_ASSETS_DIR}/${asset_name}" || { - echo "::error::GitHub release asset differs from verified bundle: ${asset_name}" - exit 1 - } - done - RELEASE_IDENTITY="$(gh release view "${RELEASE_TAG}" \ --repo "${GITHUB_REPOSITORY}" \ --json isDraft,isPrerelease,tagName \ --jq '.tagName + "\t" + (.isDraft | tostring) + "\t" + (.isPrerelease | tostring)')" - RELEASE_TAG_NAME="${RELEASE_IDENTITY%%$'\t'*}" - RELEASE_FLAGS="${RELEASE_IDENTITY#*$'\t'}" - IS_DRAFT="${RELEASE_FLAGS%%$'\t'*}" - IS_PRERELEASE="${RELEASE_FLAGS#*$'\t'}" - test "${RELEASE_TAG_NAME}" = "${RELEASE_TAG}" - test "${IS_PRERELEASE}" = "false" + case "${RELEASE_IDENTITY}" in + "${RELEASE_TAG}"$'\tfalse\tfalse') + echo "GitHub release ${RELEASE_TAG} is already public." + exit 0 + ;; + "${RELEASE_TAG}"$'\ttrue\tfalse') ;; + *) + echo "::error::release identity changed during deployment" + exit 1 + ;; + esac PUBLIC_RELEASE_TAGS="$(gh api --paginate \ "repos/${GITHUB_REPOSITORY}/releases?per_page=100" \ @@ -1027,8 +543,3 @@ jobs: --draft=false \ --latest=false fi - if [[ "${IS_DRAFT}" = "false" ]]; then - echo "GitHub release ${RELEASE_TAG} is already public." - else - test "${IS_DRAFT}" = "true" - fi diff --git a/RELEASING.md b/RELEASING.md index 55af0a7d..94816160 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,12 +1,18 @@ # Releasing -Releases are deliberate maintainer operations. Merging or pushing to `main` runs CI but does not -bump a version, create a tag, or publish a package. A maintainer starts the GitHub Actions -**Release** workflow manually and supplies the exact version to publish. +Releases have two separate stages: -The workflow publishes the Python package first and the matching Ansible collection second. It -builds the wheel, source distribution, and collection tarball once, then promotes those exact -verified files to GitHub Releases, PyPI, and Ansible Galaxy without rebuilding them. +1. After CI succeeds for a push to `main`, Commitizen inspects the conventional commits since the + last release. When a version bump is required, CI infers the next version, creates the release + commit and `v` tag, builds and verifies the wheel, source distribution, and Ansible + collection once, and stores those files with their SHA-256 manifest in a **draft** GitHub + Release. This stage does not publish to PyPI or Ansible Galaxy. +2. When the draft is ready, a maintainer manually runs the GitHub Actions **Release** workflow for + that existing version. It promotes the exact draft-release assets to PyPI and Ansible Galaxy, + then makes the GitHub Release public. + +Merging does not publish packages. The manual workflow dispatch is the publication gate; it does +not choose or create a new version. ## One-time repository setup @@ -19,49 +25,57 @@ Actions**: - `GALAXY_API_KEY`, owned by an account authorized to publish in the `cisco` namespace. Store credentials only as repository Actions secrets. Do not put them in workflow inputs or -repository files. Protect `main` and release tags, and limit repository write access to maintainers -authorized to release. Repository secrets do not add a separate publication approval step. - -## Before a release - -1. Merge all intended changes and confirm CI passes on the exact `main` commit to release. -2. Confirm the changelogs and documentation describe the intended public release. -3. Prepare the Ansible changelog history. The workflow may retarget the marked `0.38.0` seed for - the first release only; do not move or replace the seed marker afterward. For every later - release, add and review the new version entry in - `sccfm-ansible/changelogs/changelog.yaml` and its matching `v` section in - `sccfm-ansible/CHANGELOG.rst` on `main`, preserving all earlier releases. The YAML entry must - contain non-empty `changes`, a `fragments` list, and a valid `release_date`. The workflow fails - closed instead of converting the previous release entry when the requested version is absent. -4. Choose an unused exact version such as `0.39.0`. Enter it without a leading `v`. -5. Confirm that the version and its `v` tag do not already exist on PyPI, Ansible Galaxy, - or GitHub Releases. -6. Confirm the PyPI account and Galaxy account still have the required namespace permissions. +repository files. Protect `main` and release tags, ensure the release key can perform its narrowly +scoped push, and limit repository write access to maintainers authorized to release. This setup +does not use GitHub environments or per-environment approvals. + +## Before merging a release + +1. Confirm PR CI passes and the intended conventional commit will produce the correct bump. You + can preview Commitizen's inference without changing files: + + ```bash + poetry run cz bump --dry-run --yes --changelog + ``` + +2. Confirm the public documentation and changelogs describe the intended release. +3. Prepare the Ansible changelog for the version Commitizen will infer. For the first public + release only, CI may retarget the checked-in `0.38.0` seed to that inferred version. Do not move + or replace the seed marker afterward. Before every later release, commit the inferred version + as the newest entry in `sccfm-ansible/changelogs/changelog.yaml` and the matching + `v` section in `sccfm-ansible/CHANGELOG.rst`, preserving all published history. The + YAML entry must have non-empty `changes`, a `fragments` list, and a valid `release_date`. +4. Confirm the inferred version and its `v` tag are unused on PyPI, Ansible Galaxy, and + GitHub Releases, and confirm both registry accounts still have publishing permission. Published registry versions are immutable. Never reuse a version for different contents. -## Run the release +## Confirm automatic preparation + +After the release change reaches `main` and CI succeeds, confirm that: + +- Commitizen created the expected version commit and `v` tag; +- the tag identifies a commit contained in `main`; +- a draft GitHub Release exists for the tag; and +- the draft contains one wheel, one source distribution, one collection tarball, and + `release-manifest.json`. + +Do not edit the tag or replace draft-release assets after preparation. + +## Publish the prepared release 1. Open **Actions** in `CiscoDevNet/sccfm-devkit` and select **Release**. -2. Select **Run workflow**, choose the `main` branch, and enter the exact `version`. +2. Select **Run workflow** on `main` and enter the prepared version without the leading `v`. 3. Keep the run open until every job succeeds. -The workflow performs these operations in order: - -1. Validates the requested version and release source, synchronizes version metadata, and runs the - release gates. -2. Builds the wheel, source distribution, and Galaxy tarball once; scans and verifies all three. -3. Creates the release commit and `v` tag, then uploads the three artifacts and their - SHA-256 manifest to a draft GitHub Release. -4. Downloads and re-verifies the draft-release assets, publishes the wheel and source distribution - to PyPI, and verifies the published files. -5. Downloads and re-verifies the same collection tarball, publishes it to Ansible Galaxy, and - waits for Galaxy import validation. -6. Publishes the GitHub Release only after both registries succeed. +The workflow validates the existing tag and draft release, downloads and re-verifies its assets, +publishes the wheel and source distribution to PyPI, publishes the same collection tarball to +Ansible Galaxy, waits for registry validation, and finally makes the GitHub Release public. It +does not rebuild or retag the release. ## Verify the release -The successful run is the authoritative publication record. Confirm that: +The successful deployment run is the authoritative publication record. Confirm that: - the GitHub Release is public and contains the wheel, source distribution, collection tarball, and `release-manifest.json`; @@ -87,18 +101,18 @@ ANSIBLE_COLLECTIONS_PATH="${RELEASE_CHECK_ROOT}/collections" \ ## Failures and retries -- Use **Re-run failed jobs**. Do not use **Re-run all jobs** after any registry publication may - have succeeded. -- If the release commit and tag reached GitHub but the build job lost the push response, re-run - the failed job in the same workflow run. The workflow resumes only when the tag is contained in - `main` and exactly one unexpired artifact bundle from that run matches and verifies against the - tag commit. A new workflow dispatch cannot adopt an older run's artifacts. -- If PyPI succeeds and Galaxy fails, retry only the failed Galaxy path. It downloads and verifies - the preserved Actions artifact from the original workflow run; it must not rebuild it. -- A draft GitHub Release after a failed run is expected. Do not publish it manually while either - registry is incomplete or unverified. -- If a checksum, version, tag, or published-file verification fails, stop and investigate. Do not - replace an artifact, delete a registry release, or bypass a verification gate. -- Before starting a new workflow run after a failure, inspect the tag, draft release, PyPI, and - Galaxy state. If any registry accepted the version, continue only by promoting the existing - manifest-bound artifacts. +- If automatic preparation fails after pushing the release commit and tag, use **Re-run failed + jobs** on that same CI run. The retry accepts only the matching manifest-bound bundle already + produced by that run and completes the draft Release without rebuilding it. +- A failed deployment leaves the GitHub Release as a draft. Do not publish it manually while + either registry is incomplete or unverified. +- Re-run the failed deployment jobs or dispatch **Release** again for the same prepared version. + Every attempt downloads and verifies the manifest-bound draft-release assets; it must not + rebuild them. +- If PyPI succeeded and Galaxy failed, retry the same version. The workflow must verify the files + already on PyPI against the manifest before continuing to Galaxy; it must not upload different + contents under that version. +- If a checksum, version, tag, draft asset, or published-file verification fails, stop and + investigate. Do not replace an asset, move a tag, delete a registry release, or bypass a gate. +- Before retrying, inspect the tag, draft release, PyPI, and Galaxy. If either registry accepted + the version, continue only by promoting the existing draft-release assets. diff --git a/tests/test_prepare_ansible_release.py b/tests/test_prepare_ansible_release.py index e1de352c..2185fb22 100644 --- a/tests/test_prepare_ansible_release.py +++ b/tests/test_prepare_ansible_release.py @@ -106,7 +106,7 @@ def test_retargets_only_the_initial_release_metadata(tmp_path: Path) -> None: assert _SUMMARY in rst -def test_checked_in_changelog_can_be_prepared_once(tmp_path: Path) -> None: +def test_checked_in_changelog_supports_initial_and_later_releases(tmp_path: Path) -> None: repository = Path(__file__).resolve().parents[1] source = repository / "sccfm-ansible" root = tmp_path / "sccfm-ansible" @@ -114,18 +114,40 @@ def test_checked_in_changelog_can_be_prepared_once(tmp_path: Path) -> None: shutil.copy2(source / "changelogs" / "changelog.yaml", root / "changelogs") shutil.copy2(source / "CHANGELOG.rst", root) + checked_in_versions = set( + yaml.safe_load((root / "changelogs" / "changelog.yaml").read_text())["releases"] + ) + versions = sorted( + checked_in_versions, + key=lambda version: tuple(int(part) for part in version.split(".")), + ) + is_unprepared_seed = versions == [_INITIAL_VERSION] + if is_unprepared_seed: + previous_version = _INITIAL_VERSION + release_version = _RELEASE_VERSION + release_date = _RELEASE_DATE + expected_versions = {_RELEASE_VERSION} + else: + previous_version = versions[-2] if len(versions) > 1 else _INITIAL_VERSION + release_version = versions[-1] + checked_in_date = _parsed_release(root, release_version)["release_date"] + assert isinstance(checked_in_date, str) + release_date = checked_in_date + expected_versions = checked_in_versions + result = prepare_ansible_release( root, - _INITIAL_VERSION, - _RELEASE_VERSION, - _RELEASE_DATE, + previous_version, + release_version, + release_date, ) - assert result.changed - assert set( - yaml.safe_load((root / "changelogs" / "changelog.yaml").read_text())["releases"] - ) == {_RELEASE_VERSION} - assert f"v{_RELEASE_VERSION}" in (root / "CHANGELOG.rst").read_text(encoding="utf-8") + assert result.changed is is_unprepared_seed + assert ( + set(yaml.safe_load((root / "changelogs" / "changelog.yaml").read_text())["releases"]) + == expected_versions + ) + assert f"v{release_version}" in (root / "CHANGELOG.rst").read_text(encoding="utf-8") def test_preserves_a_fragment_not_named_after_the_previous_version(tmp_path: Path) -> None: diff --git a/tests/test_release_artifacts.py b/tests/test_release_artifacts.py index b956ffb6..f81e8c18 100644 --- a/tests/test_release_artifacts.py +++ b/tests/test_release_artifacts.py @@ -49,6 +49,16 @@ def _manifest(bundle: Path) -> dict[str, Any]: return value +def _workflow_job(source: str, name: str) -> str: + match = re.search( + rf"^ {re.escape(name)}:\n.*?(?=^ [a-z0-9-]+:\n|\Z)", + source, + re.MULTILINE | re.DOTALL, + ) + assert match is not None, f"workflow job {name!r} is missing" + return match.group(0) + + def test_create_and_verify_release_bundle(tmp_path: Path) -> None: bundle = _bundle(tmp_path) @@ -177,68 +187,86 @@ def test_verify_rejects_symlinked_artifact(tmp_path: Path) -> None: verify_release_bundle(bundle, _VERSION, _TAG, _COMMIT) -def test_workflows_promote_release_assets_without_rebuilding() -> None: +def test_workflows_separate_automatic_preparation_from_manual_deployment() -> None: repository = Path(__file__).resolve().parents[1] ci = (repository / ".github/workflows/ci.yml").read_text(encoding="utf-8") release = (repository / ".github/workflows/release.yml").read_text(encoding="utf-8") - assert " release:\n" not in ci + prepare = _workflow_job(ci, "prepare-release") + draft = _workflow_job(ci, "create-draft-release") + validation = _workflow_job(release, "validate-release") + pypi = _workflow_job(release, "publish-to-pypi") + galaxy = _workflow_job(release, "publish-to-galaxy") + finalizer = _workflow_job(release, "publish-github-release") + + assert "needs: lint-and-test" in prepare + assert "github.event_name == 'push'" in prepare + assert "github.ref == 'refs/heads/main'" in prepare + assert "production-release" in ci + assert "cancel-in-progress: false" in ci assert " publish-to-pypi:\n" not in ci assert " publish-to-galaxy:\n" not in ci - assert '"${COLLECTION_ROOT}/build.sh"' in ci + assert "pypa/gh-action-pypi-publish" not in ci + assert "ansible-galaxy collection publish" not in ci + assert "secrets.PYPI_API_TOKEN" not in ci + assert "secrets.GALAXY_API_KEY" not in ci + assert "secrets.SCCFM_CI_DEPLOY_KEY" in prepare + assert "contents: read" in prepare + assert "contents: write" not in prepare + + assert prepare.count("poetry build") == 1 + assert prepare.count("poetry run build-ansible-collection") == 1 + assert "release_artifacts create" in prepare + assert 'git tag -a "${{ steps.version.outputs.tag }}"' in prepare + assert "release-manifest-sha256:" in prepare + assert "release-manifest.json" in prepare + assert "python -m zipfile -e" in prepare + assert prepare.count("pip-audit \\") == 1 + assert prepare.count("verify_python_distribution \\") == 2 + assert 'steps.artifacts.outputs.wheel_path }}" wheel' in prepare + assert 'steps.artifacts.outputs.sdist_path }}" sdist' in prepare + assert "git push --atomic" in prepare + assert "actions/upload-artifact" in prepare + + assert "needs: prepare-release" in draft + assert "actions/download-artifact" in draft + assert "gh release create" in draft + assert "--verify-tag" in draft + assert "--draft" in draft + assert "release_artifacts verify" in draft + assert "workflow_dispatch:" in release assert "release:\n types:" not in release assert release.count("${{ inputs.version }}") == 1 - assert "RELEASE_VERSION: ${{ inputs.version }}" in release - assert "DEP002_EXCEPTION_EXPIRES" not in ci - assert "DEP002_EXCEPTION_EXPIRES" not in release - assert "exceptions expired" not in ci - assert "exceptions expired" not in release - assert ci.count("--ignore-vuln PYSEC-2026-") == 6 - assert release.count("--ignore-vuln PYSEC-2026-") == 6 - - build = release.split(" build-release:\n", maxsplit=1)[1].split( - " create-draft-release:\n", maxsplit=1 - )[0] - draft = release.split(" create-draft-release:\n", maxsplit=1)[1].split( - " publish-to-pypi:\n", maxsplit=1 - )[0] - pypi = release.split(" publish-to-pypi:\n", maxsplit=1)[1].split( - " publish-to-galaxy:\n", maxsplit=1 - )[0] - galaxy = release.split(" publish-to-galaxy:\n", maxsplit=1)[1].split( - " publish-github-release:\n", maxsplit=1 - )[0] - finalizer = release.split(" publish-github-release:\n", maxsplit=1)[1] - - assert build.count("poetry build") == 1 - assert build.count("poetry run build-ansible-collection") == 1 - assert "release_artifacts create" in build - assert "release-manifest.json" in build - assert "python -m zipfile -e" in build - assert build.count("pip-audit \\") == 1 - assert build.count("verify_python_distribution \\") == 2 - assert 'steps.artifacts.outputs.wheel_path }}" wheel' in build - assert 'steps.artifacts.outputs.sdist_path }}" sdist' in build - assert "git push --atomic" in build + assert "REQUESTED_RELEASE: ${{ inputs.version }}" in validation + assert "production-release" in release + assert "cancel-in-progress: false" in release + assert 'test "${GITHUB_REPOSITORY}" = "CiscoDevNet/sccfm-devkit"' in validation + assert 'test "${GITHUB_REF}" = "refs/heads/main"' in validation + assert "must identify an existing stable GitHub release" in validation + assert 'git rev-parse "refs/tags/${RELEASE_TAG}^{commit}"' in validation + assert "release-manifest-sha256:" in validation + assert "release_artifacts verify" in validation + + prohibited_deploy_commands = ( + "poetry build", + "python -m build", + "build-ansible-collection", + "cz bump", + "git commit ", + "git tag ", + "git push ", + "actions/upload-artifact", + "actions/download-artifact", + "SCCFM_CI_DEPLOY_KEY", + ) + for command in prohibited_deploy_commands: + assert command not in release - assert "gh release create" in draft - assert "--draft" in draft - assert "--json isDraft,isPrerelease,tagName" in draft - assert "\"${RELEASE_TAG}\"$'\\ttrue\\tfalse'" in draft - assert "\"${RELEASE_TAG}\"$'\\tfalse\\tfalse'" in draft - assert "RELEASE_IS_DRAFT=false" in draft - assert "public release is missing immutable asset" in draft - assert "release_artifacts verify" in draft - for publisher in (pypi, galaxy): - assert "actions/download-artifact" in publisher - assert "release_artifacts verify" in publisher - assert "python -m build" not in publisher - assert "poetry build" not in publisher - assert "build-ansible-collection" not in publisher + for job in (validation, pypi, galaxy, finalizer): + assert 'gh release download "${RELEASE_TAG}"' in job + assert "release_artifacts verify" in job - assert "\n environment:" not in release - assert "secrets.SCCFM_CI_DEPLOY_KEY" in build assert "pypa/gh-action-pypi-publish" in pypi assert "secrets.PYPI_API_TOKEN" in pypi assert "skip-existing:" not in pypi @@ -250,53 +278,47 @@ def test_workflows_promote_release_assets_without_rebuilding() -> None: 'cp "${WHEEL_PATH}" "${SDIST_PATH}" dist/' not in pypi.split("3)\n MISSING_FILES=", maxsplit=1)[1] ) + assert "- publish-to-pypi" in galaxy assert "secrets.GALAXY_API_KEY" in galaxy assert "ansible-galaxy collection publish" in galaxy assert "--import-timeout 600" in galaxy - assert "LOOKUP_ATTEMPTS=121" in galaxy - assert "GITHUB_RUN_ATTEMPT" in galaxy assert "--no-wait" not in galaxy assert "- publish-to-galaxy" in finalizer - assert "actions: read" in finalizer - assert "actions/checkout" in finalizer - assert "actions/download-artifact" in finalizer - assert 'test "$(git rev-parse HEAD)" = "${SOURCE_COMMIT}"' in finalizer - assert finalizer.count("release_artifacts verify") == 2 - assert 'gh release download "${RELEASE_TAG}"' in finalizer - assert 'cmp -s "${local_asset}" "${RELEASE_ASSETS_DIR}/${asset_name}"' in finalizer - assert "--json isDraft,isPrerelease,tagName" in finalizer - assert 'test "${RELEASE_TAG_NAME}" = "${RELEASE_TAG}"' in finalizer - assert 'test "${IS_PRERELEASE}" = "false"' in finalizer - assert "select(.draft == false and .prerelease == false) | .tag_name" in finalizer - assert "any(version > current for version in public_versions)" in finalizer - assert '[[ "${MAKE_LATEST}" = "true" ]]' in finalizer assert "--draft=false" in finalizer assert "--latest=false" in finalizer - assert finalizer.index("--latest=false") < finalizer.index('[[ "${IS_DRAFT}" = "false" ]]') + assert "DEP002_EXCEPTION_EXPIRES" not in ci + assert "DEP002_EXCEPTION_EXPIRES" not in release + assert "exceptions expired" not in ci + assert "exceptions expired" not in release + assert ci.count("--ignore-vuln PYSEC-2026-") == 6 + assert "--ignore-vuln PYSEC-2026-" not in release + assert "\n environment:" not in release + assert "\n environment:" not in ci -def test_release_workflow_refreshes_metadata_after_files_only_bump() -> None: +def test_ci_refreshes_metadata_after_inferred_files_only_bump() -> None: repository = Path(__file__).resolve().parents[1] - release = (repository / ".github/workflows/release.yml").read_text(encoding="utf-8") - synchronization = release.split( - " - name: Synchronize exact release version\n", maxsplit=1 - )[1].split(" - name: Build release artifacts once\n", maxsplit=1)[0] + ci = (repository / ".github/workflows/ci.yml").read_text(encoding="utf-8") + synchronization = ci.split(" - name: Infer and synchronize release version\n", maxsplit=1)[ + 1 + ].split(" - name: Build release artifacts once\n", maxsplit=1)[0] - bump = synchronization.index('poetry run cz bump "${RELEASE_VERSION}"') + inference = synchronization.index("poetry run cz bump --get-next") + bump = synchronization.index("poetry run cz bump --yes --changelog --files-only") reinstall = synchronization.index("poetry install --only-root --no-interaction") metadata_check = synchronization.index('test "${INSTALLED_VERSION}" = "${RELEASE_VERSION}"') - assert bump < reinstall < metadata_check + assert inference < bump < reinstall < metadata_check assert 'version("cisco-sccfm-devkit")' in synchronization -def test_release_changed_path_validation_reads_tracked_and_untracked_paths() -> None: +def test_ci_release_changed_path_validation_reads_tracked_and_untracked_paths() -> None: repository = Path(__file__).resolve().parents[1] - release = (repository / ".github/workflows/release.yml").read_text(encoding="utf-8") - commit_step = release.split(" - name: Commit and tag verified source\n", maxsplit=1)[ - 1 - ].split(" - name: Create and verify release manifest\n", maxsplit=1)[0] + ci = (repository / ".github/workflows/ci.yml").read_text(encoding="utf-8") + commit_step = ci.split(" - name: Commit verified source\n", maxsplit=1)[1].split( + " - name: Create and verify release manifest\n", maxsplit=1 + )[0] validation = re.compile( r"while IFS= read -r changed_path; do.*?done < <\(\s*\{\s*" @@ -311,48 +333,56 @@ def test_release_changed_path_validation_reads_tracked_and_untracked_paths() -> assert re.search(rf"git add .*?{re.escape(paired_runtime)}", commit_step, re.DOTALL) -def test_release_retry_resumes_only_same_run_manifest_bound_artifacts() -> None: +def test_draft_release_and_registry_retries_are_manifest_bound() -> None: repository = Path(__file__).resolve().parents[1] + ci = (repository / ".github/workflows/ci.yml").read_text(encoding="utf-8") release = (repository / ".github/workflows/release.yml").read_text(encoding="utf-8") - build = release.split(" build-release:\n", maxsplit=1)[1].split( - " create-draft-release:\n", maxsplit=1 - )[0] - validation = build.split(" - name: Validate requested release\n", maxsplit=1)[1].split( - " - name: Synchronize exact release version\n", maxsplit=1 - )[0] - - assert "actions: read" in build - assert '[[ "${GITHUB_RUN_ATTEMPT}" -le 1 ]]' in validation - assert "actions/runs/${GITHUB_RUN_ID}/artifacts" in validation - assert 'gh run download "${GITHUB_RUN_ID}"' in validation - assert "cisco_sccfm_scripts.release_artifacts verify" in validation - assert 'git merge-base --is-ancestor "${SOURCE_COMMIT}" HEAD' in validation - assert "--json isDraft,isPrerelease,tagName" in validation - assert ".isPrerelease == false" in validation - assert "select(.isPrerelease == false) | .tagName" in validation - assert '[[ "${RELEASE_IDENTITY}" != "${RELEASE_TAG}" ]]' in validation - assert "select(.draft == true) | .tag_name" in validation - assert '[[ "${RESUME_RELEASE}" != "true" || "${draft_tag}" != "${RELEASE_TAG}" ]]' in validation - assert "unresolved draft release blocks a new production release" in validation - registry_resume = re.search( - r'200\)\s+if \[\[ "\$\{RESUME_RELEASE\}" != "true" \]\]; then\s+' - r'echo "::error::\$\{registry\} already contains version', - validation, - ) - assert registry_resume is not None - assert "steps.source.outputs.source_commit || steps.version.outputs.source_commit" in build - assert "steps.source.outputs.bundle_name || steps.version.outputs.bundle_name" in build - - -def test_release_push_reconciles_an_accepted_remote_update() -> None: + draft = _workflow_job(ci, "create-draft-release") + prepare = _workflow_job(ci, "prepare-release") + pypi = _workflow_job(release, "publish-to-pypi") + galaxy = _workflow_job(release, "publish-to-galaxy") + + assert "actions: read" in prepare + assert '[[ "${GITHUB_RUN_ATTEMPT}" -gt 1' in prepare + assert 'test "$(git rev-parse "${RECOVERY_SOURCE}^")" = "${GITHUB_SHA}"' in prepare + assert "actions/runs/${GITHUB_RUN_ID}/artifacts" in prepare + assert 'gh run download "${GITHUB_RUN_ID}"' in prepare + assert "release_artifacts verify" in prepare + assert "RECOVERY_TAG_MESSAGE" in prepare + assert "release-manifest-sha256:" in prepare + assert "expected one unexpired manifest-bound bundle" in prepare + assert "steps.source.outputs.source_commit || steps.version.outputs.source_commit" in prepare + assert "steps.source.outputs.bundle_name || steps.version.outputs.bundle_name" in prepare + + assert "gh release view" in draft + assert 'cmp -s "${local_asset}" "${existing_root}/${asset_name}"' in draft + assert "gh release upload" in draft + assert draft.count('gh release download "${RELEASE_TAG}"') == 2 + assert draft.count("release_artifacts verify") == 2 + + assert "verify_pypi_release" in pypi + assert 'case "${PYPI_STATUS}" in' in pypi + assert re.search(r"\n\s+0\)\n\s+echo \"publish=false\"", pypi) is not None + assert re.search(r"\n\s+2\)\n.*?echo \"publish=true\"", pypi, re.DOTALL) is not None + assert re.search(r"\n\s+3\)\n.*?echo \"publish=true\"", pypi, re.DOTALL) is not None + + assert 'case "${HTTP_STATUS}" in' in galaxy + assert "200)" in galaxy + assert "jq -er '.artifact.sha256'" in galaxy + assert 'echo "publish=false"' in galaxy + assert '404) echo "publish=true"' in galaxy + + +def test_ci_release_push_reconciles_an_accepted_remote_update() -> None: repository = Path(__file__).resolve().parents[1] - release = (repository / ".github/workflows/release.yml").read_text(encoding="utf-8") - push = release.split(" - name: Push release commit and tag atomically\n", maxsplit=1)[ - 1 - ].split("\n create-draft-release:\n", maxsplit=1)[0] + ci = (repository / ".github/workflows/ci.yml").read_text(encoding="utf-8") + push = ci.split(" - name: Push release commit and tag atomically\n", maxsplit=1)[1].split( + "\n create-draft-release:\n", maxsplit=1 + )[0] assert "if git push --atomic origin" in push - assert "git ls-remote --refs origin" in push - assert '[[ "${REMOTE_TAG_COMMIT}" = "${SOURCE_COMMIT}" ]]' in push - assert 'git merge-base --is-ancestor "${SOURCE_COMMIT}" FETCH_HEAD' in push + assert "refs/heads/main:refs/remotes/origin/main" in push + assert '[[ "$(git rev-parse "refs/tags/${RELEASE_TAG}^{commit}")"' in push + assert "git merge-base --is-ancestor \\" in push + assert '"${SOURCE_COMMIT}" refs/remotes/origin/main' in push assert "the atomic remote update was verified" in push