diff --git a/.cz.yaml b/.cz.yaml index 701740a8..4896b455 100644 --- a/.cz.yaml +++ b/.cz.yaml @@ -1,5 +1,7 @@ commitizen: name: cz_conventional_commits - version_provider: poetry + 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/ci.yml b/.github/workflows/ci.yml index 082c3cf0..f3f5a9fe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,12 +11,30 @@ 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 + # 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: lint-and-test: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v7 + with: + persist-credentials: false - name: Set up Python uses: actions/setup-python@v7 @@ -33,31 +51,121 @@ jobs: - name: Install dependencies run: poetry install --no-interaction --with dev + - name: Lint GitHub Actions workflows + env: + SHELLCHECK_OPTS: --severity=warning + run: | + set -euo pipefail + pipx install shellcheck-py==0.11.0.1 + ACTIONLINT_BIN_DIR="${RUNNER_TEMP}/actionlint-bin" + mkdir -p "${ACTIONLINT_BIN_DIR}" + GOBIN="${ACTIONLINT_BIN_DIR}" \ + go install github.com/rhysd/actionlint/cmd/actionlint@v1.7.12 + "${ACTIONLINT_BIN_DIR}/actionlint" \ + -shellcheck "$(command -v shellcheck)" + + - name: Audit locked runtime dependencies + run: | + set -euo pipefail + 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 - name: Lint run: | + poetry check --strict --lock 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/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 run: poetry run pytest --color=yes - release: + - 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}/build.sh" \ + "${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 candidates + 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}" + 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 \ + "${WHEEL_PATH}" "${COLLECTION_PATH}" --expected-version "${PACKAGE_VERSION}" + + prepare-release: needs: lint-and-test - if: github.ref == 'refs/heads/main' + if: >- + github.event_name == 'pull_request' || + (github.event_name == 'push' && github.ref == 'refs/heads/main') + name: ${{ github.event_name == 'pull_request' && 'Rehearse release preparation' || 'Prepare release' }} runs-on: ubuntu-latest - environment: release-bot permissions: - contents: write + 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 + - name: Checkout main uses: actions/checkout@v7 with: fetch-depth: 0 - ssh-key: ${{ secrets.SCCFM_CI_DEPLOY_KEY }} + persist-credentials: false - name: Set up Python uses: actions/setup-python@v7 @@ -70,52 +178,574 @@ jobs: 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 - - name: Bump version and build collection - id: bump + - 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 "$(git rev-parse HEAD)" = "${GITHUB_SHA}" + test -z "$(git status --porcelain)" + + REHEARSAL=false + if [[ "${GITHUB_EVENT_NAME}" = "pull_request" ]]; then + REHEARSAL=true + git config user.name "github-actions" + git config user.email "github-actions@users.noreply.cisco.com" + git commit --allow-empty -m "fix: rehearse release preparation" + else + test "${GITHUB_REF}" = "refs/heads/main" + fi + + PREVIOUS_VERSION="$(poetry version -s)" + REMOTE_MAIN="${GITHUB_SHA}" + if [[ "${REHEARSAL}" != "true" ]]; then + REMOTE_MAIN="$(git rev-parse origin/main)" + fi + RECOVERY_SOURCE="" + RECOVERY_VERSION="" + if [[ "${GITHUB_RUN_ATTEMPT}" -gt 1 \ + && "${REHEARSAL}" != "true" \ + && "${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/zricethezav/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)" - # Check if version bump is needed - cz bump --dry-run --yes --changelog || exit 0 + 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" - # 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}" + - 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" - # Build Ansible collection with updated version - poetry run build-ansible-collection + - name: Preserve exact release bundle + if: >- + github.event_name == 'push' && + 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: >- + github.event_name == 'push' && + steps.version.outputs.bumped == 'true' && + steps.version.outputs.resume != 'true' + env: + RELEASE_TAG: ${{ steps.version.outputs.tag }} + SOURCE_COMMIT: ${{ steps.source.outputs.source_commit }} + SCCFM_CI_DEPLOY_KEY: ${{ secrets.SCCFM_CI_DEPLOY_KEY }} + run: | + set -euo pipefail + SSH_ROOT="$(mktemp -d "${RUNNER_TEMP}/sccfm-release-ssh.XXXXXX")" + cleanup_ssh() { + rm -rf "${SSH_ROOT}" + } + trap cleanup_ssh EXIT + umask 077 + DEPLOY_KEY_PATH="${SSH_ROOT}/deploy-key" + KNOWN_HOSTS_PATH="${SSH_ROOT}/known-hosts" + test -n "${SCCFM_CI_DEPLOY_KEY}" + printf '%s\n' "${SCCFM_CI_DEPLOY_KEY}" > "${DEPLOY_KEY_PATH}" + chmod 600 "${DEPLOY_KEY_PATH}" + unset SCCFM_CI_DEPLOY_KEY + curl --fail --silent --show-error --location \ + https://api.github.com/meta \ + | jq -er '.ssh_keys[] | "github.com " + .' > "${KNOWN_HOSTS_PATH}" + test -s "${KNOWN_HOSTS_PATH}" + GIT_SSH_COMMAND="ssh -i ${DEPLOY_KEY_PATH} -o IdentitiesOnly=yes -o UserKnownHostsFile=${KNOWN_HOSTS_PATH} -o StrictHostKeyChecking=yes" + PUSH_REMOTE="git@github.com:${GITHUB_REPOSITORY}.git" - # Now commit everything together - git add . - git commit -m "bump: version ${NEW_VERSION}" -m "[skip ci]" - git tag "${NEW_TAG}" + if GIT_SSH_COMMAND="${GIT_SSH_COMMAND}" git push --atomic "${PUSH_REMOTE}" \ + HEAD:refs/heads/main \ + "refs/tags/${RELEASE_TAG}:refs/tags/${RELEASE_TAG}"; then + exit 0 + fi - echo "bumped=true" >> "$GITHUB_OUTPUT" - echo "new_tag=${NEW_TAG}" >> "$GITHUB_OUTPUT" + 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 - - name: Push changes and tags - if: steps.bump.outputs.bumped == 'true' + - name: Complete credential-free release rehearsal + if: >- + github.event_name == 'pull_request' && + steps.version.outputs.bumped == 'true' && + steps.version.outputs.resume != 'true' env: - BRANCH: ${{ github.ref_name }} + RELEASE_TAG: ${{ steps.version.outputs.tag }} + SOURCE_COMMIT: ${{ steps.source.outputs.source_commit }} run: | - git push origin HEAD:${BRANCH} - git push origin --tags + set -euo pipefail + test "$(git rev-parse HEAD)" = "${SOURCE_COMMIT}" + test "$(git rev-parse "refs/tags/${RELEASE_TAG}^{commit}")" = "${SOURCE_COMMIT}" + test -f dist/release-manifest.json + test "$(git rev-parse origin/main)" != "${SOURCE_COMMIT}" + echo "Release preparation rehearsal completed without uploading or pushing." + + create-draft-release: + needs: prepare-release + if: >- + github.event_name == 'push' && + 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 }} + persist-credentials: false - - name: Build wheel - run: poetry build -f wheel + - name: Set up Python + uses: actions/setup-python@v7 + with: + python-version: "3.12" - - name: Create release - if: steps.bump.outputs.bumped == 'true' - uses: ncipollo/release-action@v1 + - name: Download exact release bundle + uses: actions/download-artifact@v4 with: - tag: ${{ steps.bump.outputs.new_tag }} - artifacts: "dist/*.whl,dist/*.tar.gz" - token: ${{ secrets.GITHUB_TOKEN }} + 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/generated-docs.yml b/.github/workflows/generated-docs.yml index 748526ec..2e7506b0 100644 --- a/.github/workflows/generated-docs.yml +++ b/.github/workflows/generated-docs.yml @@ -9,7 +9,7 @@ on: workflow_dispatch: permissions: - contents: write + contents: read jobs: commit-generated-docs: @@ -21,14 +21,13 @@ jobs: github.event.workflow_run.head_branch == 'main' ) runs-on: ubuntu-latest - environment: release-bot steps: - name: Checkout main uses: actions/checkout@v7 with: ref: main fetch-depth: 0 - ssh-key: ${{ secrets.SCCFM_CI_DEPLOY_KEY }} + persist-credentials: false - name: Refresh branch run: git pull --ff-only origin main @@ -61,7 +60,9 @@ jobs: poetry run check-doc-artifacts - name: Commit generated docs + id: docs run: | + echo "changed=false" >> "$GITHUB_OUTPUT" if [ -z "$(git status --porcelain docs/cli docs/man docs/ansible)" ]; then echo "Generated docs are up to date." exit 0 @@ -71,4 +72,30 @@ jobs: git config user.email "github-actions@users.noreply.cisco.com" git add docs/cli docs/man docs/ansible git commit -m "docs: update generated references" - git push origin HEAD:main + echo "changed=true" >> "$GITHUB_OUTPUT" + + - name: Push generated docs + if: steps.docs.outputs.changed == 'true' + env: + SCCFM_CI_DEPLOY_KEY: ${{ secrets.SCCFM_CI_DEPLOY_KEY }} + run: | + set -euo pipefail + SSH_ROOT="$(mktemp -d "${RUNNER_TEMP}/sccfm-docs-ssh.XXXXXX")" + cleanup_ssh() { + rm -rf "${SSH_ROOT}" + } + trap cleanup_ssh EXIT + umask 077 + DEPLOY_KEY_PATH="${SSH_ROOT}/deploy-key" + KNOWN_HOSTS_PATH="${SSH_ROOT}/known-hosts" + test -n "${SCCFM_CI_DEPLOY_KEY}" + printf '%s\n' "${SCCFM_CI_DEPLOY_KEY}" > "${DEPLOY_KEY_PATH}" + chmod 600 "${DEPLOY_KEY_PATH}" + unset SCCFM_CI_DEPLOY_KEY + curl --fail --silent --show-error --location \ + https://api.github.com/meta \ + | jq -er '.ssh_keys[] | "github.com " + .' > "${KNOWN_HOSTS_PATH}" + test -s "${KNOWN_HOSTS_PATH}" + GIT_SSH_COMMAND="ssh -i ${DEPLOY_KEY_PATH} -o IdentitiesOnly=yes -o UserKnownHostsFile=${KNOWN_HOSTS_PATH} -o StrictHostKeyChecking=yes" + PUSH_REMOTE="git@github.com:${GITHUB_REPOSITORY}.git" + GIT_SSH_COMMAND="${GIT_SSH_COMMAND}" git push "${PUSH_REMOTE}" HEAD:main diff --git a/.github/workflows/publish-to-pypi.yml b/.github/workflows/publish-to-pypi.yml deleted file mode 100644 index 268d8116..00000000 --- a/.github/workflows/publish-to-pypi.yml +++ /dev/null @@ -1,58 +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 - run: | - 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["tool"]["poetry"]["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" - - - name: Install build dependencies - run: | - python -m pip install --upgrade pip - python -m pip install build - - - name: Build package - run: python -m build - - - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 - with: - password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..42a7a8ca --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,550 @@ +name: Release + +on: + workflow_dispatch: + inputs: + version: + description: "Existing stable version or tag to deploy (X.Y.Z or vX.Y.Z)" + required: true + type: string + +permissions: + contents: read + +concurrency: + group: production-release + cancel-in-progress: false + +jobs: + validate-release: + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + 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: Validate selected draft release + id: release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REQUESTED_RELEASE: ${{ inputs.version }} + run: | + set -euo pipefail + test "${GITHUB_REPOSITORY}" = "CiscoDevNet/sccfm-devkit" + test "${GITHUB_REF}" = "refs/heads/main" + + 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::release must be an existing stable X.Y.Z version or vX.Y.Z tag" + exit 1 + fi + RELEASE_TAG="v${RELEASE_VERSION}" + 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 + + 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 + ;; + esac + + echo "version=${RELEASE_VERSION}" >> "$GITHUB_OUTPUT" + echo "tag=${RELEASE_TAG}" >> "$GITHUB_OUTPUT" + echo "is_draft=${RELEASE_IS_DRAFT}" >> "$GITHUB_OUTPUT" + + - name: Checkout selected release tag + uses: actions/checkout@v7 + with: + ref: ${{ steps.release.outputs.tag }} + fetch-depth: 0 + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@v7 + with: + python-version: "3.12" + + - name: Download exact draft release bundle + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_TAG: ${{ steps.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 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: 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.validate-release.outputs.tag }} + persist-credentials: false + + - 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 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.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}" + + 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 + PYPI_VERIFICATION="$(python -m cisco_sccfm_scripts.verify_pypi_release \ + "${BUNDLE_DIR}" \ + --version "${RELEASE_VERSION}" \ + --tag "${RELEASE_TAG}" \ + --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" + 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=}" + 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" + echo "already_published=false" >> "$GITHUB_OUTPUT" + ;; + *) exit "${PYPI_STATUS}" ;; + esac + echo "packages_dir=dist/" >> "$GITHUB_OUTPUT" + + - name: Publish exact Python artifacts + if: steps.pypi.outputs.publish == 'true' + # Pin the reviewed release/v1 revision because this step receives the PyPI token. + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 + with: + password: ${{ secrets.PYPI_API_TOKEN }} + packages-dir: ${{ steps.pypi.outputs.packages_dir }} + + - name: Verify published PyPI release + env: + 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 + 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: + - validate-release + - publish-to-pypi + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout verified release tag + uses: actions/checkout@v7 + with: + ref: ${{ needs.validate-release.outputs.tag }} + persist-credentials: false + + - 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 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.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}" \ + --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 ]] \ + || [[ "${PYPI_WAS_ALREADY_PUBLISHED}" = "true" ]]; then + LOOKUP_ATTEMPTS=121 + 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.validate-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: + - validate-release + - publish-to-galaxy + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout verified release tag + uses: actions/checkout@v7 + with: + ref: ${{ needs.validate-release.outputs.tag }} + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@v7 + with: + python-version: "3.12" + + - 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.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_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}"$'\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" \ + --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 + gh release edit "${RELEASE_TAG}" \ + --repo "${GITHUB_REPOSITORY}" \ + --draft=false \ + --latest=false + fi diff --git a/.gitignore b/.gitignore index 0ce8e8cb..752e137c 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,9 @@ results/ /.vscode/ /.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/AGENTS.md b/AGENTS.md index 94c4e529..c86bc5d6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -97,9 +97,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 + # Configure or select profiles interactively sccfm-cli-interactive diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index de935827..855a13a7 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 @@ -51,10 +51,14 @@ reserve breaking changes until the next major version release. ``` 3. Set up your SCCFM profile: + ```bash sccfm-cli configure --region us # securely prompts for the token ``` + The default profile store uses owner-only POSIX permissions. On Windows, keep it under your + user profile and rely on the filesystem's per-user access controls. + Now whenever you `cd` into the project, the virtualenv activates automatically. ## Committing Changes @@ -67,6 +71,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/INSTALL.md b/INSTALL.md index 1e06e970..844df62e 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) @@ -149,26 +148,50 @@ 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: + +```bash +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" +``` + +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: -### Install Ansible Collection ```bash -ansible-galaxy collection install /path/to/cisco-sccfm-{version}.tar.gz +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 ``` ### Verify installation ```bash -python -c "import cisco_sccfm_core; print('Python package installed')" -ansible-galaxy collection list | grep cisco.sccfm +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 ``` -### Try out examples +The Python and collection versions printed above must be identical. + +### Authentication and examples The fastest way to get going is to use the interactive CLI menu: @@ -185,4 +208,4 @@ sccfm-cli configure --region us # securely prompts for the token The profile is shared by `sccfm-cli`, `sccfm-cli-interactive`, and the `cisco.sccfm` Ansible collection. Ansible Vault remains available separately for managed-device passwords and other playbook-specific secrets. -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 [Trying out examples in the Ansible collection README](https://github.com/CiscoDevNet/sccfm-devkit/blob/main/sccfm-ansible/README.md#trying-out-examples) for the full walkthrough, including how to run playbooks. diff --git a/README.md b/README.md index 8445260d..e10ac7a9 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ sccfm-cli-interactive # interactive CLI and developer workflow 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/)) in the canonical profile store at `~/.sccfm-cli/config.json`. On POSIX systems, the directory is restricted to the current user (`0700`) and the file to owner read/write (`0600`). On Windows, the store inherits the user's profile-directory ACLs. Override the path with `--config-path` or `SCCFM_CONFIG`. +- `sccfm-cli configure --region REGION [--config-path PATH]`: Stores the SCCFM region (`int`, `us`, `eu`, `apj`, `au`, `uae`, `in`, or `ci`) and API token in the canonical named-profile store. The token comes from `SCCFM_API_TOKEN` or an interactive hidden prompt. Direct `--api-token` input remains available for compatibility but can expose the token in shell history and process listings. On POSIX systems, the default directory uses mode `0700` and the file uses `0600`; on Windows, the store inherits the user's profile-directory ACLs. - `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,13 @@ sccfm-cli-interactive # interactive CLI and developer workflow 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 +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: ```bash @@ -61,7 +68,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 @@ -92,8 +99,11 @@ The package root exports the supported public service classes and response model - Ansible modules and inventory select the same named SCCFM profile; they do not duplicate its region or API token in environment variables, playbooks, or Ansible Vault. - Keep Ansible Vault for playbook-specific secrets such as managed-device passwords. - Point Ansible at an inventory file that uses the plugin, e.g. `ansible-inventory -i sccfm-ansible/examples/inventory.sccfm.yml --graph`. +- 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. +- 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 @@ -184,4 +194,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/RELEASING.md b/RELEASING.md new file mode 100644 index 00000000..d0ed9bfd --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,118 @@ +# Releasing + +Releases have two separate stages: + +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 + +Configure these GitHub Actions repository secrets under **Settings > Secrets and variables > +Actions**: + +- `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 repository Actions secrets. Do not put them in workflow inputs or +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.39.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. + +## 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** on `main` and enter the prepared version without the leading `v`. +3. Keep the run open until every job succeeds. + +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 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`; +- 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.1 +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 + +- 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/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_cli/commands/base.py b/cisco_sccfm_cli/commands/base.py index 9d812ccc..ba1a2df0 100644 --- a/cisco_sccfm_cli/commands/base.py +++ b/cisco_sccfm_cli/commands/base.py @@ -16,8 +16,9 @@ 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 +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 +29,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 @@ -61,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]: @@ -68,36 +72,110 @@ 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 explicitly marked as sensitive.""" + for parameter in ctx.command.params: + if not isinstance(parameter, click.Option) or not is_sensitive_option(parameter): + 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) + + 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: @@ -208,24 +286,28 @@ 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 = None, ) -> None: + sensitive_values = self._active_sensitive_values(sensitive_values) 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/configure.py b/cisco_sccfm_cli/commands/configure.py index 40580a4b..3bfa19e3 100644 --- a/cisco_sccfm_cli/commands/configure.py +++ b/cisco_sccfm_cli/commands/configure.py @@ -4,20 +4,25 @@ 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 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 class ConfigureCommand(BaseCommand): + _API_TOKEN_ENVVAR: Final[str] = "SCCFM_API_TOKEN" + def __init__( self, console: Console, @@ -43,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, @@ -57,20 +62,29 @@ def build_params(self) -> Sequence[click.Parameter]: group=credential_group, required=True, ), - GroupedOption( - ["--api-token"], - help="API token for the chosen region", - group=credential_group, - required=True, - prompt="API token", - hide_input=True, + 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, + ), ), ] 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) @@ -79,3 +93,31 @@ 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 = self._prompt_sensitive("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/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/list_not_on_version/command.py b/cisco_sccfm_cli/commands/inventory/devices/asa/list_not_on_version/command.py index 0446c858..827cd187 100644 --- a/cisco_sccfm_cli/commands/inventory/devices/asa/list_not_on_version/command.py +++ b/cisco_sccfm_cli/commands/inventory/devices/asa/list_not_on_version/command.py @@ -38,7 +38,10 @@ def build_params(self) -> Sequence[click.Parameter]: click.Option( ["--version"], required=True, - help="Software version to exclude (e.g. '9.20(3)13'). Devices NOT on this version are listed.", + help=( + "Software version to exclude (e.g. '9.20(3)13'). " + "Devices NOT on this version are listed." + ), ), *asa_device_filter_params( include_device_name=True, @@ -161,7 +164,8 @@ def _render_table( if not devices: self.console.print( - f"[green]\u2713[/green] All {matched_device_count} matched device(s) are on version {version}." + f"[green]\u2713[/green] All {matched_device_count} matched device(s) " + f"are on version {version}." ) return @@ -185,5 +189,6 @@ def _render_table( self.console.print(table) self.console.print( - f"\n[bold]{len(devices)} of {matched_device_count} matched device(s) are not on version {version}.[/bold]" + f"\n[bold]{len(devices)} of {matched_device_count} matched device(s) " + f"are not on version {version}.[/bold]" ) 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/shun/show/command.py b/cisco_sccfm_cli/commands/inventory/devices/asa/shun/show/command.py index faf43620..b3a53b74 100644 --- a/cisco_sccfm_cli/commands/inventory/devices/asa/shun/show/command.py +++ b/cisco_sccfm_cli/commands/inventory/devices/asa/shun/show/command.py @@ -29,7 +29,10 @@ def name(self) -> str: @property def help_text(self) -> str: - return "Display active shun entries on ASA devices. Use --statistics for per-interface counters." + return ( + "Display active shun entries on ASA devices. " + "Use --statistics for per-interface counters." + ) def build_params(self) -> Sequence[click.Parameter]: return [ 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..f5bc0901 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,19 @@ ) 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.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 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 +54,13 @@ 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")) + self._validate_token_sources(ctx=ctx, **kwargs) 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 +70,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 +82,12 @@ def handle(self, ctx: click.Context, **kwargs: Any) -> None: must_be_virtual=throughput_level is not None, ) + token = self._resolve_token(ctx=ctx, **kwargs) 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 +95,88 @@ 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_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 = 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: + contents: 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 +196,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 +212,7 @@ def _render_results( uid_to_device=uid_to_device, script=script_text, output_format=format, + sensitive_values=sensitive_values, ) def _validate_virtual_devices( @@ -161,13 +246,37 @@ def build_params(self) -> Sequence[click.Parameter]: asa_check_option(), format_option(), config_path_option(), + 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( - ["--token", "-t"], - type=str, + ["--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="The smart license token for your virtual account, generated on " - "https://software.cisco.com/clc", + 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/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/cli_result_renderer.py b/cisco_sccfm_cli/commands/inventory/devices/cdfmc_managed_ftd/cli_result_renderer.py index 3deed87d..a82c3118 100644 --- a/cisco_sccfm_cli/commands/inventory/devices/cdfmc_managed_ftd/cli_result_renderer.py +++ b/cisco_sccfm_cli/commands/inventory/devices/cdfmc_managed_ftd/cli_result_renderer.py @@ -4,8 +4,6 @@ from __future__ import annotations -from typing import Sequence - from rich.console import Console from rich.table import 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/command.py b/cisco_sccfm_cli/commands/inventory/devices/cdfmc_managed_ftd/onboard/command.py index 8afcda13..c4b34fcf 100644 --- a/cisco_sccfm_cli/commands/inventory/devices/cdfmc_managed_ftd/onboard/command.py +++ b/cisco_sccfm_cli/commands/inventory/devices/cdfmc_managed_ftd/onboard/command.py @@ -67,13 +67,19 @@ def build_params(self) -> Sequence[click.Parameter]: ["--performance-tier"], default=None, type=click.Choice(FTDV_PERFORMANCE_TIERS, case_sensitive=True), - help="Performance tier of the FTDv (required when --virtual is set, e.g., FTDv5, FTDv10, FTDv20).", + help=( + "Performance tier of the FTDv (required when --virtual is set, " + "e.g., FTDv5, FTDv10, FTDv20)." + ), ), click.Option( ["--grouped-labels"], type=str, default=None, - help='Grouped labels in JSON format, e.g., \'{"environment": ["prod", "us-west"]}\'.', + help=( + "Grouped labels in JSON format, e.g., " + '\'{"environment": ["prod", "us-west"]}\'.' + ), ), click.Option( ["--ungrouped-labels"], 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/inventory/options.py b/cisco_sccfm_cli/commands/inventory/options.py index cd69b1a1..2b98535b 100644 --- a/cisco_sccfm_cli/commands/inventory/options.py +++ b/cisco_sccfm_cli/commands/inventory/options.py @@ -4,8 +4,6 @@ from __future__ import annotations -from typing import List - import click from cisco_sccfm_cli.commands.shared_options import ( @@ -13,9 +11,20 @@ format_option, limit_option, offset_option, - timeout_option, - wait_option, ) +from cisco_sccfm_cli.commands.shared_options import timeout_option as timeout_option +from cisco_sccfm_cli.commands.shared_options import wait_option as wait_option + +__all__ = [ + "config_path_option", + "format_option", + "inventory_list_params", + "limit_option", + "offset_option", + "query_option", + "timeout_option", + "wait_option", +] def query_option(help_text: str | None = None) -> click.Option: @@ -33,7 +42,7 @@ def query_option(help_text: str | None = None) -> click.Option: ) -def inventory_list_params() -> List[click.Parameter]: +def inventory_list_params() -> list[click.Parameter]: """Complete set of options for inventory list commands.""" return [ limit_option(), diff --git a/cisco_sccfm_cli/commands/objects/network_group/add_member/command.py b/cisco_sccfm_cli/commands/objects/network_group/add_member/command.py index 0358acb3..884ee321 100644 --- a/cisco_sccfm_cli/commands/objects/network_group/add_member/command.py +++ b/cisco_sccfm_cli/commands/objects/network_group/add_member/command.py @@ -4,7 +4,6 @@ from __future__ import annotations -import json from typing import Any, Sequence, cast import click diff --git a/cisco_sccfm_cli/commands/objects/network_group/remove_member/command.py b/cisco_sccfm_cli/commands/objects/network_group/remove_member/command.py index 8cbef443..e1884cae 100644 --- a/cisco_sccfm_cli/commands/objects/network_group/remove_member/command.py +++ b/cisco_sccfm_cli/commands/objects/network_group/remove_member/command.py @@ -4,7 +4,6 @@ from __future__ import annotations -import json from typing import Any, Sequence, cast import click diff --git a/cisco_sccfm_cli/commands/objects/show/command.py b/cisco_sccfm_cli/commands/objects/show/command.py index 4a379865..ef90be68 100644 --- a/cisco_sccfm_cli/commands/objects/show/command.py +++ b/cisco_sccfm_cli/commands/objects/show/command.py @@ -25,7 +25,10 @@ def name(self) -> str: @property def help_text(self) -> str: - return "Show the full details of an object, including its default value, overrides, and targets." + return ( + "Show the full details of an object, including its default value, " + "overrides, and targets." + ) def build_params(self) -> Sequence[click.Parameter]: return [ diff --git a/cisco_sccfm_cli/commands/objects/utils.py b/cisco_sccfm_cli/commands/objects/utils.py index 6ef8ee62..d4149242 100644 --- a/cisco_sccfm_cli/commands/objects/utils.py +++ b/cisco_sccfm_cli/commands/objects/utils.py @@ -7,7 +7,7 @@ from __future__ import annotations import uuid as uuid_mod -from typing import Any, Callable, Literal, Protocol +from typing import Any, Literal, Protocol import click from rich.console import Console @@ -24,9 +24,17 @@ def name(self) -> str: ... class _NetworkObjectLookup(Protocol): - def get_network_object(self, uid: str) -> _HasUid | None: ... + def get_network_object(self, *, uid: str) -> _HasUid | None: ... - def get_network_object_by_name(self, name: str) -> _HasUid | None: ... + def get_network_object_by_name(self, *, name: str) -> _HasUid | None: ... + + +class _LookupByName(Protocol): + def __call__(self, *, name: str) -> _HasUid | None: ... + + +class _LookupByUid(Protocol): + def __call__(self, *, uid: str) -> _HasUid | None: ... CheckOperation = Literal["create", "update", "delete"] @@ -101,8 +109,8 @@ def check_object_exists( console: Console, uid: str | None, name: str | None, - get_by_uid_fn: Callable[[str], _HasUid | None] | None, - get_by_name_fn: Callable[[str], _HasUid | None], + get_by_uid_fn: _LookupByUid | None, + get_by_name_fn: _LookupByName, object_name: str, output_format: str = "table", operation: CheckOperation = "update", @@ -131,9 +139,9 @@ def check_object_exists( entity: _HasUid | None = None if uid and get_by_uid_fn: - entity = get_by_uid_fn(uid) + entity = get_by_uid_fn(uid=uid) elif name: - entity = get_by_name_fn(name) + entity = get_by_name_fn(name=name) exists = entity is not None found_uid = entity.uid if entity else None @@ -205,9 +213,9 @@ def check_referenced_objects_exist( for ref in referenced_objects: try: uid = str(uuid_mod.UUID(ref)) - entity = obj_service.get_network_object(uid) + entity = obj_service.get_network_object(uid=uid) except ValueError: - entity = obj_service.get_network_object_by_name(ref) + entity = obj_service.get_network_object_by_name(name=ref) exists = entity is not None found_uid = entity.uid if entity else None 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/disk/test_list_files.py b/cisco_sccfm_cli/commands/tests/inventory/devices/asa/disk/test_list_files.py index fdd715e2..51215d66 100644 --- a/cisco_sccfm_cli/commands/tests/inventory/devices/asa/disk/test_list_files.py +++ b/cisco_sccfm_cli/commands/tests/inventory/devices/asa/disk/test_list_files.py @@ -9,7 +9,7 @@ from _pytest.monkeypatch import MonkeyPatch from click.testing import CliRunner -from scc_firewall_manager_sdk import CdoTransaction, Device, DevicePage +from scc_firewall_manager_sdk import Device, DevicePage from cisco_sccfm_cli.cli import cli from cisco_sccfm_cli.models import Config 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/shun/test_add_shun.py b/cisco_sccfm_cli/commands/tests/inventory/devices/asa/shun/test_add_shun.py index 88aa69db..eeebb8b3 100644 --- a/cisco_sccfm_cli/commands/tests/inventory/devices/asa/shun/test_add_shun.py +++ b/cisco_sccfm_cli/commands/tests/inventory/devices/asa/shun/test_add_shun.py @@ -6,8 +6,7 @@ from __future__ import annotations -import json -from typing import Any, List +from typing import Any from _pytest.monkeypatch import MonkeyPatch from click.testing import CliRunner @@ -43,8 +42,8 @@ def fake_get_devices( def fake_add_shun_entries( self: AsaShunService, - device_uids: List[str], - entries: List[ShunEntrySpec], + device_uids: list[str], + entries: list[ShunEntrySpec], *, wait: bool = True, ) -> list[CdoCliResult]: diff --git a/cisco_sccfm_cli/commands/tests/inventory/devices/asa/shun/test_remove_shun.py b/cisco_sccfm_cli/commands/tests/inventory/devices/asa/shun/test_remove_shun.py index 06c8a807..d224ef56 100644 --- a/cisco_sccfm_cli/commands/tests/inventory/devices/asa/shun/test_remove_shun.py +++ b/cisco_sccfm_cli/commands/tests/inventory/devices/asa/shun/test_remove_shun.py @@ -6,8 +6,7 @@ from __future__ import annotations -import json -from typing import Any, List +from typing import Any from _pytest.monkeypatch import MonkeyPatch from click.testing import CliRunner @@ -43,8 +42,8 @@ def fake_get_devices( def fake_remove_shun_entries( self: AsaShunService, - device_uids: List[str], - source_ips: List[str], + device_uids: list[str], + source_ips: list[str], *, wait: bool = True, ) -> list[CdoCliResult]: 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..fad3929d --- /dev/null +++ b/cisco_sccfm_cli/commands/tests/inventory/devices/asa/smartlicense/test_token_input.py @@ -0,0 +1,358 @@ +# 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_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, + 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) + + +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", + [ + "", + "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/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/objects/network/test_network_delete.py b/cisco_sccfm_cli/commands/tests/objects/network/test_network_delete.py index d7993c26..795facab 100644 --- a/cisco_sccfm_cli/commands/tests/objects/network/test_network_delete.py +++ b/cisco_sccfm_cli/commands/tests/objects/network/test_network_delete.py @@ -11,7 +11,6 @@ import pytest from _pytest.monkeypatch import MonkeyPatch from click.testing import CliRunner -from scc_firewall_manager_sdk.exceptions import ApiException from cisco_sccfm_cli.cli import cli from cisco_sccfm_cli.models import Config diff --git a/cisco_sccfm_cli/commands/tests/objects/network_group/test_network_group_delete.py b/cisco_sccfm_cli/commands/tests/objects/network_group/test_network_group_delete.py index aa6119e1..899c57d5 100644 --- a/cisco_sccfm_cli/commands/tests/objects/network_group/test_network_group_delete.py +++ b/cisco_sccfm_cli/commands/tests/objects/network_group/test_network_group_delete.py @@ -6,7 +6,6 @@ from typing import Any -import pytest from _pytest.monkeypatch import MonkeyPatch from click.testing import CliRunner diff --git a/cisco_sccfm_cli/commands/tests/objects/network_group/test_network_group_service.py b/cisco_sccfm_cli/commands/tests/objects/network_group/test_network_group_service.py index a5511bfc..5285ca04 100644 --- a/cisco_sccfm_cli/commands/tests/objects/network_group/test_network_group_service.py +++ b/cisco_sccfm_cli/commands/tests/objects/network_group/test_network_group_service.py @@ -300,7 +300,9 @@ def test_resolve_passes_uuids_through(self) -> None: result = service._resolve_referenced_object_uids([self.VALID_UUID]) assert result == [self.VALID_UUID] - service._network_object_service.get_network_object.assert_called_once_with(self.VALID_UUID) + service._network_object_service.get_network_object.assert_called_once_with( + uid=self.VALID_UUID + ) service._network_object_service.get_network_object_by_name.assert_not_called() def test_resolve_looks_up_names(self) -> None: @@ -315,7 +317,7 @@ def test_resolve_looks_up_names(self) -> None: assert result == ["resolved-uid-abc"] service._network_object_service.get_network_object_by_name.assert_called_once_with( - "my-object" + name="my-object" ) def test_resolve_mixed_uids_and_names(self) -> None: @@ -363,7 +365,7 @@ def test_get_network_group_returns_none_for_wrong_type(self) -> None: }, } - result = service.get_network_group("obj-123") + result = service.get_network_group(uid="obj-123") assert result is None diff --git a/cisco_sccfm_cli/commands/tests/objects/network_group/test_network_group_update.py b/cisco_sccfm_cli/commands/tests/objects/network_group/test_network_group_update.py index 2b27d2b4..946861e7 100644 --- a/cisco_sccfm_cli/commands/tests/objects/network_group/test_network_group_update.py +++ b/cisco_sccfm_cli/commands/tests/objects/network_group/test_network_group_update.py @@ -7,7 +7,6 @@ import json from typing import Any -import pytest from _pytest.monkeypatch import MonkeyPatch from click.testing import CliRunner diff --git a/cisco_sccfm_cli/commands/tests/objects/test_utils.py b/cisco_sccfm_cli/commands/tests/objects/test_utils.py new file mode 100644 index 00000000..099ab976 --- /dev/null +++ b/cisco_sccfm_cli/commands/tests/objects/test_utils.py @@ -0,0 +1,58 @@ +# Copyright 2026 Cisco Systems, Inc. and its affiliates +# +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from dataclasses import dataclass + +import pytest +from rich.console import Console + +from cisco_sccfm_cli.commands.objects.utils import check_object_exists + + +@dataclass(frozen=True) +class _Entity: + uid: str + name: str + + +@pytest.mark.parametrize( + ("uid", "name"), + [("object-uid", None), (None, "object-name")], +) +def test_check_object_exists_uses_keyword_only_lookups( + uid: str | None, + name: str | None, +) -> None: + entity = _Entity(uid="object-uid", name="object-name") + + def lookup_by_uid(*, uid: str) -> _Entity | None: + assert uid == "object-uid" + return entity + + def lookup_by_name(*, name: str) -> _Entity | None: + assert name == "object-name" + return entity + + result = check_object_exists( + console=Console(), + uid=uid, + name=name, + get_by_uid_fn=lookup_by_uid, + get_by_name_fn=lookup_by_name, + object_name="Network object", + emit=False, + ) + + assert result == { + "entity_type": "Network object", + "identifier": uid or name, + "operation": "update", + "exists": True, + "can_proceed": True, + "reason": "exists", + "uid": "object-uid", + "name": "object-name", + } diff --git a/cisco_sccfm_cli/commands/tests/test_configure.py b/cisco_sccfm_cli/commands/tests/test_configure.py index 98f57a64..81624fd6 100644 --- a/cisco_sccfm_cli/commands/tests/test_configure.py +++ b/cisco_sccfm_cli/commands/tests/test_configure.py @@ -4,13 +4,26 @@ 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 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" +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: """Configure command should create a new profile with provided credentials.""" @@ -30,12 +43,150 @@ 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) + + +@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, + 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 +219,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 +238,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 @@ -117,8 +268,11 @@ def test_should_normalize_legacy_region_aliases(cli_runner: CliRunner, config_pa def test_should_prompt_for_token_without_echoing_it( - cli_runner: CliRunner, config_path: Path + cli_runner: CliRunner, + config_path: Path, + monkeypatch: MonkeyPatch, ) -> None: + monkeypatch.setattr(ConfigureCommand, "_can_prompt", lambda _command: True) result = cli_runner.invoke( cli, [ @@ -136,3 +290,13 @@ def test_should_prompt_for_token_without_echoing_it( stored = ConfigService(path=config_path).load("default") assert stored is not None assert stored.api_token == "prompted-secret" + + +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 6db96300..e02a2f4c 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: @@ -38,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"]) @@ -54,21 +109,29 @@ 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"] == [ - "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 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" 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( @@ -80,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"] @@ -128,11 +193,51 @@ 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] + 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" @@ -286,6 +391,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"]} @@ -293,11 +413,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 @@ -314,6 +432,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/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/commands/transaction.py b/cisco_sccfm_cli/commands/transaction.py index 507d4362..5062cb61 100644 --- a/cisco_sccfm_cli/commands/transaction.py +++ b/cisco_sccfm_cli/commands/transaction.py @@ -8,7 +8,6 @@ import click from rich.console import Console -from scc_firewall_manager_sdk import CdoTransaction from cisco_sccfm_cli.commands.base import BaseCommand from cisco_sccfm_cli.commands.shared_options import ( 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..71f9d79f --- /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 == "Profile(profile='default', region='us')" 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 2cf9c738..606689ef 100644 --- a/cisco_sccfm_cli/schema.py +++ b/cisco_sccfm_cli/schema.py @@ -16,7 +16,10 @@ 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" _SCCFM_FREE_COMMANDS = { ("configure",), @@ -24,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 = { @@ -244,14 +255,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: @@ -261,15 +268,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 @@ -322,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, @@ -356,6 +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": is_sensitive_option(option), "envvar": _envvar(option.envvar), "metavar": option.metavar, } @@ -386,7 +390,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} @@ -568,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") @@ -624,7 +630,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 +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: + if option is None or is_sensitive_option(option): 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/cisco_sccfm_core/models/profile.py b/cisco_sccfm_core/models/profile.py index 97bb6e01..cc1eb84c 100644 --- a/cisco_sccfm_core/models/profile.py +++ b/cisco_sccfm_core/models/profile.py @@ -4,7 +4,7 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field @dataclass(frozen=True) @@ -13,4 +13,4 @@ class Profile: profile: str region: str - api_token: str + api_token: str = field(repr=False) 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/services/object_management/network_group_service.py b/cisco_sccfm_core/services/object_management/network_group_service.py index cd82bb80..a1e1ea4f 100644 --- a/cisco_sccfm_core/services/object_management/network_group_service.py +++ b/cisco_sccfm_core/services/object_management/network_group_service.py @@ -23,10 +23,18 @@ from cisco_sccfm_core.errors import NotFoundError from cisco_sccfm_core.services.object_management.network_object_service import NetworkObjectService from cisco_sccfm_core.services.object_management.object_api_helper import ObjectApiHelper -from cisco_sccfm_core.services.object_management.utils import build_filtered_query, resolve_uid +from cisco_sccfm_core.services.object_management.utils import ( + build_filtered_query, + resolve_uid, +) from cisco_sccfm_core.types import ConfigLike +def _string_or_empty(value: Any) -> str: + """Normalize missing API values to the response model's empty-string sentinel.""" + return str(value or "") + + @dataclass class NetworkGroupResponse: """Simplified response for a network group. @@ -59,13 +67,13 @@ def from_dict(cls, data: dict[str, Any]) -> NetworkGroupResponse: ] return cls( - uid=str(data.get("uid") or ""), - name=str(data.get("name") or ""), + uid=_string_or_empty(data.get("uid")), + name=_string_or_empty(data.get("name")), description=data.get("description"), elements=list(data.get("elements") or []), labels=list(data.get("labels") or []), tags=dict(data.get("tags") or {}), - object_type=str(value.get("objectType") or ""), + object_type=_string_or_empty(value.get("objectType")), literals=literals, referenced_object_uids=list(raw_refs), ) @@ -135,7 +143,7 @@ def __init__(self, config: ConfigLike) -> None: self._object_api = self._helper.api self._network_object_service = NetworkObjectService(config) - def get_network_group(self, uid: str) -> NetworkGroupResponse | None: + def get_network_group(self, *, uid: str) -> NetworkGroupResponse | None: """Fetch a network group by UID. Returns ``None`` when the UID does not exist **or** when it @@ -185,7 +193,7 @@ def _get_raw_group_data(self, uid: str) -> dict[str, Any]: raise NotFoundError(f"Network group with UID '{uid}' not found.") return self._helper.read_raw_response(response) - def get_network_group_by_name(self, name: str) -> NetworkGroupResponse | None: + def get_network_group_by_name(self, *, name: str) -> NetworkGroupResponse | None: """Search for a network group object by name. Uses an objectType filter so that plain network objects with the @@ -260,8 +268,8 @@ def _resolve_uid(self, *, uid: str | None, name: str | None) -> str: return resolve_uid( uid=uid, name=name, - get_by_name_fn=self.get_network_group_by_name, - get_by_uid_fn=self.get_network_group, + get_by_name_fn=lambda group_name: self.get_network_group_by_name(name=group_name), + get_by_uid_fn=lambda group_uid: self.get_network_group(uid=group_uid), entity_name="Network group", ) @@ -592,12 +600,12 @@ def _resolve_referenced_object_uids(self, referenced_objects: list[str]) -> list resolved: list[str] = [] for ref in referenced_objects: if self._is_uuid(ref): - obj = self._network_object_service.get_network_object(ref) + obj = self._network_object_service.get_network_object(uid=ref) if not obj: raise NotFoundError(f"Network object with UID '{ref}' not found.") resolved.append(obj.uid) else: - obj = self._network_object_service.get_network_object_by_name(ref) + obj = self._network_object_service.get_network_object_by_name(name=ref) if not obj: raise NotFoundError(f"Network object with name '{ref}' not found.") resolved.append(obj.uid) diff --git a/cisco_sccfm_core/services/object_management/network_object_service.py b/cisco_sccfm_core/services/object_management/network_object_service.py index 05e13c57..b0f5c2d1 100644 --- a/cisco_sccfm_core/services/object_management/network_object_service.py +++ b/cisco_sccfm_core/services/object_management/network_object_service.py @@ -13,12 +13,19 @@ from scc_firewall_manager_sdk.models.shared_object_value import SharedObjectValue from scc_firewall_manager_sdk.models.update_request import UpdateRequest -from cisco_sccfm_core.errors import NotFoundError from cisco_sccfm_core.services.object_management.object_api_helper import ObjectApiHelper -from cisco_sccfm_core.services.object_management.utils import build_filtered_query, resolve_uid +from cisco_sccfm_core.services.object_management.utils import ( + build_filtered_query, + resolve_uid, +) from cisco_sccfm_core.types import ConfigLike +def _string_or_empty(value: Any) -> str: + """Normalize missing API values to the response model's empty-string sentinel.""" + return str(value or "") + + @dataclass class NetworkObjectResponse: """Simplified response for network object operations. @@ -41,14 +48,14 @@ def from_dict(cls, data: dict[str, Any]) -> "NetworkObjectResponse": value: dict[str, Any] = data.get("value") or {} default_content: dict[str, Any] = value.get("defaultContent") or {} return cls( - uid=str(data.get("uid") or ""), - name=str(data.get("name") or ""), + uid=_string_or_empty(data.get("uid")), + name=_string_or_empty(data.get("name")), description=data.get("description"), elements=list(data.get("elements") or []), labels=list(data.get("labels") or []), tags=dict(data.get("tags") or {}), - object_type=str(value.get("objectType") or ""), - literal=str(default_content.get("literal") or ""), + object_type=_string_or_empty(value.get("objectType")), + literal=_string_or_empty(default_content.get("literal")), ) def to_dict(self) -> dict[str, Any]: @@ -141,7 +148,7 @@ def create_network_object( data = self._helper.read_raw_response(response) return NetworkObjectResponse.from_dict(data) - def get_network_object(self, uid: str) -> NetworkObjectResponse | None: + def get_network_object(self, *, uid: str) -> NetworkObjectResponse | None: """Fetch a network object by UID. Returns ``None`` when the UID does not exist **or** when it @@ -165,7 +172,7 @@ def get_network_object(self, uid: str) -> NetworkObjectResponse | None: return None return parsed - def get_network_object_by_name(self, name: str) -> NetworkObjectResponse | None: + def get_network_object_by_name(self, *, name: str) -> NetworkObjectResponse | None: """Search for a network object by name. Uses an objectType filter so that network groups with the same @@ -287,8 +294,8 @@ def _resolve_uid(self, *, uid: str | None, name: str | None) -> str: return resolve_uid( uid=uid, name=name, - get_by_name_fn=self.get_network_object_by_name, - get_by_uid_fn=self.get_network_object, + get_by_name_fn=lambda object_name: self.get_network_object_by_name(name=object_name), + get_by_uid_fn=lambda object_uid: self.get_network_object(uid=object_uid), entity_name="Network object", ) diff --git a/cisco_sccfm_core/services/policy/access_rule_service.py b/cisco_sccfm_core/services/policy/access_rule_service.py index 3ed6a6b8..6d3311ba 100644 --- a/cisco_sccfm_core/services/policy/access_rule_service.py +++ b/cisco_sccfm_core/services/policy/access_rule_service.py @@ -298,7 +298,7 @@ def delete_access_rule(self, *, uid: str) -> str: def _resolve_network_object(self, name: str) -> tuple[str, str]: """Resolve a network object name to (uid, name) tuple.""" - obj = self._network_object_service.get_network_object_by_name(name) + obj = self._network_object_service.get_network_object_by_name(name=name) if not obj: raise NotFoundError(f"Network object with name '{name}' not found.") return obj.uid, obj.name diff --git a/cisco_sccfm_core/services/profile_service.py b/cisco_sccfm_core/services/profile_service.py index eba50c6f..0e9ea076 100644 --- a/cisco_sccfm_core/services/profile_service.py +++ b/cisco_sccfm_core/services/profile_service.py @@ -6,9 +6,12 @@ import json import os -import tempfile +import secrets +import stat +import sys +from errno import ELOOP, ENOTDIR from pathlib import Path -from typing import Any, Mapping +from typing import Any, Mapping, TextIO, cast from cisco_sccfm_core.models.profile import Profile @@ -16,18 +19,17 @@ _CONFIG_FILE = _CONFIG_DIR / "config.json" _CONFIG_DIR_MODE = 0o700 _CONFIG_FILE_MODE = 0o600 -_SUPPORTS_POSIX_MODES = os.name == "posix" +_TEMPORARY_FILE_ATTEMPTS = 128 class ProfileService: - """Read and write SCCFM profiles from the canonical local config file.""" - def __init__(self, path: Path | None = None) -> None: configured_path = os.environ.get("SCCFM_CONFIG") - self._path = path or ( + selected_path = path or ( Path(configured_path).expanduser() if configured_path else _CONFIG_FILE ) - self._harden_existing_path() + self._path = self._normalize_macos_path(selected_path) + self._uses_default_path = self._path == self._normalize_macos_path(_CONFIG_FILE) def load(self, profile: str) -> Profile | None: profiles = self._load_profiles() @@ -41,12 +43,13 @@ def load(self, profile: str) -> Profile | None: ) def save(self, config: Profile) -> 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() + if self._supports_posix_permissions(): + self._save_relative(config) + else: + self._save_without_dir_fd(config) def list_profiles(self) -> list[Profile]: profiles = self._load_profiles() @@ -56,57 +59,576 @@ def list_profiles(self) -> list[Profile]: ] def remove(self, profile: str) -> bool: - """Remove *profile*, returning whether it existed.""" - profiles = self._load_profiles() - if profile not in profiles: - return False - del profiles[profile] - self._persist({"profiles": profiles}) - return True + """Remove a named profile and report whether it existed.""" + self._validate_storage_path() + self._ensure_parent_directory() + self._validate_storage_path() + if self._supports_posix_permissions(): + return self._remove_relative(profile) + return self._remove_without_dir_fd(profile) def _load_profiles(self) -> dict[str, dict[str, Any]]: - 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_config_directory() - file_descriptor, temporary_name = tempfile.mkstemp( - dir=self._path.parent, - prefix=f".{self._path.name}.", - suffix=".tmp", + def _read_profiles_for_update(self, handle: TextIO) -> dict[str, dict[str, Any]]: + data = json.load(handle) + return dict(data.get("profiles", {})) + + def _save_relative(self, config: Profile) -> None: + parent_descriptor = self._open_validated_parent_directory() + try: + profiles, original_stat = self._read_profiles_relative(parent_descriptor) + payload = self._updated_payload(profiles, config) + self._write_relative_atomically(parent_descriptor, original_stat, payload) + finally: + os.close(parent_descriptor) + + def _remove_relative(self, profile: str) -> bool: + parent_descriptor = self._open_validated_parent_directory() + try: + profiles, original_stat = self._read_profiles_relative(parent_descriptor) + if profile not in profiles: + return False + del profiles[profile] + self._write_relative_atomically( + parent_descriptor, + original_stat, + {"profiles": profiles}, + ) + return True + finally: + os.close(parent_descriptor) + + def _read_profiles_relative( + self, + parent_descriptor: int, + ) -> tuple[dict[str, dict[str, Any]], os.stat_result | None]: + try: + descriptor = self._open_relative_descriptor( + parent_descriptor, + flags=os.O_RDONLY | self._safe_open_flags(), + ) + except FileNotFoundError: + return {}, None + os.fchmod(descriptor, _CONFIG_FILE_MODE) + original_stat = os.fstat(descriptor) + with cast(TextIO, os.fdopen(descriptor, "r", encoding="utf-8")) as handle: + return self._read_profiles_for_update(handle), original_stat + + def _write_relative_atomically( + self, + parent_descriptor: int, + original_stat: os.stat_result | None, + payload: Mapping[str, Any], + ) -> None: + temporary_name, descriptor = self._create_relative_temporary_file(parent_descriptor) + replace_pending = True + validation_descriptor: int | None = None + try: + with cast(TextIO, os.fdopen(descriptor, "w", encoding="utf-8")) as handle: + self._write_and_sync(handle, payload) + validation_descriptor = self._open_relative_temporary_file( + parent_descriptor, + temporary_name, + ) + self._ensure_destination_unchanged(parent_descriptor, original_stat) + self._ensure_parent_descriptor_matches_path(parent_descriptor) + os.replace( + temporary_name, + self._path.name, + src_dir_fd=parent_descriptor, + dst_dir_fd=parent_descriptor, + ) + replace_pending = False + os.fsync(parent_descriptor) + finally: + try: + if validation_descriptor is not None: + os.close(validation_descriptor) + finally: + if replace_pending: + self._unlink_relative_temporary_file(parent_descriptor, temporary_name) + + def _create_relative_temporary_file(self, parent_descriptor: int) -> tuple[str, int]: + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | self._safe_open_flags() + for _ in range(_TEMPORARY_FILE_ATTEMPTS): + name = self._temporary_file_name() + try: + descriptor = os.open( + name, + flags, + _CONFIG_FILE_MODE, + dir_fd=parent_descriptor, + ) + except FileExistsError: + continue + try: + os.fchmod(descriptor, _CONFIG_FILE_MODE) + self._ensure_temporary_descriptor( + descriptor, + parent_descriptor=parent_descriptor, + name=name, + ) + return name, descriptor + except BaseException: + os.close(descriptor) + self._unlink_relative_temporary_file(parent_descriptor, name) + raise + raise FileExistsError("Unable to create a private temporary configuration file") + + def _open_relative_temporary_file(self, parent_descriptor: int, name: str) -> int: + descriptor = os.open( + name, + os.O_RDONLY | self._safe_open_flags(), + dir_fd=parent_descriptor, ) - temporary_path = Path(temporary_name) - try: - if _SUPPORTS_POSIX_MODES: - os.fchmod(file_descriptor, _CONFIG_FILE_MODE) - with os.fdopen(file_descriptor, "w", encoding="utf-8") as handle: - json.dump(payload, handle, indent=2) - handle.write("\n") - handle.flush() - os.fsync(handle.fileno()) - temporary_path.replace(self._path) - self._set_posix_mode(self._path, _CONFIG_FILE_MODE) - except Exception: - temporary_path.unlink(missing_ok=True) + try: + self._ensure_temporary_descriptor( + descriptor, + parent_descriptor=parent_descriptor, + name=name, + ) + return descriptor + except BaseException: + os.close(descriptor) raise - def _ensure_config_directory(self) -> None: - created = not self._path.parent.exists() - self._path.parent.mkdir(parents=True, mode=_CONFIG_DIR_MODE, exist_ok=True) - if created or self._path.parent == _CONFIG_DIR: - self._set_posix_mode(self._path.parent, _CONFIG_DIR_MODE) + @staticmethod + def _write_and_sync(handle: TextIO, payload: Mapping[str, Any]) -> None: + json.dump(payload, handle, indent=2) + handle.flush() + os.fsync(handle.fileno()) + + def _ensure_temporary_descriptor( + self, + descriptor: int, + *, + parent_descriptor: int, + name: str, + ) -> None: + descriptor_stat = os.fstat(descriptor) + path_stat = os.stat(name, dir_fd=parent_descriptor, follow_symlinks=False) + if ( + not stat.S_ISREG(descriptor_stat.st_mode) + or stat.S_ISLNK(path_stat.st_mode) + or not os.path.samestat(descriptor_stat, path_stat) + ): + raise ValueError("Temporary configuration file changed while being opened") + self._ensure_parent_descriptor_matches_path(parent_descriptor) + + def _ensure_destination_unchanged( + self, + parent_descriptor: int, + original_stat: os.stat_result | None, + ) -> None: + try: + current_stat = self._configuration_path_stat(parent_descriptor) + except FileNotFoundError: + if original_stat is None: + return + raise ValueError( + f"Configuration path changed before being replaced: {self._path}" + ) from None + if ( + original_stat is None + or stat.S_ISLNK(current_stat.st_mode) + or not os.path.samestat(original_stat, current_stat) + ): + raise ValueError(f"Configuration path changed before being replaced: {self._path}") + + @staticmethod + def _unlink_relative_temporary_file(parent_descriptor: int, name: str) -> None: + try: + os.unlink(name, dir_fd=parent_descriptor) + except FileNotFoundError: + pass + + def _save_without_dir_fd(self, config: Profile) -> None: + profiles, original_stat = self._read_profiles_without_dir_fd() + payload = self._updated_payload(profiles, config) + self._write_without_dir_fd(original_stat, payload) + + def _remove_without_dir_fd(self, profile: str) -> bool: + profiles, original_stat = self._read_profiles_without_dir_fd() + if profile not in profiles: + return False + del profiles[profile] + self._write_without_dir_fd(original_stat, {"profiles": profiles}) + return True + + def _write_without_dir_fd( + self, + original_stat: os.stat_result | None, + payload: Mapping[str, Any], + ) -> None: + temporary_path, descriptor = self._create_temporary_file_without_dir_fd() + replace_pending = True + try: + with cast(TextIO, os.fdopen(descriptor, "w", encoding="utf-8")) as handle: + self._write_and_sync(handle, payload) + self._validate_temporary_file_without_dir_fd(temporary_path) + self._ensure_destination_unchanged_without_dir_fd(original_stat) + os.replace(temporary_path, self._path) + replace_pending = False + finally: + if replace_pending: + temporary_path.unlink(missing_ok=True) + + def _read_profiles_without_dir_fd( + self, + ) -> tuple[dict[str, dict[str, Any]], os.stat_result | None]: + try: + handle = self._open_directly_for_read() + except FileNotFoundError: + return {}, None + with handle: + original_stat = os.fstat(handle.fileno()) + return self._read_profiles_for_update(handle), original_stat + + def _create_temporary_file_without_dir_fd(self) -> tuple[Path, int]: + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | self._safe_open_flags() + for _ in range(_TEMPORARY_FILE_ATTEMPTS): + path = self._path.with_name(self._temporary_file_name()) + try: + descriptor = os.open(path, flags, _CONFIG_FILE_MODE) + except FileExistsError: + continue + try: + descriptor_stat = os.fstat(descriptor) + path_stat = path.lstat() + if ( + not stat.S_ISREG(descriptor_stat.st_mode) + or stat.S_ISLNK(path_stat.st_mode) + or not os.path.samestat(descriptor_stat, path_stat) + ): + raise ValueError("Temporary configuration file changed while being opened") + return path, descriptor + except BaseException: + os.close(descriptor) + path.unlink(missing_ok=True) + raise + raise FileExistsError("Unable to create a private temporary configuration file") + + def _validate_temporary_file_without_dir_fd(self, path: Path) -> None: + descriptor = os.open(path, os.O_RDONLY | self._safe_open_flags()) + try: + descriptor_stat = os.fstat(descriptor) + path_stat = path.lstat() + if ( + not stat.S_ISREG(descriptor_stat.st_mode) + or stat.S_ISLNK(path_stat.st_mode) + or not os.path.samestat(descriptor_stat, path_stat) + ): + raise ValueError("Temporary configuration file changed before being replaced") + finally: + os.close(descriptor) + + def _ensure_destination_unchanged_without_dir_fd( + self, + original_stat: os.stat_result | None, + ) -> None: + try: + current_stat = self._path.lstat() + except FileNotFoundError: + if original_stat is None: + return + raise ValueError( + f"Configuration path changed before being replaced: {self._path}" + ) from None + if ( + original_stat is None + or stat.S_ISLNK(current_stat.st_mode) + or not os.path.samestat(original_stat, current_stat) + ): + raise ValueError(f"Configuration path changed before being replaced: {self._path}") + + @staticmethod + def _updated_payload( + profiles: dict[str, dict[str, Any]], + config: Profile, + ) -> dict[str, Any]: + profiles[config.profile] = { + "region": config.region, + "api_token": config.api_token, + } + return {"profiles": profiles} + + def _temporary_file_name(self) -> str: + return f".{self._path.name}.{secrets.token_hex(16)}.tmp" - def _harden_existing_path(self) -> None: - if self._path.is_file(): - self._set_posix_mode(self._path, _CONFIG_FILE_MODE) - if self._path.parent == _CONFIG_DIR and self._path.parent.is_dir(): - self._set_posix_mode(self._path.parent, _CONFIG_DIR_MODE) + def _ensure_parent_directory(self) -> None: + self._validate_parent_directory() + 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 + + 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 _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: + 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_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_relative_descriptor( + self, + parent_descriptor: int, + *, + flags: int, + mode: int = 0o777, + ) -> int: + descriptor = os.open( + self._path.name, + flags, + mode, + dir_fd=parent_descriptor, + ) + try: + 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) + ProfileService._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 _normalize_macos_path(path: Path) -> Path: + if sys.platform != "darwin" or not path.is_absolute(): + return path + parts = path.parts + if len(parts) < 2 or parts[1] not in {"tmp", "var"}: + return path + return Path(path.anchor) / "private" / Path(*parts[1:]) @staticmethod - def _set_posix_mode(path: Path, mode: int) -> None: - """Apply owner-only mode bits where the platform supports POSIX permissions.""" - if _SUPPORTS_POSIX_MODES: - path.chmod(mode) + def _supports_posix_permissions() -> bool: + return os.name == "posix" diff --git a/cisco_sccfm_core/tests/test_access_group_service.py b/cisco_sccfm_core/tests/test_access_group_service.py index 132f4358..84be7a48 100644 --- a/cisco_sccfm_core/tests/test_access_group_service.py +++ b/cisco_sccfm_core/tests/test_access_group_service.py @@ -8,9 +8,6 @@ from typing import Any from unittest.mock import Mock -import pytest -from _pytest.monkeypatch import MonkeyPatch - from cisco_sccfm_core.services.policy.access_group_service import ( AccessGroupListResponse, AccessGroupResponse, diff --git a/cisco_sccfm_core/tests/test_asa_upgrade_service.py b/cisco_sccfm_core/tests/test_asa_upgrade_service.py index cfbdd04c..06f41c40 100644 --- a/cisco_sccfm_core/tests/test_asa_upgrade_service.py +++ b/cisco_sccfm_core/tests/test_asa_upgrade_service.py @@ -6,7 +6,6 @@ from __future__ import annotations -from typing import Any from unittest.mock import MagicMock, patch import pytest 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_errors.py b/cisco_sccfm_core/tests/test_errors.py index 6e1ffd2a..c22a5b61 100644 --- a/cisco_sccfm_core/tests/test_errors.py +++ b/cisco_sccfm_core/tests/test_errors.py @@ -6,11 +6,8 @@ from __future__ import annotations -from typing import Any from unittest.mock import MagicMock -import pytest - from cisco_sccfm_core.errors import SccApiError @@ -22,7 +19,9 @@ def _create_mock_api_exception( exc = MagicMock() exc.status = status exc.body = body - exc.__str__ = MagicMock(return_value=f"({status})\nReason: Test error") # type: ignore[method-assign] + exc.__str__ = MagicMock( # type: ignore[method-assign] + return_value=f"({status})\nReason: Test error" + ) return exc @@ -31,7 +30,10 @@ class TestFromException: def test_should_parse_valid_json_body_with_all_fields(self) -> None: """from_exception should parse JSON body with all fields.""" - body = '{"errorMsg": "Device not found", "errorCode": "NOT_FOUND", "details": {"deviceId": "123"}}' + body = ( + '{"errorMsg": "Device not found", "errorCode": "NOT_FOUND", ' + '"details": {"deviceId": "123"}}' + ) exc = _create_mock_api_exception(status=404, body=body) error = SccApiError.from_exception(exc) diff --git a/cisco_sccfm_core/tests/test_ftd_cli_service.py b/cisco_sccfm_core/tests/test_ftd_cli_service.py index a6615c87..b079088d 100644 --- a/cisco_sccfm_core/tests/test_ftd_cli_service.py +++ b/cisco_sccfm_core/tests/test_ftd_cli_service.py @@ -6,14 +6,13 @@ from __future__ import annotations -import json from typing import Any from unittest.mock import MagicMock, patch import pytest from scc_firewall_manager_sdk import Device, DevicePage, EntityType -from cisco_sccfm_core.models.ftd_cli_result import FtdBulkCliResult, FtdDeviceCliResponse +from cisco_sccfm_core.models.ftd_cli_result import FtdBulkCliResult from cisco_sccfm_core.services.inventory.ftd_cli_service import ( FtdCommandLineService, _build_fmc_uid_map, 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_core/tests/test_ftd_deploy_service.py b/cisco_sccfm_core/tests/test_ftd_deploy_service.py index 006ce609..a1206260 100644 --- a/cisco_sccfm_core/tests/test_ftd_deploy_service.py +++ b/cisco_sccfm_core/tests/test_ftd_deploy_service.py @@ -6,7 +6,6 @@ from __future__ import annotations -from typing import Any from unittest.mock import MagicMock import pytest diff --git a/cisco_sccfm_core/tests/test_network_group_service.py b/cisco_sccfm_core/tests/test_network_group_service.py index 44ec2416..01e8d4a4 100644 --- a/cisco_sccfm_core/tests/test_network_group_service.py +++ b/cisco_sccfm_core/tests/test_network_group_service.py @@ -16,9 +16,6 @@ NetworkObjectResponse, NetworkObjectService, ) -from cisco_sccfm_core.services.object_management.network_group_service import ( - NetworkGroupResponse as GroupResponse, -) from cisco_sccfm_core.services.object_management.object_api_helper import ObjectApiHelper SAMPLE_GROUP = NetworkObjectResponse( @@ -235,7 +232,7 @@ def test_update_preserves_existing_url_literals(self, monkeypatch: MonkeyPatch) service._helper = ObjectApiHelper.__new__(ObjectApiHelper) service._network_object_service = _mock_network_object_service_for_uid(new_ref) - result = service.update_network_group( + service.update_network_group( uid="00000000-0000-0000-0000-000000000001", referenced_objects=[new_ref], ) diff --git a/cisco_sccfm_cli/commands/tests/objects/network/test_network_object_service.py b/cisco_sccfm_core/tests/test_network_object_service.py similarity index 98% rename from cisco_sccfm_cli/commands/tests/objects/network/test_network_object_service.py rename to cisco_sccfm_core/tests/test_network_object_service.py index e9ea46b5..5e93d953 100644 --- a/cisco_sccfm_cli/commands/tests/objects/network/test_network_object_service.py +++ b/cisco_sccfm_core/tests/test_network_object_service.py @@ -224,7 +224,7 @@ def test_get_network_object_returns_none_for_wrong_type(self) -> None: }, } - result = service.get_network_object("abc-123") + result = service.get_network_object(uid="abc-123") assert result is None @@ -235,7 +235,7 @@ def test_get_network_object_by_name_uses_type_filter(self) -> None: service._helper = MagicMock() service._helper.read_raw_response.return_value = {"items": []} - service.get_network_object_by_name("test-obj") + service.get_network_object_by_name(name="test-obj") call_kwargs = service._object_api.get_objects_without_preload_content.call_args.kwargs assert "objectType:NETWORK_OBJECT" in call_kwargs["q"] diff --git a/cisco_sccfm_core/tests/test_packaging_metadata.py b/cisco_sccfm_core/tests/test_packaging_metadata.py index 7fa6ee4b..b0a89532 100644 --- a/cisco_sccfm_core/tests/test_packaging_metadata.py +++ b/cisco_sccfm_core/tests/test_packaging_metadata.py @@ -13,32 +13,55 @@ 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_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 _project_config()["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: + assert "scc-firewall-manager-sdk==1.17.27" in _project_config()["dependencies"] def test_interactive_entrypoint_is_completely_renamed() -> None: - scripts = _poetry_config()["scripts"] + with (PROJECT_ROOT / "devtools" / "pyproject.toml").open("rb") as pyproject_file: + scripts = tomllib.load(pyproject_file)["project"]["scripts"] assert scripts["sccfm-cli-interactive"] == "cisco_sccfm_scripts.interactive_cli:main" assert "devkit" not in scripts @@ -59,10 +82,16 @@ def test_user_guidance_only_references_canonical_profile_configuration() -> None guidance = path.read_text(encoding="utf-8") assert "change-tokens" not in guidance, path assert "`devkit`" not in guidance, path - assert "SCCFM_API_TOKEN" not in guidance, path + if path != PROJECT_ROOT / "README.md": + assert "SCCFM_API_TOKEN" not in guidance, path assert "SCCFM_REGION" not in guidance, path assert ".env.example" not in guidance, path + readme = (PROJECT_ROOT / "README.md").read_text(encoding="utf-8") + assert readme.count("SCCFM_API_TOKEN") == 1 + assert "interactive hidden prompt" in readme + assert "can expose the token in shell history and process listings" in readme + def test_pyinstaller_spec_uses_repository_relative_entrypoint() -> None: spec = (PROJECT_ROOT / "sccfm-cli.spec").read_text(encoding="utf-8") diff --git a/cisco_sccfm_core/tests/test_profile_service.py b/cisco_sccfm_core/tests/test_profile_service.py index 3c1f2f59..430401bc 100644 --- a/cisco_sccfm_core/tests/test_profile_service.py +++ b/cisco_sccfm_core/tests/test_profile_service.py @@ -6,12 +6,10 @@ import json from pathlib import Path -from unittest.mock import MagicMock from _pytest.monkeypatch import MonkeyPatch from cisco_sccfm_core.models.profile import Profile -from cisco_sccfm_core.services import profile_service from cisco_sccfm_core.services.profile_service import ProfileService @@ -60,14 +58,14 @@ def test_should_harden_config_file_permissions(tmp_path: Path) -> None: assert config_path.parent.stat().st_mode & 0o777 == 0o700 -def test_should_harden_existing_config_file_on_open(tmp_path: Path) -> None: +def test_should_not_mutate_existing_config_file_during_construction(tmp_path: Path) -> None: config_path = tmp_path / "config.json" config_path.write_text('{"profiles": {}}\n') config_path.chmod(0o644) ProfileService(path=config_path) - assert config_path.stat().st_mode & 0o777 == 0o600 + assert config_path.stat().st_mode & 0o777 == 0o644 def test_should_replace_config_atomically_without_leaving_temporary_files( @@ -96,21 +94,3 @@ def test_should_honor_canonical_config_path_override( assert ProfileService().load("lab") == Profile(profile="lab", region="eu", api_token="token") assert config_path.is_file() - - -def test_should_save_profile_without_posix_mode_apis( - tmp_path: Path, monkeypatch: MonkeyPatch -) -> None: - config_path = tmp_path / "config.json" - monkeypatch.setattr(profile_service, "_SUPPORTS_POSIX_MODES", False) - fchmod = MagicMock() - monkeypatch.setattr(profile_service.os, "fchmod", fchmod, raising=False) - - ProfileService(path=config_path).save( - Profile(profile="default", region="us", api_token="secret-token") - ) - - fchmod.assert_not_called() - assert ProfileService(path=config_path).load("default") == Profile( - profile="default", region="us", api_token="secret-token" - ) diff --git a/cisco_sccfm_core/tests/test_profile_service_security.py b/cisco_sccfm_core/tests/test_profile_service_security.py new file mode 100644 index 00000000..4c68197f --- /dev/null +++ b/cisco_sccfm_core/tests/test_profile_service_security.py @@ -0,0 +1,800 @@ +# Copyright 2026 Cisco Systems, Inc. and its affiliates +# +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import json +import os +import stat +import sys +from pathlib import Path +from typing import Any, TextIO + +import pytest + +from cisco_sccfm_core.models.profile import Profile +from cisco_sccfm_core.services import ProfileService +from cisco_sccfm_core.services import profile_service as profile_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 _temporary_files(config_path: Path) -> list[Path]: + return list(config_path.parent.glob(f".{config_path.name}.*.tmp")) + + +def _write_config(path: Path, profile: str = "default") -> Profile: + expected = Profile(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(profile_service_module, "_CONFIG_DIR", config_path.parent) + monkeypatch.setattr(profile_service_module, "_CONFIG_FILE", config_path) + + +def test_should_save_and_load_config(tmp_path: Path) -> None: + """ProfileService should persist and retrieve configuration.""" + config_path = tmp_path / "config.json" + service = ProfileService(path=config_path) + + expected = Profile(profile="default", region="us", api_token="secret-token") + service.save(expected) + + loaded = service.load("default") + assert loaded == expected + + +def test_should_list_all_profiles(tmp_path: Path) -> None: + """ProfileService should list all saved profiles.""" + config_path = tmp_path / "config.json" + service = ProfileService(path=config_path) + + expected = Profile(profile="default", region="us", api_token="secret-token") + service.save(expected) + + profiles = service.list_profiles() + 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"): + ProfileService(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"): + ProfileService(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"): + ProfileService(path=config_directory / "config.json").save( + Profile(profile="default", region="us", api_token="example-token") + ) + + assert not (target_directory / "config.json").exists() + assert _mode(target_directory) == original_mode + + +@pytest.mark.parametrize( + ("provided", "normalized"), + [ + (Path("/tmp/sccfm/config.json"), Path("/private/tmp/sccfm/config.json")), + (Path("/var/sccfm/config.json"), Path("/private/var/sccfm/config.json")), + ], +) +def test_macos_fixed_directory_aliases_are_normalized( + provided: Path, + normalized: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Only macOS's fixed /tmp and /var aliases should use their physical paths.""" + monkeypatch.setattr(profile_service_module.sys, "platform", "darwin") + + service = ProfileService(path=provided) + + assert service._path == normalized + + +@pytest.mark.parametrize( + "path", + [ + Path("/private/tmp/sccfm/config.json"), + Path("/opt/tmp/sccfm/config.json"), + Path("relative/tmp/sccfm/config.json"), + ], +) +def test_macos_path_normalization_is_narrow( + path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Normalization must not resolve or rewrite arbitrary path components.""" + monkeypatch.setattr(profile_service_module.sys, "platform", "darwin") + + service = ProfileService(path=path) + + assert service._path == path + + +@pytest.mark.skipif(sys.platform != "darwin", reason="macOS fixed aliases are platform-specific") +def test_macos_alias_normalization_still_rejects_user_controlled_symlink( + tmp_path: Path, +) -> None: + """Allowing the fixed /var alias must not allow a later user-created symlink.""" + if tmp_path.parts[1:3] != ("private", "var"): + pytest.skip("pytest temporary storage is not below macOS /private/var") + target_directory = tmp_path / "target" + target_directory.mkdir() + linked_directory = tmp_path / "linked" + linked_directory.symlink_to(target_directory, target_is_directory=True) + alias_root = Path(tmp_path.anchor).joinpath(*tmp_path.parts[2:]) + + with pytest.raises(ValueError, match="must not contain symbolic links"): + ProfileService(path=alias_root / "linked" / "config.json").save( + Profile(profile="default", region="us", api_token="example-token") + ) + + assert not (target_directory / "config.json").exists() + + +def test_save_validates_opened_file_before_updating( + 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 = ProfileService(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.save(Profile(profile="default", region="us", api_token="example-token")) + + 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): + ProfileService(path=config_path).save( + Profile(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_save_preserves_existing_config_when_serialization_fails( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A serialization failure must leave the installed configuration untouched.""" + config_path = tmp_path / "config.json" + _write_config(config_path, profile="existing") + config_path.chmod(0o600) + original_payload = config_path.read_bytes() + + def fail_dump(payload: Any, handle: TextIO, *, indent: int) -> None: + raise TypeError("synthetic serialization failure") + + monkeypatch.setattr(profile_service_module.json, "dump", fail_dump) + + with pytest.raises(TypeError, match="synthetic serialization failure"): + ProfileService(path=config_path).save( + Profile(profile="added", region="eu", api_token="must-not-be-installed") + ) + + assert config_path.read_bytes() == original_payload + assert _temporary_files(config_path) == [] + + +@POSIX_ONLY +def test_save_preserves_existing_config_after_partial_temporary_write_failure( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A partial temporary write must not corrupt or replace live configuration.""" + config_path = tmp_path / "config.json" + _write_config(config_path, profile="existing") + config_path.chmod(0o600) + original_payload = config_path.read_bytes() + + def fail_dump(payload: Any, handle: TextIO, *, indent: int) -> None: + handle.write('{"profiles":') + raise OSError("synthetic write failure") + + monkeypatch.setattr(profile_service_module.json, "dump", fail_dump) + + with pytest.raises(OSError, match="synthetic write failure"): + ProfileService(path=config_path).save( + Profile(profile="added", region="eu", api_token="must-not-be-installed") + ) + + assert config_path.read_bytes() == original_payload + assert _temporary_files(config_path) == [] + + +@POSIX_ONLY +def test_save_preserves_existing_config_when_temporary_fsync_fails( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A temporary-file fsync failure must abort before replacing live configuration.""" + config_path = tmp_path / "config.json" + _write_config(config_path, profile="existing") + config_path.chmod(0o600) + original_payload = config_path.read_bytes() + real_fsync = os.fsync + + def fail_regular_file_fsync(descriptor: int) -> None: + if stat.S_ISREG(os.fstat(descriptor).st_mode): + raise OSError("synthetic fsync failure") + real_fsync(descriptor) + + monkeypatch.setattr(profile_service_module.os, "fsync", fail_regular_file_fsync) + + with pytest.raises(OSError, match="synthetic fsync failure"): + ProfileService(path=config_path).save( + Profile(profile="added", region="eu", api_token="must-not-be-installed") + ) + + assert config_path.read_bytes() == original_payload + assert _temporary_files(config_path) == [] + + +@POSIX_ONLY +def test_save_syncs_temporary_file_and_parent_directory( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A successful atomic save must make both content and replacement durable.""" + config_path = tmp_path / "config.json" + real_fsync = os.fsync + synced_modes: list[int] = [] + + def observe_fsync(descriptor: int) -> None: + synced_modes.append(os.fstat(descriptor).st_mode) + real_fsync(descriptor) + + monkeypatch.setattr(profile_service_module.os, "fsync", observe_fsync) + + ProfileService(path=config_path).save( + Profile(profile="default", region="us", api_token="example-token") + ) + + assert len(synced_modes) == 2 + assert stat.S_ISREG(synced_modes[0]) + assert stat.S_ISDIR(synced_modes[1]) + assert _temporary_files(config_path) == [] + + +@POSIX_ONLY +def test_save_uses_descriptor_relative_replace( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The final replace must stay anchored to the validated parent descriptor.""" + config_path = tmp_path / "config.json" + real_replace = os.replace + replace_descriptors: list[tuple[int | None, int | None]] = [] + + def observe_replace( + source: str | os.PathLike[str], + destination: str | os.PathLike[str], + *, + src_dir_fd: int | None = None, + dst_dir_fd: int | None = None, + ) -> None: + replace_descriptors.append((src_dir_fd, dst_dir_fd)) + real_replace( + source, + destination, + src_dir_fd=src_dir_fd, + dst_dir_fd=dst_dir_fd, + ) + + monkeypatch.setattr(profile_service_module.os, "replace", observe_replace) + + ProfileService(path=config_path).save( + Profile(profile="default", region="us", api_token="example-token") + ) + + assert len(replace_descriptors) == 1 + source_descriptor, destination_descriptor = replace_descriptors[0] + assert source_descriptor is not None + assert source_descriptor == destination_descriptor + + +@POSIX_ONLY +def test_save_rejects_path_swap_before_atomic_update( + 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 dir_fd is not None and not flags & os.O_CREAT and not swapped: + config_path.rename(opened_path) + replacement_path.rename(config_path) + swapped = True + return descriptor + + monkeypatch.setattr(profile_service_module.os, "open", swap_after_open) + + with pytest.raises(ValueError, match="changed while being opened"): + ProfileService(path=config_path).save( + Profile(profile="added", region="eu", api_token="must-not-be-written") + ) + + 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(profile_service_module.os, "open", swap_parent_before_relative_open) + + with pytest.raises(ValueError, match="directory changed") as excinfo: + ProfileService(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(profile_service_module.os, "open", swap_parent_before_relative_open) + + with pytest.raises(ValueError, match="directory changed") as excinfo: + ProfileService(path=config_path).save( + Profile(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 = ProfileService(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 = ProfileService(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(Profile(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(profile_service_module.os, "fchmod", reject_fchmod) + + assert ProfileService().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, + 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(stat.S_IMODE(os.fstat(handle.fileno()).st_mode)) + original_dump(payload, handle, indent=indent) + + monkeypatch.setattr(profile_service_module.json, "dump", observe_mode) + + ProfileService(path=config_path).save( + Profile(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) + + ProfileService().save(Profile(profile="default", region="us", api_token="example-token")) + + assert _mode(config_path.parent) == 0o700 + assert _mode(config_path) == 0o600 + + +@POSIX_ONLY +@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(unsafe_mode) + + expected_mode = f"{unsafe_mode:04o}" + with pytest.raises(PermissionError, match=f"expected 0600, found {expected_mode}") as excinfo: + ProfileService(path=config_path).load(expected.profile) + + 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_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) + 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 = Profile(profile="added", region="eu", api_token="another-example-token") + + service = ProfileService(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_rejects_unsafe_default_directory_without_changing_modes( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """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(0o600) + _use_default_path(monkeypatch, config_path) + + with pytest.raises(PermissionError, match="expected 0700, found 0755"): + ProfileService().load(expected.profile) + + assert _mode(config_path.parent) == 0o755 + assert _mode(config_path) == 0o600 + + +@POSIX_ONLY +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"): + ProfileService().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: + """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) + existing = _write_config(config_path, profile="existing") + config_path.chmod(0o644) + _use_default_path(monkeypatch, config_path) + + added = Profile(profile="added", region="eu", api_token="example-token-2") + service = ProfileService() + 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( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Platforms without POSIX permissions should still persist configuration.""" + config_path = tmp_path / "config.json" + expected = Profile(profile="default", region="us", api_token="example-token") + monkeypatch.setattr( + ProfileService, + "_supports_posix_permissions", + staticmethod(lambda: False), + ) + + service = ProfileService(path=config_path) + service.save(expected) + + assert service.load(expected.profile) == expected diff --git a/cisco_sccfm_scripts/build_ansible_collection.py b/cisco_sccfm_scripts/build_ansible_collection.py index 40292b19..84120249 100644 --- a/cisco_sccfm_scripts/build_ansible_collection.py +++ b/cisco_sccfm_scripts/build_ansible_collection.py @@ -5,6 +5,8 @@ # SPDX-License-Identifier: Apache-2.0 """Build script for Ansible collection.""" +import os +import re import shutil import subprocess import sys @@ -13,6 +15,74 @@ import yaml +from cisco_sccfm_scripts.verify_ansible_collection import ( + ArtifactVerificationError, + verify_collection_artifact, +) + +_PAIRED_REQUIREMENT_PIN = re.compile( + r"^[ \t]*cisco-sccfm-devkit[ \t]*==[ \t]*[^\s;#]+[ \t]*(?:#.*)?" r"(?P\r?\n)?$", + re.IGNORECASE, +) +_RUNTIME_REQUIREMENT_PIN = re.compile( + r'^_PAIRED_DEVKIT_REQUIREMENT = "cisco-sccfm-devkit==[^"]+"$', + re.MULTILINE, +) + + +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.""" + 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 _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 _sync_runtime_requirement(dependencies_path: Path, version: str) -> None: + """Synchronize the dependency error with the collection's paired wheel pin.""" + content = dependencies_path.read_text(encoding="utf-8") + replacement = f'_PAIRED_DEVKIT_REQUIREMENT = "cisco-sccfm-devkit=={version}"' + updated, count = _RUNTIME_REQUIREMENT_PIN.subn(replacement, content) + if count != 1: + raise CollectionBuildError( + "dependencies.py must declare exactly one paired cisco-sccfm-devkit requirement" + ) + if updated != content: + dependencies_path.write_text(updated, encoding="utf-8") + def main() -> int: """Build the Ansible collection tarball.""" @@ -21,22 +91,36 @@ 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" + dependencies_path = collection_dir / "plugins" / "module_utils" / "dependencies.py" license_src = project_root / "LICENSE" license_dst = collection_dir / "LICENSE" print("🎭 Building Ansible collection...") - # 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}/") + symlink = _find_collection_symlink(collection_dir) + if symlink is not None: + print(f"❌ Collection source contains a symlink: {symlink}", file=sys.stderr) + return 1 # 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: + _sync_paired_python_requirement(requirements_path, version) + _sync_runtime_requirement(dependencies_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 requirements 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) @@ -63,7 +147,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/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/prepare_ansible_release.py b/cisco_sccfm_scripts/prepare_ansible_release.py new file mode 100644 index 00000000..58083c3b --- /dev/null +++ b/cisco_sccfm_scripts/prepare_ansible_release.py @@ -0,0 +1,431 @@ +# 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]*))$" +) +_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.39.0" +_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 _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() + 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 _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 = [ + 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") + 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") + + 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) + 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 + if yaml_has_target != rst_has_target: + raise AnsibleReleaseError( + f"changelog files disagree on the release; {_MAINTAINER_GUIDANCE}" + ) + + 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: + if set(releases) != {previous_version} or set(headings) != {previous_version}: + 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) + _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/setup_ci_environment.sh b/cisco_sccfm_scripts/setup_ci_environment.sh index e9f3e86c..f28aee14 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 if [[ ! -x "${VENV_DIR}/bin/cz" ]]; then echo "Commitizen did not install correctly." >&2 diff --git a/cisco_sccfm_scripts/setup_environment.sh b/cisco_sccfm_scripts/setup_environment.sh index 3909f4f0..093d2064 100755 --- a/cisco_sccfm_scripts/setup_environment.sh +++ b/cisco_sccfm_scripts/setup_environment.sh @@ -48,7 +48,7 @@ function create_venv() { if ! command -v poetry >/dev/null 2>&1; then pip install poetry fi - POETRY_VIRTUALENVS_IN_PROJECT=1 poetry install --with dev,build + POETRY_VIRTUALENVS_IN_PROJECT=1 poetry install --with dev if [[ ! -x "${VENV_DIR}/bin/cz" ]]; then echo "Commitizen did not install correctly." >&2 exit 1 diff --git a/cisco_sccfm_scripts/verify_ansible_collection.py b/cisco_sccfm_scripts/verify_ansible_collection.py new file mode 100644 index 00000000..5e203840 --- /dev/null +++ b/cisco_sccfm_scripts/verify_ansible_collection.py @@ -0,0 +1,480 @@ +# 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 + +import yaml + +_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", + "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", + "meta/runtime.yml", + "plugins/inventory", + "plugins/module_utils", + "plugins/modules", + "requirements.txt", + "tests/sanity/ignore-2.20.txt", + "tests/sanity/ignore-2.21.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", + } +) +_ALLOWED_TEST_PATHS = frozenset( + { + "tests", + "tests/sanity", + "tests/sanity/ignore-2.20.txt", + "tests/sanity/ignore-2.21.txt", + } +) +_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", + ".pyc", + ".pyo", + ".p12", + ".pem", + ".pfx", + ".retry", + ".sqlite", + ".sqlite3", + ".swo", + ".swp", + ".tmp", +) +_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 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 + + 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 _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") + 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_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" + 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"]) + _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)) + 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/cisco_sccfm_scripts/verify_clean_controller.py b/cisco_sccfm_scripts/verify_clean_controller.py new file mode 100644 index 00000000..fc413b35 --- /dev/null +++ b/cisco_sccfm_scripts/verify_clean_controller.py @@ -0,0 +1,308 @@ +# 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" +_PROFILE_ERROR = "SCCFM profile 'default' not found" +_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_controller_and_collection( + controller: _Controller, + collection: Path, +) -> None: + python = controller.binaries / "python" + _run(controller, [python, "-I", "-m", "pip", "install", "--no-cache-dir", _ANSIBLE_CORE]) + _run( + controller, + [ + controller.binaries / "ansible-galaxy", + "collection", + "install", + collection, + "-p", + controller.collections, + "-f", + ], + ) + + +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: {rendered}" + ) + + +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( + _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 _PROFILE_ERROR not in f"{result.stdout}\n{result.stderr}": + raise CleanControllerVerificationError("module did not reach missing-profile 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_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 + + +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_pypi_release.py b/cisco_sccfm_scripts/verify_pypi_release.py new file mode 100644 index 00000000..d44c2d84 --- /dev/null +++ b/cisco_sccfm_scripts/verify_pypi_release.py @@ -0,0 +1,247 @@ +# 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 + missing_filenames: tuple[str, ...] = () + + +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, + missing_filenames=tuple(sorted(set(local_hashes) - set(remote_hashes))), + ) + + +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} " + f"missing={','.join(result.missing_filenames)}" + ) + 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 new file mode 100644 index 00000000..08a18d3a --- /dev/null +++ b/cisco_sccfm_scripts/verify_python_artifacts.py @@ -0,0 +1,509 @@ +# 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 email.policy +import hashlib +import io +import re +import stat +import tarfile +import tomllib +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 +from urllib.parse import urlsplit + +_DISTRIBUTION_STEM = "cisco_sccfm_devkit" +_PACKAGE_ROOTS = frozenset({"cisco_sccfm_cli", "cisco_sccfm_core"}) +_SDIST_METADATA_ROOTS = frozenset( + { + "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"} +_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( + { + ".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 + + +@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("-") + 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_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 _parse_package_metadata(raw: bytes, source: str) -> Message: + """Parse one bounded package metadata document.""" + try: + metadata = BytesParser(policy=email.policy.default).parsebytes(raw) + 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: + 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(project, dict) or 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 = project.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])) + metadata_name = f"{expected_dist_info}/METADATA" + if metadata_name not in members: + raise PythonArtifactVerificationError("wheel has no package 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) + + +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" + ) + 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") + 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_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 + return sum(member.isfile() for member in members.values()) + + +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_verification = verify_python_wheel(wheel) + sdist_version = _sdist_version(sdist) + if wheel_verification.version != sdist_version: + raise PythonArtifactVerificationError("wheel and sdist versions do not match") + + return PythonArtifactVerification( + wheel_files=wheel_verification.files, + 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 044af422..f1eb0181 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`, `sccfm-cli-interactive`, `build-ansible-collection`. + - Public entry point: `sccfm-cli`; `sccfm-cli-interactive` and other 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 @@ -417,7 +418,8 @@ ### 16.5 Helper scripts - **Canonical:** `cisco_sccfm_scripts/` (`interactive_cli.py`, `setup_environment.sh`, `setup_ci_environment.sh`, `import_legacy_vault.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. +- **Invariants:** new repo-wide automation remains source-only and is exposed through + `devtools/pyproject.toml`; public CLI features belong under `cisco_sccfm_cli`. --- diff --git a/sccfm-ansible/plugins/module_utils/builders/__init__.py b/devtools/cisco_sccfm_devtools/__init__.py similarity index 51% rename from sccfm-ansible/plugins/module_utils/builders/__init__.py rename to devtools/cisco_sccfm_devtools/__init__.py index 2c3a50b8..bfb460ce 100644 --- a/sccfm-ansible/plugins/module_utils/builders/__init__.py +++ b/devtools/cisco_sccfm_devtools/__init__.py @@ -2,6 +2,4 @@ # # SPDX-License-Identifier: Apache-2.0 -from .inventory_host_builder import InventoryHostBuilder - -__all__ = ["InventoryHostBuilder"] +"""Local-only console entry points for SCCFM maintainers.""" diff --git a/devtools/pyproject.toml b/devtools/pyproject.toml new file mode 100644 index 00000000..2cd6aa76 --- /dev/null +++ b/devtools/pyproject.toml @@ -0,0 +1,24 @@ +# 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] +sccfm-cli-interactive = "cisco_sccfm_scripts.interactive_cli:main" +build-ansible-collection = "cisco_sccfm_scripts.build_ansible_collection: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/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 diff --git a/docs/ansible/modules/onboard_cdfmc_ftd.md b/docs/ansible/modules/onboard_cdfmc_ftd.md index d09e5625..27b61c67 100644 --- a/docs/ansible/modules/onboard_cdfmc_ftd.md +++ b/docs/ansible/modules/onboard_cdfmc_ftd.md @@ -70,7 +70,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: "00000000-0000-0000-0000-000000000000" licenses: - BASE profile: default @@ -79,7 +79,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: "00000000-0000-0000-0000-000000000000" licenses: - BASE - CARRIER @@ -90,7 +90,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: "00000000-0000-0000-0000-000000000000" licenses: - BASE ungrouped_labels: @@ -110,7 +110,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: "00000000-0000-0000-0000-000000000000" licenses: - BASE diff --git a/docs/ansible/modules/onboard_cdfmc_ftd_ztp.md b/docs/ansible/modules/onboard_cdfmc_ftd_ztp.md index 30764dd8..f683f7ce 100644 --- a/docs/ansible/modules/onboard_cdfmc_ftd_ztp.md +++ b/docs/ansible/modules/onboard_cdfmc_ftd_ztp.md @@ -77,7 +77,7 @@ EXAMPLES: serial_number: "FTD1234567890" licenses: - BASE - fmc_access_policy_uid: "7131daad-e813-4b8f-8f42-be1e241e8cdb" + fmc_access_policy_uid: "00000000-0000-0000-0000-000000000000" profile: default # Example 2: Onboard with initial password and device group @@ -88,7 +88,7 @@ EXAMPLES: licenses: - BASE - CARRIER - fmc_access_policy_uid: "7131daad-e813-4b8f-8f42-be1e241e8cdb" + fmc_access_policy_uid: "00000000-0000-0000-0000-000000000000" admin_password: "{{ ftd_admin_password }}" device_group_uid: "abcd1234-0000-0000-0000-000000000001" @@ -106,7 +106,7 @@ EXAMPLES: serial_number: "FTD1234567890" licenses: - BASE - fmc_access_policy_uid: "7131daad-e813-4b8f-8f42-be1e241e8cdb" + fmc_access_policy_uid: "00000000-0000-0000-0000-000000000000" RETURN VALUES: 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/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/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 b40170e0..d33d399f 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/docs/man/man1/sccfm-cli-inventory-devices-asa-smartlicense.1 b/docs/man/man1/sccfm-cli-inventory-devices-asa-smartlicense.1 index 2721d5fc..d6596838 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/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 c65f8750..6c2b5cd1 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 @@ -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-onboard-ztp.1 b/docs/man/man1/sccfm-cli-inventory-devices-cdfmc-managed-ftd-onboard-ztp.1 index b2238796..19c4800a 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 @@ -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/poetry.lock b/poetry.lock index b5a90640..eb6596a3 100644 --- a/poetry.lock +++ b/poetry.lock @@ -437,16 +437,30 @@ 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.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 +646,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 +786,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 +1182,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" @@ -1261,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"}, @@ -1465,14 +1472,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] @@ -1608,7 +1615,7 @@ version = "6.0.3" description = "YAML parser and emitter for Python" optional = false python-versions = ">=3.8" -groups = ["main", "dev"] +groups = ["dev"] files = [ {file = "PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f"}, {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4"}, @@ -1691,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"}, @@ -1890,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"}, @@ -1898,5 +1905,5 @@ files = [ [metadata] lock-version = "2.1" -python-versions = "^3.12" -content-hash = "892343bb2171803dea35c2ac9371d1aef943fdc3223af1b8931a93d7e78c1bf9" +python-versions = ">=3.12,<4.0" +content-hash = "881e29072275a61cf5f391e2de12831144d985912c88efec4b52aec3597ce337" diff --git a/pyproject.toml b/pyproject.toml index 5615a523..12a6d91d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,18 +1,18 @@ -[tool.poetry] +[project] name = "cisco-sccfm-devkit" version = "0.39.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,42 +21,52 @@ classifiers = [ "Topic :: System :: Networking", "Topic :: System :: Systems Administration", ] -readme = "README.md" -packages = [ - { include = "cisco_sccfm_cli" }, - { include = "cisco_sccfm_core" }, - { include = "cisco_sccfm_scripts" }, +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", ] -[tool.poetry.urls] -"Documentation" = "https://ciscodevnet.github.io/sccfm-devkit/" +[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" - -[tool.poetry.dependencies] -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" -questionary = "^2.1.1" -paramiko = "^3.5.0" -pyyaml = "^6.0.0" +Changelog = "https://github.com/CiscoDevNet/sccfm-devkit/releases" -[tool.poetry.scripts] +[project.scripts] sccfm-cli = "cisco_sccfm_cli.cli:cli" -sccfm-cli-interactive = "cisco_sccfm_scripts.interactive_cli:main" -build-ansible-collection = "cisco_sccfm_scripts.build_ansible_collection: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] +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", + "**/e2e", + "**/__pycache__", + "**/.pytest_cache", + "**/.mypy_cache", + "**/*.pyc", + "**/*.pyo", + "**/.DS_Store", +] [tool.poetry.group.dev.dependencies] -ansible-core = "^2.17.0" +cisco-sccfm-devtools = { path = "devtools", develop = true } +ansible-core = ">=2.20,<2.22" black = "^25.11.0" click-man = "^0.5.1" isort = "^7.0.0" @@ -67,9 +77,10 @@ pre-commit = "^4.5.0" flake8 = "^7.1.1" commitizen = "^3.27.0" reuse = "^6.2.0" +questionary = "^2.1.1" [build-system] -requires = ["poetry-core"] +requires = ["poetry-core>=2.2.0,<3.0.0"] build-backend = "poetry.core.masonry.api" [tool.black] @@ -123,7 +134,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/CHANGELOG.rst b/sccfm-ansible/CHANGELOG.rst new file mode 100644 index 00000000..5f79a3d8 --- /dev/null +++ b/sccfm-ansible/CHANGELOG.rst @@ -0,0 +1,13 @@ +==================================== +Cisco SCCFM Collection Release Notes +==================================== + +.. contents:: Topics + +v0.39.1 +======= + +Release Summary +--------------- + +Initial development release of the cisco.sccfm collection, with dynamic inventory and modules for automating Cisco Security Cloud Control Firewall Manager. This release unifies CLI and Ansible authentication around canonical SCCFM profiles and prepares the paired Python and Galaxy artifacts for secure publication. diff --git a/sccfm-ansible/changelogs/changelog.yaml b/sccfm-ansible/changelogs/changelog.yaml new file mode 100644 index 00000000..1abeb412 --- /dev/null +++ b/sccfm-ansible/changelogs/changelog.yaml @@ -0,0 +1,15 @@ +--- +ancestor: null +# sccfm-release-retarget-seed: 0.39.0 +releases: + 0.39.1: + changes: + release_summary: >- + Initial development release of the cisco.sccfm collection, with dynamic + inventory and modules for automating Cisco Security Cloud Control Firewall + Manager. This release unifies CLI and Ansible authentication around canonical + SCCFM profiles and prepares the paired Python and Galaxy artifacts for secure + publication. + fragments: + - 0.39.1.yml + release_date: '2026-08-18' 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/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/asa/playbooks/onboard_vasa.yml b/sccfm-ansible/e2e/asa/playbooks/onboard_vasa.yml index 270801f5..c76e7448 100644 --- a/sccfm-ansible/e2e/asa/playbooks/onboard_vasa.yml +++ b/sccfm-ansible/e2e/asa/playbooks/onboard_vasa.yml @@ -1,9 +1,9 @@ --- # Onboard the CI vASA to the SCCFM tenant. # -# Expected environment variable: -# ASA_HOST — IP or hostname of the provisioned vASA (set by Jenkins) -# VASA_PASSWORD — password for the vASA admin user (set by Jenkins) +# Expected inputs: +# ASA_HOST — IP or hostname of the provisioned vASA (set by Jenkins) +# vault_vasa_password — password for the vASA admin user (from Ansible Vault) - name: "CI: Onboard vASA to SCCFM tenant" hosts: localhost @@ -23,15 +23,14 @@ ansible.builtin.assert: that: - lookup('env', 'ASA_HOST') | length > 0 - - lookup('env', 'VASA_PASSWORD') | length > 0 - fail_msg: "ASA_HOST and VASA_PASSWORD environment variables must be set" + fail_msg: "ASA_HOST environment variable must be set" - name: Onboard vASA device cisco.sccfm.onboard_asa: name: "ci-e2e-asa-{{ lookup('env', 'ASA_HOST') | regex_replace('[^a-zA-Z0-9]', '-') }}" device_address: "{{ lookup('env', 'ASA_HOST') }}:443" username: admin - password: "{{ lookup('env', 'VASA_PASSWORD') }}" + password: "{{ vault_vasa_password }}" connector_type: "{{ default_connector_type }}" ignore_certificate: "{{ default_ignore_certificate }}" register: onboard_result 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/examples/group_vars/all/vault.yml.example b/sccfm-ansible/examples/group_vars/all/vault.yml.example index 38891ebe..9fd05bd3 100644 --- a/sccfm-ansible/examples/group_vars/all/vault.yml.example +++ b/sccfm-ansible/examples/group_vars/all/vault.yml.example @@ -12,6 +12,7 @@ # ASA device passwords - each device can have its own password # Use descriptive names that match your device naming in the playbook +vault_vasa_password: "ReplaceWithYourVasaPassword" vault_asa_branch_office_01_password: "BranchOffice01-SecurePass!" vault_asa_datacenter_01_password: "Datacenter01-SecurePass!" vault_asa_dmz_01_password: "DMZ01-SecurePass!" diff --git a/sccfm-ansible/galaxy.yml b/sccfm-ansible/galaxy.yml index be104e4d..80f08619 100644 --- a/sccfm-ansible/galaxy.yml +++ b/sccfm-ansible/galaxy.yml @@ -4,9 +4,8 @@ version: 0.39.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,8 @@ build_ignore: - ansible_collections - plugins/modules/tests - plugins/modules/tests/** +- tests/test_profile_lookup.py +- changelogs/.plugin-cache.yaml - .DS_Store - '**/.DS_Store' - .gitignore @@ -39,3 +40,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/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..e194c82c 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: @@ -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 7c637dcf..0f418694 100644 --- a/sccfm-ansible/plugins/inventory/sccfm.py +++ b/sccfm-ansible/plugins/inventory/sccfm.py @@ -4,23 +4,8 @@ from __future__ import annotations -from pathlib import Path -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 cisco_sccfm_core.services.profile_service import ProfileService - -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 @@ -71,6 +56,27 @@ """ +from pathlib import Path +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 + +try: + from scc_firewall_manager_sdk import Device + + from cisco_sccfm_core.services.profile_service import ProfileService +except ImportError as exc: + _DEPENDENCY_IMPORT_ERROR: ImportError | None = exc +else: + _DEPENDENCY_IMPORT_ERROR = None + +from ..module_utils.config import Config +from ..plugin_utils.inventory_host_builder import InventoryHostBuilder +from ..plugin_utils.inventory_loader import InventoryLoader + + class InventoryModule(BaseInventoryPlugin): NAME = "cisco.sccfm.sccfm" @@ -88,6 +94,12 @@ def verify_file(self, path: str) -> bool: def parse(self, inventory: Any, loader: Any, path: str, cache: bool = True) -> None: super().parse(inventory, loader, path, cache=cache) + 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 + config_data: Dict[str, Any] = self._read_config_data(path) profile = ( self._template_string(cast(Optional[str], config_data.get("profile"))) or "default" diff --git a/sccfm-ansible/plugins/lookup/profile.py b/sccfm-ansible/plugins/lookup/profile.py index ff1509c1..df4005c9 100644 --- a/sccfm-ansible/plugins/lookup/profile.py +++ b/sccfm-ansible/plugins/lookup/profile.py @@ -4,17 +4,9 @@ from __future__ import annotations -from pathlib import Path -from typing import Any - -from ansible.errors import AnsibleError -from ansible.plugins.lookup import LookupBase - -from cisco_sccfm_core.services.profile_service import ProfileService - DOCUMENTATION = r""" name: profile -author: Cisco SCCFM Team +author: Cisco SCCFM Team (@CiscoDevNet) version_added: "0.39.0" short_description: Read a value from a configured SCCFM profile description: @@ -50,6 +42,20 @@ """ +from pathlib import Path +from typing import Any + +from ansible.errors import AnsibleError +from ansible.plugins.lookup import LookupBase + +try: + from cisco_sccfm_core.services.profile_service import ProfileService +except ImportError as exc: + _DEPENDENCY_IMPORT_ERROR: ImportError | None = exc +else: + _DEPENDENCY_IMPORT_ERROR = None + + class LookupModule(LookupBase): """Read fields from the canonical SCCFM profile store.""" @@ -59,6 +65,12 @@ def run( variables: dict[str, Any] | None = None, **kwargs: Any, ) -> list[str]: + if _DEPENDENCY_IMPORT_ERROR is not None: + raise AnsibleError( + "cisco-sccfm-devkit must be installed on the Ansible controller " + "to use the cisco.sccfm.profile lookup" + ) from _DEPENDENCY_IMPORT_ERROR + self.set_options(var_options=variables, direct=kwargs) field = self.get_option("field") raw_path = self.get_option("config_path") diff --git a/sccfm-ansible/plugins/module_utils/config.py b/sccfm-ansible/plugins/module_utils/config.py index 87073266..39ae1445 100644 --- a/sccfm-ansible/plugins/module_utils/config.py +++ b/sccfm-ansible/plugins/module_utils/config.py @@ -8,16 +8,29 @@ from pathlib import Path from typing import TYPE_CHECKING, Any -from cisco_sccfm_core.constants import SCCFM_REGIONS, normalize_sccfm_region -from cisco_sccfm_core.services.profile_service import ProfileService +from .dependencies import ensure_required_dependencies, record_import_error + +try: + from cisco_sccfm_core.services.profile_service import ProfileService +except ImportError as exc: + record_import_error(exc) if TYPE_CHECKING: from ansible.module_utils.basic import AnsibleModule -ALLOWED_REGIONS = SCCFM_REGIONS +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 requiring 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: """Validated SCCFM API configuration resolved from a named profile.""" @@ -26,7 +39,7 @@ class Config: api_token: str = "" def __post_init__(self) -> None: - resolved_region = normalize_sccfm_region(self.region) + resolved_region = _normalize_region(self.region) resolved_token = self.api_token # Use object.__setattr__ since dataclass is frozen @@ -86,6 +99,8 @@ def create_config(module: "AnsibleModule") -> Config: Note: On validation error, calls module.fail_json() and does not return. """ + ensure_required_dependencies(module) + try: profile = module.params.get("profile") or "default" raw_path = module.params.get("config_path") diff --git a/sccfm-ansible/plugins/module_utils/dependencies.py b/sccfm-ansible/plugins/module_utils/dependencies.py new file mode 100644 index 00000000..803c4f78 --- /dev/null +++ b/sccfm-ansible/plugins/module_utils/dependencies.py @@ -0,0 +1,39 @@ +# 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]] = [] +_PAIRED_DEVKIT_REQUIREMENT = "cisco-sccfm-devkit==0.39.0" + + +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 + + import_traceback = _IMPORT_ERRORS[0][1] + module.fail_json( + msg=missing_required_lib( + _PAIRED_DEVKIT_REQUIREMENT, + reason="by this cisco.sccfm collection release", + ), + 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..39ec15d1 100644 --- a/sccfm-ansible/plugins/module_utils/operations.py +++ b/sccfm-ansible/plugins/module_utils/operations.py @@ -8,14 +8,20 @@ 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 T = TypeVar("T") +T_co = TypeVar("T_co", covariant=True) class HasUid(Protocol): @@ -31,10 +37,16 @@ class DeleteFunction(Protocol): def __call__(self, *, uid: str | None, name: str | None) -> str: ... -class ResolveFunction(Protocol): - """Protocol for functions that check existence (returns object or None).""" +class LookupByName(Protocol[T_co]): + """Protocol for object lookups with a keyword-only name.""" + + def __call__(self, *, name: str) -> T_co | None: ... + + +class LookupByUid(Protocol[T_co]): + """Protocol for object lookups with a keyword-only UID.""" - def __call__(self, uid: str) -> HasUid | None: ... + def __call__(self, *, uid: str) -> T_co | None: ... def fetch_object_by_identifier( @@ -42,7 +54,7 @@ def fetch_object_by_identifier( uid: str | None, name: str | None, list_fn: Callable[[str, int], Any], - get_by_name_fn: Callable[[str], T | None], + get_by_name_fn: LookupByName[T], entity_name: str, ) -> T: """Fetch an object by UID or name. @@ -68,7 +80,7 @@ def fetch_object_by_identifier( return result.items[0] if name: - obj = get_by_name_fn(name) + obj = get_by_name_fn(name=name) if not obj: raise NotFoundError(f"{entity_name} with name '{name}' not found.") return obj @@ -83,8 +95,8 @@ def run_delete_with_idempotency( uid: str | None, name: str | None, entity_name: str, - get_by_uid_fn: Callable[[str], HasUid | None] | None = None, - get_by_name_fn: Callable[[str], HasUid | None] | None = None, + get_by_uid_fn: LookupByUid[HasUid] | None = None, + get_by_name_fn: LookupByName[HasUid] | None = None, ) -> None: """Run a delete operation with idempotency and check_mode handling. @@ -147,16 +159,16 @@ def _handle_delete_check_mode( name: str | None, entity_name: str, identifier: str | None, - get_by_uid_fn: Callable[[str], HasUid | None] | None, - get_by_name_fn: Callable[[str], HasUid | None] | None, + get_by_uid_fn: LookupByUid[HasUid] | None, + get_by_name_fn: LookupByName[HasUid] | None, ) -> None: """Report what a delete would do without performing it.""" entity: HasUid | None = None try: if uid and get_by_uid_fn: - entity = get_by_uid_fn(uid) + entity = get_by_uid_fn(uid=uid) elif name and get_by_name_fn: - entity = get_by_name_fn(name) + entity = get_by_name_fn(name=name) except NotFoundError: entity = None except ApiException as e: 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 e7ea2758..3ea4a10a 100644 --- a/sccfm-ansible/plugins/modules/add_asa_shun.py +++ b/sccfm-ansible/plugins/modules/add_asa_shun.py @@ -4,16 +4,6 @@ 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 @@ -141,7 +131,7 @@ required: false type: path author: - - Cisco SCCFM Team + - Cisco SCCFM Team (@CiscoDevNet) """ EXAMPLES = r""" @@ -213,6 +203,32 @@ """ +from typing import Any, cast + +from ansible.module_utils.basic import AnsibleModule + +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) + ApiException = RuntimeError + NotFoundError = LookupError + FtdConfigureManagerError = ValueError + + +from ..module_utils.config import base_argument_spec, create_config + + 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 195bc16c..7526d475 100644 --- a/sccfm-ansible/plugins/modules/add_network_group_members.py +++ b/sccfm-ansible/plugins/modules/add_network_group_members.py @@ -4,24 +4,6 @@ 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 @@ -59,7 +41,7 @@ required: false type: path author: - - Cisco SCCFM Team + - Cisco SCCFM Team (@CiscoDevNet) """ EXAMPLES = r""" @@ -129,6 +111,35 @@ """ +from typing import Any + +from ansible.module_utils.basic import AnsibleModule + +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) + ApiException = RuntimeError + NotFoundError = LookupError + FtdConfigureManagerError = ValueError + + +from ..module_utils.config import ( + Config, + base_argument_spec, + create_config, + identifier_argument_spec, +) + + 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 b3a31313..c05dd1ed 100644 --- a/sccfm-ansible/plugins/modules/add_object_override.py +++ b/sccfm-ansible/plugins/modules/add_object_override.py @@ -4,16 +4,6 @@ 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 @@ -51,7 +41,7 @@ required: false type: path author: - - Cisco SCCFM Team + - Cisco SCCFM Team (@CiscoDevNet) """ EXAMPLES = r""" @@ -114,6 +104,27 @@ """ +from typing import Any + +from ansible.module_utils.basic import AnsibleModule + +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) + ApiException = RuntimeError + NotFoundError = LookupError + FtdConfigureManagerError = ValueError + + +from ..module_utils.config import Config, base_argument_spec, create_config + + 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 7cef603c..0ef3e213 100644 --- a/sccfm-ansible/plugins/modules/apply_object_override_as_default.py +++ b/sccfm-ansible/plugins/modules/apply_object_override_as_default.py @@ -4,16 +4,6 @@ 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 @@ -43,7 +33,7 @@ required: false type: path author: - - Cisco SCCFM Team + - Cisco SCCFM Team (@CiscoDevNet) """ EXAMPLES = r""" @@ -94,6 +84,27 @@ """ +from typing import Any + +from ansible.module_utils.basic import AnsibleModule + +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) + ApiException = RuntimeError + NotFoundError = LookupError + FtdConfigureManagerError = ValueError + + +from ..module_utils.config import Config, base_argument_spec, create_config + + 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 cf5ecad9..2a3d795a 100644 --- a/sccfm-ansible/plugins/modules/asa_ha_check.py +++ b/sccfm-ansible/plugins/modules/asa_ha_check.py @@ -4,21 +4,6 @@ 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 @@ -71,7 +56,7 @@ required: false type: path author: - - Cisco SCCFM Team + - Cisco SCCFM Team (@CiscoDevNet) """ EXAMPLES = r""" @@ -169,6 +154,32 @@ """ +from typing import Any, cast + +from ansible.module_utils.basic import AnsibleModule + +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) + ApiException = RuntimeError + NotFoundError = LookupError + FtdConfigureManagerError = ValueError + + +from ..module_utils.config import base_argument_spec, create_config + + 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 1fc0aa0a..62b3eef1 100644 --- a/sccfm-ansible/plugins/modules/change_asa_boot_image.py +++ b/sccfm-ansible/plugins/modules/change_asa_boot_image.py @@ -4,25 +4,6 @@ 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 @@ -81,7 +62,7 @@ required: false type: path author: - - Cisco SCCFM Team + - Cisco SCCFM Team (@CiscoDevNet) """ EXAMPLES = r""" @@ -156,6 +137,36 @@ """ +from typing import Any, cast + +from ansible.module_utils.basic import AnsibleModule + +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) + ApiException = RuntimeError + NotFoundError = LookupError + FtdConfigureManagerError = ValueError + + +from ..module_utils.config import Config, base_argument_spec, create_config + + 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 4a95e709..e83d4dc5 100644 --- a/sccfm-ansible/plugins/modules/change_asa_local_password.py +++ b/sccfm-ansible/plugins/modules/change_asa_local_password.py @@ -4,18 +4,6 @@ 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 @@ -55,7 +43,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). @@ -80,7 +67,7 @@ required: false type: path author: - - Cisco SCCFM Team + - Cisco SCCFM Team (@CiscoDevNet) """ EXAMPLES = r""" @@ -147,6 +134,29 @@ """ +from typing import Any, cast + +from ansible.module_utils.basic import AnsibleModule + +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) + ApiException = RuntimeError + NotFoundError = LookupError + FtdConfigureManagerError = ValueError + + +from ..module_utils.config import Config, base_argument_spec, create_config + + 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 5fac20d1..3b1dbe8c 100644 --- a/sccfm-ansible/plugins/modules/clear_asa_shun.py +++ b/sccfm-ansible/plugins/modules/clear_asa_shun.py @@ -4,15 +4,6 @@ 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 @@ -64,7 +55,7 @@ required: false type: path author: - - Cisco SCCFM Team + - Cisco SCCFM Team (@CiscoDevNet) """ EXAMPLES = r""" @@ -115,6 +106,31 @@ """ +from typing import Any, cast + +from ansible.module_utils.basic import AnsibleModule + +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) + ApiException = RuntimeError + NotFoundError = LookupError + FtdConfigureManagerError = ValueError + + +from ..module_utils.config import base_argument_spec, create_config + + 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 82431365..3fa0ef8a 100644 --- a/sccfm-ansible/plugins/modules/configure_manager.py +++ b/sccfm-ansible/plugins/modules/configure_manager.py @@ -4,16 +4,6 @@ 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 +39,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 +59,13 @@ - 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 + - Cisco SCCFM Team (@CiscoDevNet) """ EXAMPLES = r""" @@ -148,6 +134,25 @@ """ +from typing import Any + +from ansible.module_utils.basic import AnsibleModule, env_fallback + +from ..module_utils.dependencies import record_import_error + +try: + from cisco_sccfm_core.services.inventory import ( + FtdConfigureManagerError, + FtdConfigureManagerService, + parse_jump_host, + ) +except ImportError as exc: + record_import_error(exc) + ApiException = RuntimeError + NotFoundError = LookupError + FtdConfigureManagerError = RuntimeError + + def build_argument_spec() -> dict[str, dict[str, Any]]: return { "ftd_host": {"type": "str", "required": True}, diff --git a/sccfm-ansible/plugins/modules/create_access_rule.py b/sccfm-ansible/plugins/modules/create_access_rule.py index 99f86908..fdfbcd6c 100644 --- a/sccfm-ansible/plugins/modules/create_access_rule.py +++ b/sccfm-ansible/plugins/modules/create_access_rule.py @@ -4,16 +4,6 @@ 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 @@ -88,7 +78,7 @@ required: false type: path author: - - Cisco SCCFM Team + - Cisco SCCFM Team (@CiscoDevNet) """ EXAMPLES = r""" @@ -173,6 +163,27 @@ """ +from typing import Any + +from ansible.module_utils.basic import AnsibleModule + +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) + ApiException = RuntimeError + NotFoundError = LookupError + FtdConfigureManagerError = ValueError + + +from ..module_utils.config import Config, base_argument_spec, create_config + + 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 6a4c5b00..ee0acc77 100644 --- a/sccfm-ansible/plugins/modules/create_network_group.py +++ b/sccfm-ansible/plugins/modules/create_network_group.py @@ -4,16 +4,6 @@ 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 @@ -75,7 +65,7 @@ required: false type: path author: - - Cisco SCCFM Team + - Cisco SCCFM Team (@CiscoDevNet) """ EXAMPLES = r""" @@ -154,6 +144,27 @@ """ +from typing import Any + +from ansible.module_utils.basic import AnsibleModule + +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) + ApiException = RuntimeError + NotFoundError = LookupError + FtdConfigureManagerError = ValueError + + +from ..module_utils.config import Config, base_argument_spec, create_config + + def build_argument_spec() -> dict[str, dict[str, Any]]: return { "name": {"type": "str", "required": True}, @@ -180,7 +191,7 @@ def run_module() -> None: try: service = NetworkGroupService(config=config) - existing = service.get_network_group_by_name(params["name"]) + existing = service.get_network_group_by_name(name=params["name"]) if existing: module.exit_json( changed=False, diff --git a/sccfm-ansible/plugins/modules/create_network_object.py b/sccfm-ansible/plugins/modules/create_network_object.py index d664eb48..f69b5102 100644 --- a/sccfm-ansible/plugins/modules/create_network_object.py +++ b/sccfm-ansible/plugins/modules/create_network_object.py @@ -4,16 +4,6 @@ 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 @@ -61,7 +51,7 @@ required: false type: path author: - - Cisco SCCFM Team + - Cisco SCCFM Team (@CiscoDevNet) """ EXAMPLES = r""" @@ -134,6 +124,27 @@ """ +from typing import Any + +from ansible.module_utils.basic import AnsibleModule + +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) + ApiException = RuntimeError + NotFoundError = LookupError + FtdConfigureManagerError = ValueError + + +from ..module_utils.config import Config, base_argument_spec, create_config + + def build_argument_spec() -> dict[str, dict[str, Any]]: return { "name": {"type": "str", "required": True}, @@ -160,7 +171,7 @@ def run_module() -> None: try: service = NetworkObjectService(config=config) - existing = service.get_network_object_by_name(name) + existing = service.get_network_object_by_name(name=name) if existing: module.exit_json( diff --git a/sccfm-ansible/plugins/modules/delete_access_rule.py b/sccfm-ansible/plugins/modules/delete_access_rule.py index cb3086f1..dae23418 100644 --- a/sccfm-ansible/plugins/modules/delete_access_rule.py +++ b/sccfm-ansible/plugins/modules/delete_access_rule.py @@ -4,16 +4,6 @@ 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 @@ -37,7 +27,7 @@ required: false type: path author: - - Cisco SCCFM Team + - Cisco SCCFM Team (@CiscoDevNet) """ EXAMPLES = r""" @@ -69,6 +59,27 @@ """ +from typing import Any + +from ansible.module_utils.basic import AnsibleModule + +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) + ApiException = RuntimeError + NotFoundError = LookupError + FtdConfigureManagerError = ValueError + + +from ..module_utils.config import Config, base_argument_spec, create_config + + 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 574fe742..940b07f0 100644 --- a/sccfm-ansible/plugins/modules/delete_network_group.py +++ b/sccfm-ansible/plugins/modules/delete_network_group.py @@ -4,20 +4,6 @@ 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 @@ -51,7 +37,7 @@ - Network groups are filtered by objectType to avoid accidentally matching network objects with the same name. author: - - Cisco SCCFM Team + - Cisco SCCFM Team (@CiscoDevNet) """ EXAMPLES = r""" @@ -97,6 +83,30 @@ """ +from typing import Any + +from ansible.module_utils.basic import AnsibleModule + +from ..module_utils.dependencies import record_import_error + +try: + from cisco_sccfm_core.services.object_management import NetworkGroupService +except ImportError as exc: + record_import_error(exc) + ApiException = RuntimeError + NotFoundError = LookupError + FtdConfigureManagerError = ValueError + + +from ..module_utils.config import ( + Config, + base_argument_spec, + create_config, + identifier_argument_spec, +) +from ..module_utils.operations import run_delete_with_idempotency + + 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 44874d20..8ac7e8d0 100644 --- a/sccfm-ansible/plugins/modules/delete_network_object.py +++ b/sccfm-ansible/plugins/modules/delete_network_object.py @@ -4,20 +4,6 @@ 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 @@ -48,7 +34,7 @@ - When using C(name), the module searches for the object and resolves it to a UID before deletion. author: - - Cisco SCCFM Team + - Cisco SCCFM Team (@CiscoDevNet) """ EXAMPLES = r""" @@ -95,6 +81,30 @@ """ +from typing import Any + +from ansible.module_utils.basic import AnsibleModule + +from ..module_utils.dependencies import record_import_error + +try: + from cisco_sccfm_core.services.object_management import NetworkObjectService +except ImportError as exc: + record_import_error(exc) + ApiException = RuntimeError + NotFoundError = LookupError + FtdConfigureManagerError = ValueError + + +from ..module_utils.config import ( + Config, + base_argument_spec, + create_config, + identifier_argument_spec, +) +from ..module_utils.operations import run_delete_with_idempotency + + 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 c5424a07..187cf968 100644 --- a/sccfm-ansible/plugins/modules/delete_object_override.py +++ b/sccfm-ansible/plugins/modules/delete_object_override.py @@ -4,16 +4,6 @@ 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 @@ -43,7 +33,7 @@ required: false type: path author: - - Cisco SCCFM Team + - Cisco SCCFM Team (@CiscoDevNet) """ EXAMPLES = r""" @@ -94,6 +84,27 @@ """ +from typing import Any + +from ansible.module_utils.basic import AnsibleModule + +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) + ApiException = RuntimeError + NotFoundError = LookupError + FtdConfigureManagerError = ValueError + + +from ..module_utils.config import Config, base_argument_spec, create_config + + 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 9d5526fe..9a5dbdfc 100644 --- a/sccfm-ansible/plugins/modules/deploy_cdfmc_ftd.py +++ b/sccfm-ansible/plugins/modules/deploy_cdfmc_ftd.py @@ -4,20 +4,6 @@ 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 @@ -93,7 +79,7 @@ required: false type: path author: - - Cisco SCCFM Team + - Cisco SCCFM Team (@CiscoDevNet) """ EXAMPLES = r""" @@ -147,6 +133,32 @@ """ +from typing import Any, cast + +from ansible.module_utils.basic import AnsibleModule + +from ..module_utils.dependencies import record_import_error + +try: + 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 +except ImportError as exc: + record_import_error(exc) + ApiException = RuntimeError + NotFoundError = LookupError + FtdConfigureManagerError = ValueError + DEFAULT_TRANSACTION_TIMEOUT_SEC = 3600 + + +from ..module_utils.config import Config, base_argument_spec, create_config + + def build_argument_spec() -> dict[str, dict[str, Any]]: return { "query": {"type": "str", "required": False}, diff --git a/sccfm-ansible/plugins/modules/edit_object_override.py b/sccfm-ansible/plugins/modules/edit_object_override.py index 18b0b2a0..59cd6079 100644 --- a/sccfm-ansible/plugins/modules/edit_object_override.py +++ b/sccfm-ansible/plugins/modules/edit_object_override.py @@ -4,16 +4,6 @@ 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 @@ -51,7 +41,7 @@ required: false type: path author: - - Cisco SCCFM Team + - Cisco SCCFM Team (@CiscoDevNet) """ EXAMPLES = r""" @@ -104,6 +94,27 @@ """ +from typing import Any + +from ansible.module_utils.basic import AnsibleModule + +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) + ApiException = RuntimeError + NotFoundError = LookupError + FtdConfigureManagerError = ValueError + + +from ..module_utils.config import Config, base_argument_spec, create_config + + 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 67003f90..10bca204 100644 --- a/sccfm-ansible/plugins/modules/execute_asa_cli.py +++ b/sccfm-ansible/plugins/modules/execute_asa_cli.py @@ -4,21 +4,6 @@ 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 @@ -83,7 +68,7 @@ required: false type: path author: - - Cisco SCCFM Team + - Cisco SCCFM Team (@CiscoDevNet) """ EXAMPLES = r""" @@ -171,6 +156,32 @@ """ +from typing import Any, cast + +from ansible.module_utils.basic import AnsibleModule + +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) + ApiException = RuntimeError + NotFoundError = LookupError + FtdConfigureManagerError = ValueError + + +from ..module_utils.config import Config, base_argument_spec, create_config + + 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 810641d6..474a30c8 100644 --- a/sccfm-ansible/plugins/modules/execute_ftd_cli.py +++ b/sccfm-ansible/plugins/modules/execute_ftd_cli.py @@ -4,21 +4,6 @@ from __future__ import annotations -from typing import Any - -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 @@ -84,7 +69,7 @@ required: false type: path author: - - Cisco SCCFM Team + - Cisco SCCFM Team (@CiscoDevNet) """ EXAMPLES = r""" @@ -157,6 +142,32 @@ """ +from typing import Any + +from ansible.module_utils.basic import AnsibleModule + +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) + ApiException = RuntimeError + NotFoundError = LookupError + FtdConfigureManagerError = ValueError + + +from ..module_utils.config import Config, base_argument_spec, create_config + + 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 5432b77d..3455ef77 100644 --- a/sccfm-ansible/plugins/modules/get_access_group.py +++ b/sccfm-ansible/plugins/modules/get_access_group.py @@ -4,16 +4,6 @@ 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 @@ -35,7 +25,7 @@ required: false type: path author: - - Cisco SCCFM Team + - Cisco SCCFM Team (@CiscoDevNet) """ EXAMPLES = r""" @@ -84,6 +74,27 @@ """ +from typing import Any + +from ansible.module_utils.basic import AnsibleModule + +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) + ApiException = RuntimeError + NotFoundError = LookupError + FtdConfigureManagerError = ValueError + + +from ..module_utils.config import Config, base_argument_spec, create_config + + 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 6610375d..61df85b3 100644 --- a/sccfm-ansible/plugins/modules/get_access_rule.py +++ b/sccfm-ansible/plugins/modules/get_access_rule.py @@ -4,16 +4,6 @@ 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 @@ -35,7 +25,7 @@ required: false type: path author: - - Cisco SCCFM Team + - Cisco SCCFM Team (@CiscoDevNet) """ EXAMPLES = r""" @@ -101,6 +91,27 @@ """ +from typing import Any + +from ansible.module_utils.basic import AnsibleModule + +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) + ApiException = RuntimeError + NotFoundError = LookupError + FtdConfigureManagerError = ValueError + + +from ..module_utils.config import Config, base_argument_spec, create_config + + 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 959289c6..81c1abe6 100644 --- a/sccfm-ansible/plugins/modules/get_object.py +++ b/sccfm-ansible/plugins/modules/get_object.py @@ -4,16 +4,6 @@ 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 @@ -37,7 +27,7 @@ required: false type: path author: - - Cisco SCCFM Team + - Cisco SCCFM Team (@CiscoDevNet) """ EXAMPLES = r""" @@ -118,6 +108,27 @@ """ +from typing import Any + +from ansible.module_utils.basic import AnsibleModule + +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) + ApiException = RuntimeError + NotFoundError = LookupError + FtdConfigureManagerError = ValueError + + +from ..module_utils.config import Config, base_argument_spec, create_config + + 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 b9a03498..cb2ee21a 100644 --- a/sccfm-ansible/plugins/modules/list_access_groups.py +++ b/sccfm-ansible/plugins/modules/list_access_groups.py @@ -4,16 +4,6 @@ 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 @@ -48,7 +38,7 @@ required: false type: path author: - - Cisco SCCFM Team + - Cisco SCCFM Team (@CiscoDevNet) """ EXAMPLES = r""" @@ -106,6 +96,27 @@ """ +from typing import Any + +from ansible.module_utils.basic import AnsibleModule + +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) + ApiException = RuntimeError + NotFoundError = LookupError + FtdConfigureManagerError = ValueError + + +from ..module_utils.config import Config, base_argument_spec, create_config + + 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 9cde7c53..b144eb61 100644 --- a/sccfm-ansible/plugins/modules/list_access_rules.py +++ b/sccfm-ansible/plugins/modules/list_access_rules.py @@ -4,16 +4,6 @@ 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 @@ -48,7 +38,7 @@ required: false type: path author: - - Cisco SCCFM Team + - Cisco SCCFM Team (@CiscoDevNet) """ EXAMPLES = r""" @@ -118,6 +108,27 @@ """ +from typing import Any + +from ansible.module_utils.basic import AnsibleModule + +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) + ApiException = RuntimeError + NotFoundError = LookupError + FtdConfigureManagerError = ValueError + + +from ..module_utils.config import Config, base_argument_spec, create_config + + 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 a62b9c87..6072e1ce 100644 --- a/sccfm-ansible/plugins/modules/list_asa_boot_registry.py +++ b/sccfm-ansible/plugins/modules/list_asa_boot_registry.py @@ -4,22 +4,6 @@ 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 @@ -72,7 +56,7 @@ required: false type: path author: - - Cisco SCCFM Team + - Cisco SCCFM Team (@CiscoDevNet) """ EXAMPLES = r""" @@ -138,6 +122,33 @@ """ +from typing import Any, cast + +from ansible.module_utils.basic import AnsibleModule + +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) + ApiException = RuntimeError + NotFoundError = LookupError + FtdConfigureManagerError = ValueError + + +from ..module_utils.config import Config, base_argument_spec, create_config + + 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 01397277..1ebfd98f 100644 --- a/sccfm-ansible/plugins/modules/list_asa_compatible_versions.py +++ b/sccfm-ansible/plugins/modules/list_asa_compatible_versions.py @@ -4,18 +4,6 @@ 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 @@ -79,7 +67,7 @@ required: false type: path author: - - Cisco SCCFM Team + - Cisco SCCFM Team (@CiscoDevNet) """ EXAMPLES = r""" @@ -161,6 +149,29 @@ """ +from typing import Any, cast + +from ansible.module_utils.basic import AnsibleModule + +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) + ApiException = RuntimeError + NotFoundError = LookupError + FtdConfigureManagerError = ValueError + + +from ..module_utils.config import Config, base_argument_spec, create_config + + 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 b22a6074..c5d608fb 100644 --- a/sccfm-ansible/plugins/modules/list_asa_disk_files.py +++ b/sccfm-ansible/plugins/modules/list_asa_disk_files.py @@ -4,22 +4,6 @@ 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 @@ -72,7 +56,7 @@ required: false type: path author: - - Cisco SCCFM Team + - Cisco SCCFM Team (@CiscoDevNet) """ EXAMPLES = r""" @@ -137,6 +121,33 @@ """ +from typing import Any, cast + +from ansible.module_utils.basic import AnsibleModule + +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) + ApiException = RuntimeError + NotFoundError = LookupError + FtdConfigureManagerError = ValueError + + +from ..module_utils.config import Config, base_argument_spec, create_config + + 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 f68ca5ab..51b6d551 100644 --- a/sccfm-ansible/plugins/modules/list_asa_local_users.py +++ b/sccfm-ansible/plugins/modules/list_asa_local_users.py @@ -4,23 +4,6 @@ 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 @@ -64,7 +47,7 @@ required: false type: path author: - - Cisco SCCFM Team + - Cisco SCCFM Team (@CiscoDevNet) """ EXAMPLES = r""" @@ -124,6 +107,34 @@ """ +import json +from typing import Any, cast + +from ansible.module_utils.basic import AnsibleModule + +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) + ApiException = RuntimeError + NotFoundError = LookupError + FtdConfigureManagerError = ValueError + + +from ..module_utils.config import Config, base_argument_spec, create_config + + 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 05480e70..573362c9 100644 --- a/sccfm-ansible/plugins/modules/list_asa_not_on_version.py +++ b/sccfm-ansible/plugins/modules/list_asa_not_on_version.py @@ -4,16 +4,6 @@ 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 @@ -73,7 +63,7 @@ required: false type: path author: - - Cisco SCCFM Team + - Cisco SCCFM Team (@CiscoDevNet) """ EXAMPLES = r""" @@ -159,6 +149,27 @@ type: int """ + +import re +from typing import Any, cast + +from ansible.module_utils.basic import AnsibleModule + +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) + ApiException = RuntimeError + NotFoundError = LookupError + FtdConfigureManagerError = ValueError + + +from ..module_utils.config import base_argument_spec, create_config + _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 239f2f15..5670d7c9 100644 --- a/sccfm-ansible/plugins/modules/list_cdfmc_access_policies.py +++ b/sccfm-ansible/plugins/modules/list_cdfmc_access_policies.py @@ -4,16 +4,6 @@ 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 @@ -48,7 +38,7 @@ required: false type: path author: - - Cisco SCCFM Team + - Cisco SCCFM Team (@CiscoDevNet) """ EXAMPLES = r""" @@ -106,6 +96,29 @@ """ +from typing import Any + +from ansible.module_utils.basic import AnsibleModule + +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) + ApiException = RuntimeError + NotFoundError = LookupError + FtdConfigureManagerError = ValueError + + +from ..module_utils.config import base_argument_spec, create_config + + 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 40e02875..e7838bc0 100644 --- a/sccfm-ansible/plugins/modules/list_ftd_compatible_versions.py +++ b/sccfm-ansible/plugins/modules/list_ftd_compatible_versions.py @@ -4,18 +4,6 @@ from __future__ import annotations -from typing import Any, cast - -from ansible.module_utils.basic import AnsibleModule -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 - -from ..module_utils.config import base_argument_spec, create_config - DOCUMENTATION = r""" --- module: list_ftd_compatible_versions @@ -76,7 +64,7 @@ required: false type: path author: - - Cisco SCCFM Team + - Cisco SCCFM Team (@CiscoDevNet) """ EXAMPLES = r""" @@ -162,6 +150,29 @@ """ +from typing import Any, cast + +from ansible.module_utils.basic import AnsibleModule + +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) + ApiException = RuntimeError + NotFoundError = LookupError + FtdConfigureManagerError = ValueError + + +from ..module_utils.config import base_argument_spec, create_config + + 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 a1fa238a..c99ec4df 100644 --- a/sccfm-ansible/plugins/modules/list_ftd_not_on_version.py +++ b/sccfm-ansible/plugins/modules/list_ftd_not_on_version.py @@ -4,17 +4,6 @@ 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 FTD_DEVICE_TYPE_FILTER, InventoryService, SccApiError -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 @@ -86,7 +75,7 @@ required: false type: path author: - - Cisco SCCFM Team + - Cisco SCCFM Team (@CiscoDevNet) """ EXAMPLES = r""" @@ -184,6 +173,28 @@ type: str """ + +import re +from typing import Any, cast + +from ansible.module_utils.basic import AnsibleModule + +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) + ApiException = RuntimeError + NotFoundError = LookupError + FtdConfigureManagerError = ValueError + + +from ..module_utils.config import base_argument_spec, create_config + _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 4d7ffc34..d2fa7b44 100644 --- a/sccfm-ansible/plugins/modules/list_managers.py +++ b/sccfm-ansible/plugins/modules/list_managers.py @@ -4,16 +4,6 @@ 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 @@ -47,7 +37,7 @@ required: false type: path author: - - Cisco SCCFM Team + - Cisco SCCFM Team (@CiscoDevNet) """ EXAMPLES = r""" @@ -124,6 +114,27 @@ """ +from typing import Any + +from ansible.module_utils.basic import AnsibleModule + +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) + ApiException = RuntimeError + NotFoundError = LookupError + FtdConfigureManagerError = ValueError + + +from ..module_utils.config import base_argument_spec, create_config + + 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 5097547c..d5a6b2c2 100644 --- a/sccfm-ansible/plugins/modules/list_network_groups.py +++ b/sccfm-ansible/plugins/modules/list_network_groups.py @@ -4,19 +4,6 @@ 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 @@ -54,7 +41,7 @@ required: false type: path author: - - Cisco SCCFM Team + - Cisco SCCFM Team (@CiscoDevNet) """ EXAMPLES = r""" @@ -140,6 +127,30 @@ """ +from typing import Any + +from ansible.module_utils.basic import AnsibleModule + +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) + ApiException = RuntimeError + NotFoundError = LookupError + FtdConfigureManagerError = ValueError + + +from ..module_utils.config import Config, base_argument_spec, create_config + + 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 844541ea..53fab872 100644 --- a/sccfm-ansible/plugins/modules/list_network_objects.py +++ b/sccfm-ansible/plugins/modules/list_network_objects.py @@ -4,19 +4,6 @@ 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 @@ -54,7 +41,7 @@ required: false type: path author: - - Cisco SCCFM Team + - Cisco SCCFM Team (@CiscoDevNet) """ EXAMPLES = r""" @@ -137,6 +124,30 @@ """ +from typing import Any + +from ansible.module_utils.basic import AnsibleModule + +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) + ApiException = RuntimeError + NotFoundError = LookupError + FtdConfigureManagerError = ValueError + + +from ..module_utils.config import Config, base_argument_spec, create_config + + 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 210ae6e2..c72bb484 100644 --- a/sccfm-ansible/plugins/modules/onboard_asa.py +++ b/sccfm-ansible/plugins/modules/onboard_asa.py @@ -4,24 +4,6 @@ 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 +27,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 @@ -80,7 +61,7 @@ required: false type: path author: - - Cisco SCCFM Team + - Cisco SCCFM Team (@CiscoDevNet) """ EXAMPLES = r""" @@ -136,6 +117,35 @@ """ +from typing import Optional + +from ansible.module_utils.basic import AnsibleModule + +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) + ApiException = RuntimeError + NotFoundError = LookupError + FtdConfigureManagerError = ValueError + + +from ..module_utils.config import Config, base_argument_spec, create_config + + def build_argument_spec() -> dict[str, dict[str, str | bool | list[str]]]: return { "name": {"type": "str", "required": True}, @@ -145,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 3ae7e8b0..1b3a16f9 100644 --- a/sccfm-ansible/plugins/modules/onboard_cdfmc_ftd.py +++ b/sccfm-ansible/plugins/modules/onboard_cdfmc_ftd.py @@ -4,24 +4,6 @@ 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 @@ -80,7 +62,7 @@ required: false type: path author: - - Cisco SCCFM Team + - Cisco SCCFM Team (@CiscoDevNet) """ EXAMPLES = r""" @@ -88,7 +70,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: "00000000-0000-0000-0000-000000000000" licenses: - BASE profile: default @@ -97,7 +79,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: "00000000-0000-0000-0000-000000000000" licenses: - BASE - CARRIER @@ -108,7 +90,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: "00000000-0000-0000-0000-000000000000" licenses: - BASE ungrouped_labels: @@ -128,7 +110,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: "00000000-0000-0000-0000-000000000000" licenses: - BASE """ @@ -148,6 +130,34 @@ """ +from typing import Optional + +from ansible.module_utils.basic import AnsibleModule + +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) + ApiException = RuntimeError + NotFoundError = LookupError + FtdConfigureManagerError = ValueError + + +from ..module_utils.config import Config, base_argument_spec, create_config + _VALID_LICENSES = ["BASE", "CARRIER", "THREAT", "MALWARE", "URLFilter"] _VALID_PERFORMANCE_TIERS = ["FTDv5", "FTDv10", "FTDv20", "FTDv30", "FTDv50", "FTDv100", "FTDv"] @@ -156,7 +166,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", + "choices": _VALID_LICENSES, + "required": True, + }, "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 763f4c59..9c19e8a6 100644 --- a/sccfm-ansible/plugins/modules/onboard_cdfmc_ftd_ztp.py +++ b/sccfm-ansible/plugins/modules/onboard_cdfmc_ftd_ztp.py @@ -4,23 +4,6 @@ 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 +47,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 @@ -79,7 +61,7 @@ required: false type: path author: - - Cisco SCCFM Team + - Cisco SCCFM Team (@CiscoDevNet) """ EXAMPLES = r""" @@ -90,7 +72,7 @@ serial_number: "FTD1234567890" licenses: - BASE - fmc_access_policy_uid: "7131daad-e813-4b8f-8f42-be1e241e8cdb" + fmc_access_policy_uid: "00000000-0000-0000-0000-000000000000" profile: default # Example 2: Onboard with initial password and device group @@ -101,7 +83,7 @@ licenses: - BASE - CARRIER - fmc_access_policy_uid: "7131daad-e813-4b8f-8f42-be1e241e8cdb" + fmc_access_policy_uid: "00000000-0000-0000-0000-000000000000" admin_password: "{{ ftd_admin_password }}" device_group_uid: "abcd1234-0000-0000-0000-000000000001" @@ -119,7 +101,7 @@ serial_number: "FTD1234567890" licenses: - BASE - fmc_access_policy_uid: "7131daad-e813-4b8f-8f42-be1e241e8cdb" + fmc_access_policy_uid: "00000000-0000-0000-0000-000000000000" """ RETURN = r""" @@ -131,6 +113,34 @@ type: str """ + +from typing import Optional + +from ansible.module_utils.basic import AnsibleModule + +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) + ApiException = RuntimeError + NotFoundError = LookupError + FtdConfigureManagerError = ValueError + + +from ..module_utils.config import Config, base_argument_spec, create_config + _VALID_LICENSES = ["BASE", "CARRIER", "THREAT", "MALWARE", "URLFilter"] @@ -138,7 +148,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", + "choices": _VALID_LICENSES, + "required": True, + }, "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 0954c839..46ee2790 100644 --- a/sccfm-ansible/plugins/modules/register_cdfmc_ftd.py +++ b/sccfm-ansible/plugins/modules/register_cdfmc_ftd.py @@ -4,16 +4,6 @@ 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 @@ -44,7 +34,7 @@ required: false type: path author: - - Cisco SCCFM Team + - Cisco SCCFM Team (@CiscoDevNet) """ EXAMPLES = r""" @@ -69,6 +59,27 @@ """ +from typing import Any + +from ansible.module_utils.basic import AnsibleModule + +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) + ApiException = RuntimeError + NotFoundError = LookupError + FtdConfigureManagerError = ValueError + + +from ..module_utils.config import Config, base_argument_spec, create_config + + 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 b72e2298..4250adcf 100644 --- a/sccfm-ansible/plugins/modules/remove_asa_shun.py +++ b/sccfm-ansible/plugins/modules/remove_asa_shun.py @@ -4,15 +4,6 @@ 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 @@ -81,7 +72,7 @@ required: false type: path author: - - Cisco SCCFM Team + - Cisco SCCFM Team (@CiscoDevNet) """ EXAMPLES = r""" @@ -145,6 +136,31 @@ """ +from typing import Any, cast + +from ansible.module_utils.basic import AnsibleModule + +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) + ApiException = RuntimeError + NotFoundError = LookupError + FtdConfigureManagerError = ValueError + + +from ..module_utils.config import base_argument_spec, create_config + + 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 ba96d09c..4592331e 100644 --- a/sccfm-ansible/plugins/modules/remove_network_group_members.py +++ b/sccfm-ansible/plugins/modules/remove_network_group_members.py @@ -4,24 +4,6 @@ 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 @@ -59,7 +41,7 @@ required: false type: path author: - - Cisco SCCFM Team + - Cisco SCCFM Team (@CiscoDevNet) """ EXAMPLES = r""" @@ -129,6 +111,35 @@ """ +from typing import Any + +from ansible.module_utils.basic import AnsibleModule + +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) + ApiException = RuntimeError + NotFoundError = LookupError + FtdConfigureManagerError = ValueError + + +from ..module_utils.config import ( + Config, + base_argument_spec, + create_config, + identifier_argument_spec, +) + + 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 6c586017..eff9cdce 100644 --- a/sccfm-ansible/plugins/modules/show_asa_shun.py +++ b/sccfm-ansible/plugins/modules/show_asa_shun.py @@ -4,16 +4,6 @@ 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 @@ -74,7 +64,7 @@ required: false type: path author: - - Cisco SCCFM Team + - Cisco SCCFM Team (@CiscoDevNet) """ EXAMPLES = r""" @@ -158,6 +148,32 @@ """ +from typing import Any, cast + +from ansible.module_utils.basic import AnsibleModule + +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) + ApiException = RuntimeError + NotFoundError = LookupError + FtdConfigureManagerError = ValueError + + +from ..module_utils.config import base_argument_spec, create_config + + 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 6d47e134..faf5a298 100644 --- a/sccfm-ansible/plugins/modules/tests/conftest.py +++ b/sccfm-ansible/plugins/modules/tests/conftest.py @@ -35,14 +35,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") @@ -60,31 +53,34 @@ 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 - @pytest.fixture(autouse=True) def configured_sccfm_profile(monkeypatch: MonkeyPatch, tmp_path: Path) -> None: 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..f65b6845 --- /dev/null +++ b/sccfm-ansible/plugins/modules/tests/test_inventory_plugin_security.py @@ -0,0 +1,195 @@ +# 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 ClassVar, cast + +import pytest +import yaml +from ansible.errors import AnsibleParserError +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 + +from cisco_sccfm_core.models.profile import Profile +from cisco_sccfm_core.services.profile_service import ProfileService + +_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" + + +@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_profile(config_path: Path) -> None: + ProfileService(path=config_path).save( + Profile(profile="default", region="us", api_token=_SYNTHETIC_TOKEN) + ) + + +def _write_inventory_config(path: Path, config_path: Path) -> None: + path.write_text( + yaml.safe_dump( + { + "plugin": "cisco.sccfm.sccfm", + "profile": "default", + "config_path": str(config_path), + "group": "sccfm", + "group_by_device_type": False, + }, + sort_keys=False, + ), + encoding="utf-8", + ) + + +def _serialized_inventory(inventory: InventoryData) -> dict[str, object]: + 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}, + } + + +def test_inventory_profile_token_is_consumed_but_never_exported( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + config_path = tmp_path / "profiles.json" + inventory_path = tmp_path / "inventory.sccfm.yml" + _write_profile(config_path) + _write_inventory_config(inventory_path, config_path) + + _RecordingInventoryLoader.captured_config = None + monkeypatch.setattr( + inventory_plugin.ProfileService, + "load", + lambda _service, profile: Profile( + profile=profile, + region="us", + api_token=_SYNTHETIC_TOKEN, + ), + ) + 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) + serialized = json.dumps(payload, sort_keys=True) + assert "sccfm_api_token" not in serialized + assert _SYNTHETIC_TOKEN not in serialized + + group_vars = cast(dict[str, object], cast(dict[str, object], payload["sccfm"])["vars"]) + assert group_vars == {"sccfm_profile": "default", "sccfm_region": "us"} + + +def test_inventory_reports_missing_devkit_dependency( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + inventory_path = tmp_path / "inventory.sccfm.yml" + _write_inventory_config(inventory_path, tmp_path / "profiles.json") + monkeypatch.setattr( + inventory_plugin, + "_DEPENDENCY_IMPORT_ERROR", + ImportError("cisco_sccfm_core is unavailable"), + ) + + with pytest.raises(AnsibleParserError, match="cisco-sccfm-devkit must be installed"): + inventory_plugin.InventoryModule().parse( + InventoryData(), + DataLoader(), + str(inventory_path), + ) + + +def test_packaged_examples_use_profiles_without_sccfm_api_tokens() -> None: + checked_playbooks = 0 + offenders: dict[str, object] = {} + + for path in sorted(_EXAMPLES_DIR.glob("*.yml")): + content = path.read_text(encoding="utf-8") + if "SCCFM_API_TOKEN" in content or "vault_sccfm_api_token" in content: + offenders[path.name] = "contains legacy SCCFM API token authentication" + continue + + playbook = yaml.safe_load(content) + if not isinstance(playbook, list): + continue + for play_number, play in enumerate(playbook, start=1): + if not isinstance(play, dict) or "module_defaults" not in play: + continue + checked_playbooks += 1 + actual = play["module_defaults"].get(_SCCFM_ACTION_GROUP) + if actual != {"profile": "default"}: + offenders[f"{path.name} play {play_number}"] = actual + + assert checked_playbooks + assert offenders == {} + + +def test_e2e_playbooks_use_profiles_without_sccfm_api_tokens() -> None: + offenders: dict[str, object] = {} + checked_playbooks = 0 + + for path in sorted(_E2E_DIR.glob("*/playbooks/*.yml")): + content = path.read_text(encoding="utf-8") + relative_path = path.relative_to(_E2E_DIR).as_posix() + if "SCCFM_API_TOKEN" in content or "vault_sccfm_api_token" in content: + offenders[relative_path] = "contains legacy SCCFM API token authentication" + continue + + playbook = yaml.safe_load(content) + if not isinstance(playbook, list): + offenders[relative_path] = "playbook is not a list" + continue + for play_number, play in enumerate(playbook, start=1): + checked_playbooks += 1 + module_defaults = play.get("module_defaults", {}) + actual = module_defaults.get(_SCCFM_ACTION_GROUP) + if not module_defaults and relative_path in { + "asa/playbooks/remove_vasa.yml", + "ftd/playbooks/cleanup.yml", + }: + continue + if actual != {"profile": "default"}: + offenders[f"{relative_path} play {play_number}"] = actual + + assert checked_playbooks + assert offenders == {} 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 15186d56..c98c4aec 100644 --- a/sccfm-ansible/plugins/modules/tests/test_module_utils_config.py +++ b/sccfm-ansible/plugins/modules/tests/test_module_utils_config.py @@ -5,16 +5,29 @@ from __future__ import annotations from pathlib import Path +from typing import Any from unittest.mock import MagicMock import pytest from _pytest.monkeypatch import MonkeyPatch from config import Config, base_argument_spec, create_config +from plugins.module_utils import dependencies from cisco_sccfm_core.models.profile import Profile from cisco_sccfm_core.services.profile_service import ProfileService +class _ModuleFailure(RuntimeError): + 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: config = Config(region="AUS", api_token="token-xyz") @@ -26,6 +39,24 @@ def test_config_should_reject_unknown_regions() -> None: Config(region="mars", api_token="token-xyz") +def test_missing_dependency_uses_actionable_ansible_failure( + monkeypatch: 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 dependencies._PAIRED_DEVKIT_REQUIREMENT in payload["msg"] + assert "cisco_sccfm_core" not in payload["msg"] + assert payload["exception"] == "synthetic import traceback" + + def test_base_argument_spec_should_only_expose_canonical_profile_options() -> None: spec = base_argument_spec() diff --git a/sccfm-ansible/plugins/modules/tests/test_module_utils_operations.py b/sccfm-ansible/plugins/modules/tests/test_module_utils_operations.py new file mode 100644 index 00000000..16dbafb9 --- /dev/null +++ b/sccfm-ansible/plugins/modules/tests/test_module_utils_operations.py @@ -0,0 +1,83 @@ +# Copyright 2026 Cisco Systems, Inc. and its affiliates +# +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any +from unittest.mock import MagicMock + +import pytest +from operations import fetch_object_by_identifier, run_delete_with_idempotency + + +@dataclass(frozen=True) +class _Entity: + uid: str + + +def test_fetch_object_uses_keyword_only_name_lookup() -> None: + requested_names: list[str] = [] + + def list_objects(_query: str, _limit: int) -> Any: + raise AssertionError("Unexpected list lookup") + + def lookup_by_name(*, name: str) -> _Entity | None: + requested_names.append(name) + return _Entity(uid="object-uid") + + result = fetch_object_by_identifier( + uid=None, + name="object-name", + list_fn=list_objects, + get_by_name_fn=lookup_by_name, + entity_name="Network object", + ) + + assert result.uid == "object-uid" + assert requested_names == ["object-name"] + + +@pytest.mark.parametrize( + ("uid", "name"), + [("object-uid", None), (None, "object-name")], +) +def test_delete_check_mode_uses_keyword_only_lookups( + uid: str | None, + name: str | None, +) -> None: + module = MagicMock() + module.check_mode = True + module.exit_json.side_effect = SystemExit(0) + module.fail_json.side_effect = AssertionError("Lookup failed") + entity = _Entity(uid="object-uid") + + def delete_object(*, uid: str | None, name: str | None) -> str: + raise AssertionError(f"Unexpected delete: uid={uid}, name={name}") + + def lookup_by_uid(*, uid: str) -> _Entity | None: + assert uid == "object-uid" + return entity + + def lookup_by_name(*, name: str) -> _Entity | None: + assert name == "object-name" + return entity + + with pytest.raises(SystemExit): + run_delete_with_idempotency( + module, + delete_fn=delete_object, + uid=uid, + name=name, + entity_name="Network object", + get_by_uid_fn=lookup_by_uid, + get_by_name_fn=lookup_by_name, + ) + + module.exit_json.assert_called_once_with( + changed=True, + msg=f"Would delete Network object '{uid or name}'.", + deleted_uid="object-uid", + ) + module.fail_json.assert_not_called() diff --git a/sccfm-ansible/plugins/modules/tests/test_update_network_group.py b/sccfm-ansible/plugins/modules/tests/test_update_network_group.py index c0c71cc6..53d2bb1d 100644 --- a/sccfm-ansible/plugins/modules/tests/test_update_network_group.py +++ b/sccfm-ansible/plugins/modules/tests/test_update_network_group.py @@ -121,6 +121,7 @@ def test_should_update_when_referenced_objects_differ( call_kwargs = mock_module_instance.exit_json.call_args[1] assert call_kwargs["changed"] is True assert "Successfully updated" in call_kwargs["msg"] + mock_service.get_network_group_by_name.assert_called_once_with(name="test-network-group") @patch("plugins.modules.update_network_group.Config") diff --git a/sccfm-ansible/plugins/modules/tests/test_update_network_object.py b/sccfm-ansible/plugins/modules/tests/test_update_network_object.py index 244d85a0..2ffc8388 100644 --- a/sccfm-ansible/plugins/modules/tests/test_update_network_object.py +++ b/sccfm-ansible/plugins/modules/tests/test_update_network_object.py @@ -118,6 +118,7 @@ def test_should_update_when_value_differs( assert call_kwargs["changed"] is True assert call_kwargs["network_object"]["literal"] == "192.168.1.0/24" assert "Successfully updated" in call_kwargs["msg"] + mock_service.get_network_object_by_name.assert_called_once_with(name="test-network-object") @patch("plugins.modules.update_network_object.Config") diff --git a/sccfm-ansible/plugins/modules/trigger_asa_upgrade.py b/sccfm-ansible/plugins/modules/trigger_asa_upgrade.py index ccb8e2e4..bdfde74e 100644 --- a/sccfm-ansible/plugins/modules/trigger_asa_upgrade.py +++ b/sccfm-ansible/plugins/modules/trigger_asa_upgrade.py @@ -4,25 +4,6 @@ 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 @@ -126,7 +107,7 @@ required: false type: path author: - - Cisco SCCFM Team + - Cisco SCCFM Team (@CiscoDevNet) """ EXAMPLES = r""" @@ -190,6 +171,37 @@ """ +from typing import Any, cast + +from ansible.module_utils.basic import AnsibleModule + +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.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 +except ImportError as exc: + record_import_error(exc) + ApiException = RuntimeError + NotFoundError = LookupError + FtdConfigureManagerError = ValueError + DEFAULT_TRANSACTION_TIMEOUT_SEC = 3600 + + +from ..module_utils.config import Config, base_argument_spec, create_config + + def build_argument_spec() -> dict[str, dict[str, Any]]: return { "query": {"type": "str", "required": False}, diff --git a/sccfm-ansible/plugins/modules/trigger_ftd_upgrade.py b/sccfm-ansible/plugins/modules/trigger_ftd_upgrade.py index 45b0e6a8..317369a9 100644 --- a/sccfm-ansible/plugins/modules/trigger_ftd_upgrade.py +++ b/sccfm-ansible/plugins/modules/trigger_ftd_upgrade.py @@ -4,25 +4,6 @@ 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 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 base_argument_spec, create_config - DOCUMENTATION = r""" --- module: trigger_ftd_upgrade @@ -115,7 +96,7 @@ required: false type: path author: - - Cisco SCCFM Team + - Cisco SCCFM Team (@CiscoDevNet) """ EXAMPLES = r""" @@ -176,6 +157,37 @@ """ +from typing import Any, cast + +from ansible.module_utils.basic import AnsibleModule + +from ..module_utils.dependencies import record_import_error + +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.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 +except ImportError as exc: + record_import_error(exc) + ApiException = RuntimeError + NotFoundError = LookupError + FtdConfigureManagerError = ValueError + DEFAULT_TRANSACTION_TIMEOUT_SEC = 3600 + + +from ..module_utils.config import base_argument_spec, create_config + + def build_argument_spec() -> dict[str, dict[str, Any]]: return { "query": {"type": "str", "required": False}, diff --git a/sccfm-ansible/plugins/modules/update_access_rule.py b/sccfm-ansible/plugins/modules/update_access_rule.py index 972f1d34..8272ef09 100644 --- a/sccfm-ansible/plugins/modules/update_access_rule.py +++ b/sccfm-ansible/plugins/modules/update_access_rule.py @@ -4,16 +4,6 @@ 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 @@ -83,7 +73,7 @@ required: false type: path author: - - Cisco SCCFM Team + - Cisco SCCFM Team (@CiscoDevNet) """ EXAMPLES = r""" @@ -146,6 +136,27 @@ type: dict """ + +from typing import Any + +from ansible.module_utils.basic import AnsibleModule + +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) + ApiException = RuntimeError + NotFoundError = LookupError + FtdConfigureManagerError = ValueError + + +from ..module_utils.config import Config, base_argument_spec, create_config + _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 f4c766c5..e8d84e03 100644 --- a/sccfm-ansible/plugins/modules/update_network_group.py +++ b/sccfm-ansible/plugins/modules/update_network_group.py @@ -4,22 +4,6 @@ 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 @@ -78,7 +62,7 @@ required: false type: path author: - - Cisco SCCFM Team + - Cisco SCCFM Team (@CiscoDevNet) """ EXAMPLES = r""" @@ -153,6 +137,36 @@ """ +from typing import Any + +from ansible.module_utils.basic import AnsibleModule + +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 ( + NetworkGroupResponse, + NetworkGroupService, + ) +except ImportError as exc: + record_import_error(exc) + ApiException = RuntimeError + NotFoundError = LookupError + FtdConfigureManagerError = ValueError + + +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 + + 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 4002c281..0ccde662 100644 --- a/sccfm-ansible/plugins/modules/update_network_object.py +++ b/sccfm-ansible/plugins/modules/update_network_object.py @@ -4,22 +4,6 @@ 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 @@ -77,7 +61,7 @@ required: false type: path author: - - Cisco SCCFM Team + - Cisco SCCFM Team (@CiscoDevNet) """ EXAMPLES = r""" @@ -157,6 +141,36 @@ """ +from typing import Any + +from ansible.module_utils.basic import AnsibleModule + +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 ( + NetworkObjectResponse, + NetworkObjectService, + ) +except ImportError as exc: + record_import_error(exc) + ApiException = RuntimeError + NotFoundError = LookupError + FtdConfigureManagerError = ValueError + + +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 + + 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 0e460f70..e0139c9d 100644 --- a/sccfm-ansible/plugins/modules/update_object_default.py +++ b/sccfm-ansible/plugins/modules/update_object_default.py @@ -4,16 +4,6 @@ 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 @@ -45,7 +35,7 @@ required: false type: path author: - - Cisco SCCFM Team + - Cisco SCCFM Team (@CiscoDevNet) """ EXAMPLES = r""" @@ -115,6 +105,27 @@ """ +from typing import Any + +from ansible.module_utils.basic import AnsibleModule + +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) + ApiException = RuntimeError + NotFoundError = LookupError + FtdConfigureManagerError = ValueError + + +from ..module_utils.config import Config, base_argument_spec, create_config + + def build_argument_spec() -> dict[str, dict[str, Any]]: return { "uid": {"type": "str", "required": True}, diff --git a/sccfm-ansible/plugins/plugin_utils/__init__.py b/sccfm-ansible/plugins/plugin_utils/__init__.py new file mode 100644 index 00000000..6ed0f466 --- /dev/null +++ b/sccfm-ansible/plugins/plugin_utils/__init__.py @@ -0,0 +1,3 @@ +# Copyright 2026 Cisco Systems, Inc. and its affiliates +# +# SPDX-License-Identifier: Apache-2.0 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/requirements.txt b/sccfm-ansible/requirements.txt index 92c37dbd..2d9dbaf4 100644 --- a/sccfm-ansible/requirements.txt +++ b/sccfm-ansible/requirements.txt @@ -1,8 +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>=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 +# Controller-side Python dependency for this cisco.sccfm release. +# The collection build keeps this exact version aligned with galaxy.yml. +cisco-sccfm-devkit==0.39.0 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..b7b844ae --- /dev/null +++ b/sccfm-ansible/tests/sanity/ignore-2.20.txt @@ -0,0 +1,51 @@ +plugins/inventory/sccfm.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/lookup/profile.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..b7b844ae --- /dev/null +++ b/sccfm-ansible/tests/sanity/ignore-2.21.txt @@ -0,0 +1,51 @@ +plugins/inventory/sccfm.py validate-modules:missing-gplv3-license # Apache-2.0; legal review is tracked separately +plugins/lookup/profile.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/test_profile_lookup.py b/sccfm-ansible/tests/test_profile_lookup.py index 38fdb78d..43a1cb47 100644 --- a/sccfm-ansible/tests/test_profile_lookup.py +++ b/sccfm-ansible/tests/test_profile_lookup.py @@ -42,3 +42,17 @@ def test_should_read_profile_field(tmp_path: Path) -> None: def test_should_fail_for_missing_profile(tmp_path: Path) -> None: with pytest.raises(AnsibleError, match="profile 'missing' not found"): _lookup(tmp_path / "config.json").run(["missing"]) + + +def test_should_report_missing_devkit_dependency( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + _MODULE, + "_DEPENDENCY_IMPORT_ERROR", + ImportError("cisco_sccfm_core is unavailable"), + ) + + with pytest.raises(AnsibleError, match="cisco-sccfm-devkit must be installed"): + _lookup(tmp_path / "config.json").run(["default"]) diff --git a/skills/sccfm-cli/SKILL.md b/skills/sccfm-cli/SKILL.md index 26da3918..c3728fdb 100644 --- a/skills/sccfm-cli/SKILL.md +++ b/skills/sccfm-cli/SKILL.md @@ -158,8 +158,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. @@ -225,7 +225,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: @@ -334,11 +335,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_ansible_dependency_metadata.py b/tests/test_ansible_dependency_metadata.py new file mode 100644 index 00000000..3732bda6 --- /dev/null +++ b/tests/test_ansible_dependency_metadata.py @@ -0,0 +1,106 @@ +# Copyright 2026 Cisco Systems, Inc. and its affiliates +# +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import re +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["project"]["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("#") + ] + 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: + """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" + + +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_current_or_prepared_first_release() -> None: + """Keep changelogs aligned while allowing the one-time first-release seed.""" + galaxy = _yaml_mapping(_COLLECTION_ROOT / "galaxy.yml") + changelog_path = _COLLECTION_ROOT / "changelogs" / "changelog.yaml" + changelog = _yaml_mapping(changelog_path) + changelog_config = _yaml_mapping(_COLLECTION_ROOT / "changelogs" / "config.yaml") + version = str(galaxy["version"]) + releases = cast(dict[str, Any], changelog["releases"]) + + assert changelog_config["changes_file"] == "changelog.yaml" + assert changelog_config["notesdir"] == "fragments" + if version in releases: + changelog_version = version + else: + source = changelog_path.read_text(encoding="utf-8") + assert f"# sccfm-release-retarget-seed: {version}" in source + assert len(releases) == 1 + changelog_version = next(iter(releases)) + assert tuple(map(int, changelog_version.split("."))) > tuple(map(int, version.split("."))) + assert f"v{changelog_version}" in (_COLLECTION_ROOT / "CHANGELOG.rst").read_text() diff --git a/tests/test_ansible_e2e_playbooks.py b/tests/test_ansible_e2e_playbooks.py new file mode 100644 index 00000000..908895a7 --- /dev/null +++ b/tests/test_ansible_e2e_playbooks.py @@ -0,0 +1,50 @@ +# Copyright 2026 Cisco Systems, Inc. and its affiliates +# +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for Ansible E2E credential handling.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any, cast + +import yaml + +_REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +_ONBOARD_PLAYBOOK = ( + _REPOSITORY_ROOT / "sccfm-ansible" / "e2e" / "asa" / "playbooks" / "onboard_vasa.yml" +) +_VAULT_EXAMPLE = ( + _REPOSITORY_ROOT / "sccfm-ansible" / "examples" / "group_vars" / "all" / "vault.yml.example" +) + + +def _load_single_play() -> tuple[dict[str, Any], str]: + content = _ONBOARD_PLAYBOOK.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_ansible_vasa_onboarding_uses_vaulted_device_password() -> None: + play, content = _load_single_play() + onboard_task = next( + task["cisco.sccfm.onboard_asa"] + for task in play["tasks"] + if "cisco.sccfm.onboard_asa" in task + ) + + assert "../../../examples/group_vars/all/vault.yml" in play["vars_files"] + assert onboard_task["password"] == "{{ vault_vasa_password }}" + assert "lookup('env', 'VASA_PASSWORD')" not in content + + +def test_vault_example_declares_vasa_password() -> None: + vault_example = yaml.safe_load(_VAULT_EXAMPLE.read_text(encoding="utf-8")) + + assert isinstance(vault_example, dict) + assert "vault_vasa_password" in vault_example diff --git a/tests/test_build_ansible_collection.py b/tests/test_build_ansible_collection.py new file mode 100644 index 00000000..a4a36c17 --- /dev/null +++ b/tests/test_build_ansible_collection.py @@ -0,0 +1,75 @@ +# 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, + _sync_runtime_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 + + +def test_sync_runtime_requirement_updates_the_dependency_error_pin(tmp_path: Path) -> None: + dependencies = tmp_path / "dependencies.py" + dependencies.write_text( + '_PAIRED_DEVKIT_REQUIREMENT = "cisco-sccfm-devkit==0.38.0"\n', + encoding="utf-8", + ) + + _sync_runtime_requirement(dependencies, "0.39.0") + + assert dependencies.read_text(encoding="utf-8") == ( + '_PAIRED_DEVKIT_REQUIREMENT = "cisco-sccfm-devkit==0.39.0"\n' + ) + + +def test_sync_runtime_requirement_rejects_missing_contract(tmp_path: Path) -> None: + dependencies = tmp_path / "dependencies.py" + dependencies.write_text("# no paired requirement\n", encoding="utf-8") + + with pytest.raises(CollectionBuildError): + _sync_runtime_requirement(dependencies, "0.39.0") diff --git a/tests/test_cli_e2e_playbooks.py b/tests/test_cli_e2e_playbooks.py new file mode 100644 index 00000000..cf66c177 --- /dev/null +++ b/tests/test_cli_e2e_playbooks.py @@ -0,0 +1,51 @@ +# 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" +_PROFILE = "default" + + +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_default_profile() -> None: + play, content = _load_playbook("onboard_vasa.yml") + module_defaults = play["module_defaults"]["group/cisco.sccfm.all"] + + assert module_defaults == {"profile": _PROFILE} + assert play["vars"]["profile_region"] == ( + "{{ lookup('cisco.sccfm.profile', 'default', field='region') }}" + ) + assert "api_token" not in content + + +def test_cli_vasa_cleanup_uses_default_profile_lookup() -> 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 play["vars"]["profile_token"] == ( + "{{ lookup('cisco.sccfm.profile', 'default', field='api_token') }}" + ) + assert authorizations == ["Bearer {{ profile_token }}"] * 2 + assert "vault_sccfm_api_token" not in content diff --git a/tests/test_development_commands.py b/tests/test_development_commands.py new file mode 100644 index 00000000..044382ca --- /dev/null +++ b/tests/test_development_commands.py @@ -0,0 +1,95 @@ +# 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", + "check-doc-artifacts": "cisco_sccfm_scripts.check_doc_artifacts:main", + "check-doc-links": "cisco_sccfm_scripts.check_doc_links: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", + "sccfm-cli-interactive": "cisco_sccfm_scripts.interactive_cli: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"] + project = pyproject["project"] + + assert isinstance(tool, dict) + assert isinstance(project, dict) + poetry = tool["poetry"] + assert isinstance(poetry, dict) + dependencies = poetry["group"]["dev"]["dependencies"] + assert dependencies["cisco-sccfm-devtools"] == { + "path": "devtools", + "develop": True, + } + assert all( + not dependency.startswith("cisco-sccfm-devtools") for dependency in project["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_environment_setup.py b/tests/test_environment_setup.py new file mode 100644 index 00000000..f876151b --- /dev/null +++ b/tests/test_environment_setup.py @@ -0,0 +1,49 @@ +# Copyright 2026 Cisco Systems, Inc. and its affiliates +# +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for project environment setup scripts.""" + +from __future__ import annotations + +import re +import tomllib +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +SETUP_SCRIPTS = ( + PROJECT_ROOT / "cisco_sccfm_scripts" / "setup_ci_environment.sh", + PROJECT_ROOT / "cisco_sccfm_scripts" / "setup_environment.sh", +) +POETRY_GROUP_ARGUMENT = re.compile( + r"\binstall --with (?P[A-Za-z0-9_-]+(?:,[A-Za-z0-9_-]+)*)" +) + + +def _defined_poetry_groups() -> set[str]: + with (PROJECT_ROOT / "pyproject.toml").open("rb") as file_handle: + pyproject: dict[str, object] = tomllib.load(file_handle) + + tool = pyproject["tool"] + assert isinstance(tool, dict) + poetry = tool["poetry"] + assert isinstance(poetry, dict) + groups = poetry["group"] + assert isinstance(groups, dict) + return {str(group) for group in groups} + + +def test_setup_scripts_request_defined_poetry_groups() -> None: + defined_groups = _defined_poetry_groups() + + for script in SETUP_SCRIPTS: + source = script.read_text(encoding="utf-8") + matches = list(POETRY_GROUP_ARGUMENT.finditer(source)) + assert matches, f"{script.name} does not install a Poetry dependency group" + + for match in matches: + requested_groups = set(match.group("groups").split(",")) + assert requested_groups <= defined_groups, ( + f"{script.name} requests undefined Poetry groups: " + f"{sorted(requested_groups - defined_groups)}" + ) diff --git a/tests/test_prepare_ansible_release.py b/tests/test_prepare_ansible_release.py new file mode 100644 index 00000000..9a0daf12 --- /dev/null +++ b/tests/test_prepare_ansible_release.py @@ -0,0 +1,410 @@ +# 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 + +import shutil +from pathlib import Path + +import pytest +import yaml + +from cisco_sccfm_scripts.prepare_ansible_release import ( + AnsibleReleaseError, + main, + prepare_ansible_release, +) + +_INITIAL_VERSION = "0.39.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.39.0.yml", +) -> str: + return f"""--- +ancestor: null +# sccfm-release-retarget-seed: {_INITIAL_VERSION} +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.39.0" not in rst + assert _SUMMARY in rst + + +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" + (root / "changelogs").mkdir(parents=True) + 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, + previous_version, + release_version, + release_date, + ) + + 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: + 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) + 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() + + 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) + prepare_ansible_release(root, _INITIAL_VERSION, _RELEASE_VERSION, "2026-08-01") + 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 + + +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"], +) +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_version", ["0.39.0", "0.38.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) + + 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] + 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() + + 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.39.0\n=======\n", "v0.39.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_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, + } diff --git a/tests/test_release_artifacts.py b/tests/test_release_artifacts.py new file mode 100644 index 00000000..e73a556f --- /dev/null +++ b/tests/test_release_artifacts.py @@ -0,0 +1,472 @@ +# 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 +import re +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 _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) + + 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_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") + + 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.event_name == 'pull_request'" 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 "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 "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 + + for job in (validation, pypi, galaxy, finalizer): + assert 'gh release download "${RELEASE_TAG}"' in job + assert "release_artifacts verify" in job + + assert "pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33" in pypi + assert "pypa/gh-action-pypi-publish@release/v1" not 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 + 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 "secrets.GALAXY_API_KEY" 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 + assert "--latest=false" in finalizer + 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_ci_refreshes_metadata_after_inferred_files_only_bump() -> None: + repository = Path(__file__).resolve().parents[1] + 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] + + 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 inference < bump < reinstall < metadata_check + assert 'version("cisco-sccfm-devkit")' in synchronization + + +def test_ci_release_changed_path_validation_reads_tracked_and_untracked_paths() -> None: + repository = Path(__file__).resolve().parents[1] + 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*" + 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_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") + 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] + 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 'PUSH_REMOTE="git@github.com:${GITHUB_REPOSITORY}.git"' in push + assert 'git push --atomic "${PUSH_REMOTE}"' 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 + + +def test_release_workflows_limit_deploy_key_to_push_steps() -> 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") + generated_docs = (repository / ".github/workflows/generated-docs.yml").read_text( + encoding="utf-8" + ) + prepare = _workflow_job(ci, "prepare-release") + + for workflow in (ci, release, generated_docs): + assert workflow.count("uses: actions/checkout@v7") == workflow.count( + "persist-credentials: false" + ) + + prepare_before_push, prepare_push = prepare.split( + " - name: Push release commit and tag atomically\n", maxsplit=1 + ) + assert "persist-credentials: false" in prepare_before_push + assert "ssh-key:" not in prepare_before_push + assert "SCCFM_CI_DEPLOY_KEY" not in prepare_before_push + assert prepare_push.count("secrets.SCCFM_CI_DEPLOY_KEY") == 1 + assert 'chmod 600 "${DEPLOY_KEY_PATH}"' in prepare_push + assert "trap cleanup_ssh EXIT" in prepare_push + assert "unset SCCFM_CI_DEPLOY_KEY" in prepare_push + assert "https://api.github.com/meta" in prepare_push + assert "StrictHostKeyChecking=yes" in prepare_push + + docs_before_push, docs_push = generated_docs.split( + " - name: Push generated docs\n", maxsplit=1 + ) + assert "persist-credentials: false" in docs_before_push + assert "permissions:\n contents: read" in docs_before_push + assert "ssh-key:" not in docs_before_push + assert "SCCFM_CI_DEPLOY_KEY" not in docs_before_push + assert docs_push.count("secrets.SCCFM_CI_DEPLOY_KEY") == 1 + assert 'chmod 600 "${DEPLOY_KEY_PATH}"' in docs_push + assert "trap cleanup_ssh EXIT" in docs_push + assert "unset SCCFM_CI_DEPLOY_KEY" in docs_push + assert "StrictHostKeyChecking=yes" in docs_push + + +def test_ci_runs_pinned_workflow_lints_and_a_no_push_release_rehearsal() -> None: + repository = Path(__file__).resolve().parents[1] + ci = (repository / ".github/workflows/ci.yml").read_text(encoding="utf-8") + prepare = _workflow_job(ci, "prepare-release") + draft = _workflow_job(ci, "create-draft-release") + + assert "shellcheck-py==0.11.0.1" in ci + assert "github.com/rhysd/actionlint/cmd/actionlint@v1.7.12" in ci + assert "SHELLCHECK_OPTS: --severity=warning" in ci + assert '-shellcheck "$(command -v shellcheck)"' in ci + assert "go install github.com/zricethezav/gitleaks/v8@v8.30.1" in prepare + assert "go install github.com/gitleaks/gitleaks/v8@v8.30.1" not in prepare + + assert "github.event_name == 'pull_request'" in prepare + assert 'git commit --allow-empty -m "fix: rehearse release preparation"' in prepare + assert "poetry run cz bump --get-next" in prepare + assert "poetry run cz bump --yes --changelog --files-only" in prepare + assert "prepare_ansible_release" in prepare + assert "poetry run generate-cli-docs" in prepare + assert "poetry run generate-cli-man-docs" in prepare + assert "poetry run generate-ansible-docs" in prepare + assert "poetry run build-ansible-collection" in prepare + assert "poetry build" in prepare + assert "verify_python_artifacts" in prepare + assert "verify_ansible_collection" in prepare + assert 'git commit -m "bump: version ${RELEASE_VERSION}"' in prepare + assert "release_artifacts create" in prepare + assert 'git tag -a "${{ steps.version.outputs.tag }}"' in prepare + assert "Complete credential-free release rehearsal" in prepare + assert "without uploading or pushing" in prepare + + preserve = prepare.split(" - name: Preserve exact release bundle\n", maxsplit=1)[1] + preserve = preserve.split(" - name: Push release commit", maxsplit=1)[0] + push = prepare.split(" - name: Push release commit and tag atomically\n", maxsplit=1)[1] + push = push.split(" - name: Complete credential-free", maxsplit=1)[0] + assert "github.event_name == 'push'" in preserve + assert "github.event_name == 'push'" in push + assert "github.event_name == 'push'" in draft diff --git a/tests/test_verify_ansible_collection.py b/tests/test_verify_ansible_collection.py new file mode 100644 index 00000000..58391d17 --- /dev/null +++ b/tests/test_verify_ansible_collection.py @@ -0,0 +1,470 @@ +# 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 = { + "changelogs", + "examples", + "examples/group_vars", + "examples/group_vars/all", + "meta", + "plugins", + "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"---\nvault_device_password: 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" + ), +} + + +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) + + +@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, + 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( + 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_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" + 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()} + 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_asa_branch_office_01_password" in packaged_vault_template + assert "sccfm_api_token" not in packaged_vault_template + + 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 + + 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_asa_branch_office_01_password" in installed_vault_template + assert "sccfm_api_token" not in installed_vault_template + + 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 + + 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 = ("profile: default",) + 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 }}", + "lookup('env', 'SCCFM_REGION')", + "lookup('env', '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, + 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"} 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_pypi_release.py b/tests/test_verify_pypi_release.py new file mode 100644 index 00000000..f7ff40e4 --- /dev/null +++ b/tests/test_verify_pypi_release.py @@ -0,0 +1,326 @@ +# 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, + missing_filenames=(_SDIST if filename == _WHEEL else _WHEEL,), + ) + + +@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, + (_SDIST,), + ), + 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 new file mode 100644 index 00000000..32d5de87 --- /dev/null +++ b/tests/test_verify_python_artifacts.py @@ -0,0 +1,275 @@ +# 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, + verify_python_wheel, +) + +_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" +_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", + "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" }, +] +""" + + +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, + 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": _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 {}), + } + 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": _LICENSE, + "LICENSES/Apache-2.0.txt": _LICENSE, + "CHANGELOG.md": b"# Changelog\n", + "CONTRIBUTING.md": b"# Contributing\n", + "INSTALL.md": b"# Installation\n", + "PKG-INFO": _METADATA_HEADERS + 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, + **(sdist_extra or {}), + } + 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 + + +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 == 8 + assert result.sdist_files == 11 + + +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 == 8 + + +@pytest.mark.parametrize( + ("artifact", "member"), + [ + ("wheel", "cisco_sccfm_scripts/interactive_cli.py"), + ("sdist", "cisco_sccfm_scripts/interactive_cli.py"), + ("wheel", "cisco_sccfm_scripts/bin/sccfm-cli-interactive"), + ("sdist", "cisco_sccfm_scripts/bin/sccfm-cli-interactive"), + ("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"sccfm-cli-interactive=cisco_sccfm_scripts.interactive_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.replace( + b'sccfm-cli = "cisco_sccfm_cli.cli:cli"\n', + b'sccfm-cli = "cisco_sccfm_cli.cli:cli"\n' + b'sccfm-cli-interactive = "cisco_sccfm_scripts.interactive_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) + + +@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) + + +@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)