bind9-aiops 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bind9_aiops-0.2.0/.claude-plugin/plugin.json +22 -0
- bind9_aiops-0.2.0/.github/workflows/ci.yml +28 -0
- bind9_aiops-0.2.0/.github/workflows/mcp-publish.yml +55 -0
- bind9_aiops-0.2.0/.github/workflows/publish.yml +26 -0
- bind9_aiops-0.2.0/.gitignore +23 -0
- bind9_aiops-0.2.0/.mcp.json +13 -0
- bind9_aiops-0.2.0/CHANGELOG.md +12 -0
- bind9_aiops-0.2.0/LICENSE +21 -0
- bind9_aiops-0.2.0/PKG-INFO +51 -0
- bind9_aiops-0.2.0/README.md +37 -0
- bind9_aiops-0.2.0/SECURITY.md +5 -0
- bind9_aiops-0.2.0/bind9_aiops/__init__.py +8 -0
- bind9_aiops-0.2.0/bind9_aiops/cli/__init__.py +9 -0
- bind9_aiops-0.2.0/bind9_aiops/cli/_common.py +118 -0
- bind9_aiops-0.2.0/bind9_aiops/cli/_root.py +249 -0
- bind9_aiops-0.2.0/bind9_aiops/cli/undo.py +66 -0
- bind9_aiops-0.2.0/bind9_aiops/config.py +58 -0
- bind9_aiops-0.2.0/bind9_aiops/connection.py +119 -0
- bind9_aiops-0.2.0/bind9_aiops/governance/__init__.py +50 -0
- bind9_aiops-0.2.0/bind9_aiops/governance/audit.py +526 -0
- bind9_aiops-0.2.0/bind9_aiops/governance/budget.py +222 -0
- bind9_aiops-0.2.0/bind9_aiops/governance/decorators.py +599 -0
- bind9_aiops-0.2.0/bind9_aiops/governance/outcome.py +102 -0
- bind9_aiops-0.2.0/bind9_aiops/governance/paths.py +23 -0
- bind9_aiops-0.2.0/bind9_aiops/governance/patterns.py +396 -0
- bind9_aiops-0.2.0/bind9_aiops/governance/policy.py +67 -0
- bind9_aiops-0.2.0/bind9_aiops/governance/sanitize.py +79 -0
- bind9_aiops-0.2.0/bind9_aiops/governance/undo.py +259 -0
- bind9_aiops-0.2.0/bind9_aiops/mcp_server/__init__.py +1 -0
- bind9_aiops-0.2.0/bind9_aiops/mcp_server/_annotations.py +66 -0
- bind9_aiops-0.2.0/bind9_aiops/mcp_server/_shared.py +67 -0
- bind9_aiops-0.2.0/bind9_aiops/mcp_server/server.py +14 -0
- bind9_aiops-0.2.0/bind9_aiops/mcp_server/tools/__init__.py +1 -0
- bind9_aiops-0.2.0/bind9_aiops/mcp_server/tools/dns.py +224 -0
- bind9_aiops-0.2.0/bind9_aiops/mcp_server/tools/undo.py +204 -0
- bind9_aiops-0.2.0/bind9_aiops/ops/__init__.py +1 -0
- bind9_aiops-0.2.0/bind9_aiops/ops/dns.py +116 -0
- bind9_aiops-0.2.0/docs/VERIFICATION.md +15 -0
- bind9_aiops-0.2.0/glama.json +6 -0
- bind9_aiops-0.2.0/openclaw.plugin.json +24 -0
- bind9_aiops-0.2.0/package.json +20 -0
- bind9_aiops-0.2.0/pyproject.toml +59 -0
- bind9_aiops-0.2.0/server.json +21 -0
- bind9_aiops-0.2.0/skills/bind9-aiops/SKILL.md +24 -0
- bind9_aiops-0.2.0/skills/bind9-aiops/references/agent-guardrails.md +13 -0
- bind9_aiops-0.2.0/skills/bind9-aiops/references/capabilities.md +5 -0
- bind9_aiops-0.2.0/skills/bind9-aiops/references/cli-reference.md +3 -0
- bind9_aiops-0.2.0/skills/bind9-aiops/references/setup-guide.md +5 -0
- bind9_aiops-0.2.0/tests/test_audit_guardrails.py +211 -0
- bind9_aiops-0.2.0/tests/test_cli_audited.py +47 -0
- bind9_aiops-0.2.0/tests/test_cli_governed_exit.py +18 -0
- bind9_aiops-0.2.0/tests/test_dns.py +219 -0
- bind9_aiops-0.2.0/tests/test_gov_audit.py +292 -0
- bind9_aiops-0.2.0/tests/test_gov_decorators.py +433 -0
- bind9_aiops-0.2.0/tests/test_gov_patterns.py +413 -0
- bind9_aiops-0.2.0/tests/test_gov_policy.py +56 -0
- bind9_aiops-0.2.0/tests/test_governance_persistence.py +333 -0
- bind9_aiops-0.2.0/tests/test_package_layout.py +62 -0
- bind9_aiops-0.2.0/tests/test_smoke.py +28 -0
- bind9_aiops-0.2.0/tests/test_tool_annotations.py +139 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://anthropic.com/claude-code/plugin.schema.json",
|
|
3
|
+
"name": "bind9-aiops",
|
|
4
|
+
"displayName": "BIND 9 AIops",
|
|
5
|
+
"version": "0.2.0",
|
|
6
|
+
"description": "Governed BIND 9 DNS operations — 23 MCP tools with audit.",
|
|
7
|
+
"author": {
|
|
8
|
+
"name": "Wei Zhou",
|
|
9
|
+
"email": "zhouwei008@gmail.com",
|
|
10
|
+
"url": "https://github.com/AIops-tools"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/AIops-tools/BIND9-AIops",
|
|
13
|
+
"repository": "https://github.com/AIops-tools/BIND9-AIops",
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"keywords": [
|
|
16
|
+
"bind9",
|
|
17
|
+
"dns",
|
|
18
|
+
"dnssec",
|
|
19
|
+
"mcp",
|
|
20
|
+
"aiops"
|
|
21
|
+
]
|
|
22
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [bootstrap, main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
contents: read
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
quality:
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
- uses: astral-sh/setup-uv@v5
|
|
17
|
+
- name: Install Python and dependencies
|
|
18
|
+
run: |
|
|
19
|
+
uv python install 3.11
|
|
20
|
+
uv sync --dev --python 3.11
|
|
21
|
+
- name: Tests
|
|
22
|
+
run: uv run pytest -q
|
|
23
|
+
- name: Lint
|
|
24
|
+
run: uv run ruff check .
|
|
25
|
+
- name: Security scan
|
|
26
|
+
run: uv run bandit -r bind9_aiops -ll -q
|
|
27
|
+
- name: Build
|
|
28
|
+
run: uv build
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
name: mcp-publish
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
workflow_dispatch:
|
|
5
|
+
release:
|
|
6
|
+
types: [published]
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
id-token: write
|
|
10
|
+
contents: read
|
|
11
|
+
|
|
12
|
+
jobs:
|
|
13
|
+
publish-mcp:
|
|
14
|
+
runs-on: ubuntu-latest
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
- name: Wait for PyPI
|
|
18
|
+
# The release event also triggers the PyPI publish workflow; the MCP
|
|
19
|
+
# registry validates that the package version exists on PyPI, so poll
|
|
20
|
+
# until it has propagated (every 15s, up to 10 minutes).
|
|
21
|
+
run: |
|
|
22
|
+
python3 - <<'EOF'
|
|
23
|
+
import json
|
|
24
|
+
import sys
|
|
25
|
+
import time
|
|
26
|
+
import urllib.error
|
|
27
|
+
import urllib.request
|
|
28
|
+
|
|
29
|
+
with open("server.json", encoding="utf-8") as f:
|
|
30
|
+
package = json.load(f)["packages"][0]
|
|
31
|
+
name, version = package["identifier"], package["version"]
|
|
32
|
+
url = f"https://pypi.org/pypi/{name}/{version}/json"
|
|
33
|
+
deadline = time.monotonic() + 600
|
|
34
|
+
while True:
|
|
35
|
+
try:
|
|
36
|
+
with urllib.request.urlopen(url, timeout=10):
|
|
37
|
+
print(f"{name}=={version} is available on PyPI.")
|
|
38
|
+
sys.exit(0)
|
|
39
|
+
except (urllib.error.URLError, OSError) as exc:
|
|
40
|
+
print(f"{name}=={version} not on PyPI yet ({exc}); "
|
|
41
|
+
"retrying in 15s...")
|
|
42
|
+
if time.monotonic() >= deadline:
|
|
43
|
+
sys.exit(f"Timed out after 10 minutes waiting for "
|
|
44
|
+
f"{name}=={version} to appear on PyPI. "
|
|
45
|
+
"Check the PyPI publish workflow, then re-run "
|
|
46
|
+
"this workflow via workflow_dispatch.")
|
|
47
|
+
time.sleep(15)
|
|
48
|
+
EOF
|
|
49
|
+
- name: Install mcp-publisher
|
|
50
|
+
run: |
|
|
51
|
+
curl -sL "https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_$(uname -s | tr '[:upper:]' '[:lower:]')_$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/').tar.gz" | tar xz mcp-publisher
|
|
52
|
+
- name: Login to MCP Registry (GitHub OIDC)
|
|
53
|
+
run: ./mcp-publisher login github-oidc
|
|
54
|
+
- name: Publish server.json
|
|
55
|
+
run: ./mcp-publisher publish
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
# Trusted Publishing (OIDC) — publishes from GitHub's runners with no API token,
|
|
4
|
+
# sidestepping the local-IP / account new-project rate limit. Configure a matching
|
|
5
|
+
# "trusted publisher" for this package on PyPI (see the repo release notes).
|
|
6
|
+
on:
|
|
7
|
+
release:
|
|
8
|
+
types: [published]
|
|
9
|
+
workflow_dispatch:
|
|
10
|
+
|
|
11
|
+
permissions:
|
|
12
|
+
contents: read
|
|
13
|
+
|
|
14
|
+
jobs:
|
|
15
|
+
publish:
|
|
16
|
+
runs-on: ubuntu-latest
|
|
17
|
+
permissions:
|
|
18
|
+
id-token: write # required for PyPI Trusted Publishing (OIDC)
|
|
19
|
+
steps:
|
|
20
|
+
- uses: actions/checkout@v4
|
|
21
|
+
- name: Set up uv
|
|
22
|
+
uses: astral-sh/setup-uv@v5
|
|
23
|
+
- name: Build sdist + wheel
|
|
24
|
+
run: uv build
|
|
25
|
+
- name: Publish to PyPI (Trusted Publishing)
|
|
26
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
.pytest_cache/
|
|
6
|
+
.ruff_cache/
|
|
7
|
+
|
|
8
|
+
# Virtual envs / build
|
|
9
|
+
.venv/
|
|
10
|
+
dist/
|
|
11
|
+
build/
|
|
12
|
+
|
|
13
|
+
# uv
|
|
14
|
+
uv.lock
|
|
15
|
+
|
|
16
|
+
# Local config / secrets (never commit)
|
|
17
|
+
*.env
|
|
18
|
+
.env
|
|
19
|
+
config.yaml
|
|
20
|
+
|
|
21
|
+
# OS
|
|
22
|
+
.DS_Store
|
|
23
|
+
.coverage
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## v0.2.0 — 2026-11-05
|
|
4
|
+
|
|
5
|
+
- Expand to 23 governed MCP/CLI tools covering zone DNSSEC, managed keys, stale answers, running zone config and selected-server queries.
|
|
6
|
+
- Add preflighted zone reload, refresh, retransfer, NOTIFY resend and name-specific cache flush with dry-run, explicit unknown outcomes and no fabricated undo.
|
|
7
|
+
- Keep DNS query and probe failures explicit; compare serials only when both SOA responses are parseable.
|
|
8
|
+
- Add GitHub CI for tests, lint, security scan and build.
|
|
9
|
+
|
|
10
|
+
## 0.1.0
|
|
11
|
+
|
|
12
|
+
- Initial local development version; not published.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 wei <zhouwei008@gmail.com>
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: bind9-aiops
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Governed BIND 9 DNS diagnosis for service, zones, DNSSEC and SOA replication.
|
|
5
|
+
Author-email: Wei Zhou <zhouwei008@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Python: >=3.11
|
|
9
|
+
Requires-Dist: mcp[cli]<3.0,>=2.0
|
|
10
|
+
Requires-Dist: pyyaml<7.0,>=6.0
|
|
11
|
+
Requires-Dist: rich<16.0,>=13.0
|
|
12
|
+
Requires-Dist: typer<1.0,>=0.12
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
<!-- mcp-name: io.github.AIops-tools/bind9-aiops -->
|
|
16
|
+
# BIND 9 AIops
|
|
17
|
+
|
|
18
|
+
Governed BIND 9 DNS operations with 23 MCP tools and corresponding CLI commands. It executes `rndc`, `named-checkconf` and `dig` as fixed argument lists without a shell. Every call is audited and budgeted by the bundled governance harness.
|
|
19
|
+
|
|
20
|
+
## What it checks
|
|
21
|
+
|
|
22
|
+
- Named service and zone status from `rndc`.
|
|
23
|
+
- Configuration and loaded-zone validation with `named-checkconf -z`.
|
|
24
|
+
- DNSSEC validation mode.
|
|
25
|
+
- Zone DNSSEC signing, managed-key and serve-stale status; running zone configuration where enabled.
|
|
26
|
+
- Recursive fetch-limit hits, DNSSEC trust anchors, DNS TCP timeouts and per-zone signing progress.
|
|
27
|
+
- DNS queries with their actual DNS response code.
|
|
28
|
+
- Queries against a selected secondary server.
|
|
29
|
+
- Guarded secondary retransfer and single-zone NOTIFY resend.
|
|
30
|
+
- Primary and secondary SOA serial comparison. A failed or unparseable query returns `null`, never a fabricated zero or match.
|
|
31
|
+
- `bind_diagnose_zone` keeps individual probe failures visible.
|
|
32
|
+
|
|
33
|
+
## Setup from source
|
|
34
|
+
|
|
35
|
+
Requires Python 3.11+, `uv`, and BIND 9 command utilities on the operator host. Run `uv sync --dev`, `uv run bind9-aiops init`, then `uv run bind9-aiops doctor`. A manual `~/.bind9-aiops/config.yaml` can specify paths and a DNS server:
|
|
36
|
+
|
|
37
|
+
```yaml
|
|
38
|
+
targets:
|
|
39
|
+
- name: local
|
|
40
|
+
server: 127.0.0.1
|
|
41
|
+
dns_port: 53
|
|
42
|
+
rndc_config: /etc/bind/rndc.conf
|
|
43
|
+
named_config: /etc/bind/named.conf
|
|
44
|
+
timeout: 10
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Use `uv run bind9-aiops diagnose example.org` or `uv run bind9-aiops compare-soa example.org 192.0.2.2`. The MCP entry point is `uv run bind9-aiops-mcp`. The process account needs access to its `rndc` key file and BIND config, but no elevated shell is given to the agent.
|
|
48
|
+
|
|
49
|
+
## Scope and verification
|
|
50
|
+
|
|
51
|
+
`reload-zone`, `refresh-zone`, `flush-name`, `retransfer-zone` and `notify-zone` support dry-run and two CLI confirmations. RNDC command acceptance does not prove a queued zone reload or transfer completed, so these writes report `outcomeUnknown`; a lost command response does too. No safe undo exists for a cache flush, retransfer, NOTIFY delivery or externally edited zone file. Zone-file editing, DNSSEC key changes and global service restarts remain outside this release. See [verification](docs/VERIFICATION.md). Missing features and live-environment feedback are welcome as issues or PRs.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
<!-- mcp-name: io.github.AIops-tools/bind9-aiops -->
|
|
2
|
+
# BIND 9 AIops
|
|
3
|
+
|
|
4
|
+
Governed BIND 9 DNS operations with 23 MCP tools and corresponding CLI commands. It executes `rndc`, `named-checkconf` and `dig` as fixed argument lists without a shell. Every call is audited and budgeted by the bundled governance harness.
|
|
5
|
+
|
|
6
|
+
## What it checks
|
|
7
|
+
|
|
8
|
+
- Named service and zone status from `rndc`.
|
|
9
|
+
- Configuration and loaded-zone validation with `named-checkconf -z`.
|
|
10
|
+
- DNSSEC validation mode.
|
|
11
|
+
- Zone DNSSEC signing, managed-key and serve-stale status; running zone configuration where enabled.
|
|
12
|
+
- Recursive fetch-limit hits, DNSSEC trust anchors, DNS TCP timeouts and per-zone signing progress.
|
|
13
|
+
- DNS queries with their actual DNS response code.
|
|
14
|
+
- Queries against a selected secondary server.
|
|
15
|
+
- Guarded secondary retransfer and single-zone NOTIFY resend.
|
|
16
|
+
- Primary and secondary SOA serial comparison. A failed or unparseable query returns `null`, never a fabricated zero or match.
|
|
17
|
+
- `bind_diagnose_zone` keeps individual probe failures visible.
|
|
18
|
+
|
|
19
|
+
## Setup from source
|
|
20
|
+
|
|
21
|
+
Requires Python 3.11+, `uv`, and BIND 9 command utilities on the operator host. Run `uv sync --dev`, `uv run bind9-aiops init`, then `uv run bind9-aiops doctor`. A manual `~/.bind9-aiops/config.yaml` can specify paths and a DNS server:
|
|
22
|
+
|
|
23
|
+
```yaml
|
|
24
|
+
targets:
|
|
25
|
+
- name: local
|
|
26
|
+
server: 127.0.0.1
|
|
27
|
+
dns_port: 53
|
|
28
|
+
rndc_config: /etc/bind/rndc.conf
|
|
29
|
+
named_config: /etc/bind/named.conf
|
|
30
|
+
timeout: 10
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Use `uv run bind9-aiops diagnose example.org` or `uv run bind9-aiops compare-soa example.org 192.0.2.2`. The MCP entry point is `uv run bind9-aiops-mcp`. The process account needs access to its `rndc` key file and BIND config, but no elevated shell is given to the agent.
|
|
34
|
+
|
|
35
|
+
## Scope and verification
|
|
36
|
+
|
|
37
|
+
`reload-zone`, `refresh-zone`, `flush-name`, `retransfer-zone` and `notify-zone` support dry-run and two CLI confirmations. RNDC command acceptance does not prove a queued zone reload or transfer completed, so these writes report `outcomeUnknown`; a lost command response does too. No safe undo exists for a cache flush, retransfer, NOTIFY delivery or externally edited zone file. Zone-file editing, DNSSEC key changes and global service restarts remain outside this release. See [verification](docs/VERIFICATION.md). Missing features and live-environment feedback are welcome as issues or PRs.
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# Security
|
|
2
|
+
|
|
3
|
+
Report vulnerabilities privately through GitHub security advisories when the repository is available. Do not share `rndc` keys or private zone data in public issues.
|
|
4
|
+
|
|
5
|
+
The tool invokes configured binaries with argument lists, never a shell. It does not accept an agent-provided command or file path. Access to `rndc` is controlled by the operating-system account and the `rndc` key file; keep that file private. Reload, refresh, name-specific cache flush, high-risk secondary retransfer and NOTIFY resend have dry-run and CLI double confirmation. RNDC acceptance is recorded as unknown until the operator verifies the resulting DNS state. Audit records and budget enforcement are bundled locally.
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Shared helpers for bind9-aiops CLI sub-modules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import functools
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from typing import Annotated, Any
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.markup import escape
|
|
12
|
+
|
|
13
|
+
console = Console()
|
|
14
|
+
|
|
15
|
+
# ─── Shared Option types ───────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
TargetOption = Annotated[str | None, typer.Option("--target", "-t", help="Target name from config")]
|
|
18
|
+
DryRunOption = Annotated[
|
|
19
|
+
bool, typer.Option("--dry-run", help="Print the API call without executing")
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _cli_error_types() -> tuple[type[BaseException], ...]:
|
|
24
|
+
"""Exceptions translated to a one-line teaching error instead of a traceback.
|
|
25
|
+
|
|
26
|
+
``BudgetExceeded`` (and the retained ``PolicyDenied`` type) are raised by
|
|
27
|
+
``@governed_tool`` OUTSIDE the tool body, so ``tool_errors`` never sees them
|
|
28
|
+
and they never arrive as an ``{"error": ...}`` dict. Their message is the
|
|
29
|
+
teaching text (e.g. which budget was hit) — without them here such a refusal
|
|
30
|
+
reaches the CLI as a traceback instead.
|
|
31
|
+
"""
|
|
32
|
+
from bind9_aiops.connection import BindCommandError
|
|
33
|
+
from bind9_aiops.governance import BudgetExceeded, PolicyDenied
|
|
34
|
+
|
|
35
|
+
return (BindCommandError, PolicyDenied, BudgetExceeded, KeyError, OSError, ValueError)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def cli_errors(fn: Callable) -> Callable:
|
|
39
|
+
"""Translate known exceptions into one red line + exit code 1."""
|
|
40
|
+
|
|
41
|
+
@functools.wraps(fn)
|
|
42
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
43
|
+
try:
|
|
44
|
+
return fn(*args, **kwargs)
|
|
45
|
+
except (typer.Exit, typer.Abort):
|
|
46
|
+
raise
|
|
47
|
+
except _cli_error_types() as e:
|
|
48
|
+
message = str(e)
|
|
49
|
+
if isinstance(e, KeyError):
|
|
50
|
+
message = f"Missing required key or environment variable: {message}"
|
|
51
|
+
# Upstream text can contain "[/bold]"; unescaped, rich raises on it and the
|
|
52
|
+
# real error is replaced by a MarkupError traceback.
|
|
53
|
+
console.print(f"[red]Error: {escape(message)}[/]")
|
|
54
|
+
raise typer.Exit(1) from e
|
|
55
|
+
|
|
56
|
+
return wrapper
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
#: Exit status for a write whose outcome could not be determined — kept distinct
|
|
60
|
+
#: from 0 (confirmed) and 1 (failed) so a script can tell all three apart.
|
|
61
|
+
EXIT_UNDETERMINED = 2
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def governed(result: Any) -> dict:
|
|
65
|
+
"""Return a governed tool's result, or print its error and exit 1.
|
|
66
|
+
|
|
67
|
+
The ``bind9_aiops.mcp_server.tools`` twins never raise: ``@tool_errors`` flattens every
|
|
68
|
+
failure — an unreachable service, a refused login, a bad argument — into
|
|
69
|
+
``{"error": ...}``. A CLI command that drops that on the floor prints an empty
|
|
70
|
+
table and exits 0 as if the service had nothing to report. Route every governed
|
|
71
|
+
call through here.
|
|
72
|
+
"""
|
|
73
|
+
# ``outcomeUnknown`` is judged BEFORE ``error``, matching the harness: a
|
|
74
|
+
# write whose response was lost carries BOTH keys, and it is audited
|
|
75
|
+
# `unknown` precisely because it may have taken effect. Reporting that as a
|
|
76
|
+
# plain failure would tell a script the change did not happen and invite the
|
|
77
|
+
# double-apply the payload's own note warns about.
|
|
78
|
+
if isinstance(result, dict) and result.get("outcomeUnknown"):
|
|
79
|
+
console.print(f"[yellow]Outcome undetermined: {escape(str(result.get('note') or ''))}[/]")
|
|
80
|
+
raise typer.Exit(EXIT_UNDETERMINED)
|
|
81
|
+
if isinstance(result, dict) and result.get("error"):
|
|
82
|
+
console.print(f"[red]Error: {escape(str(result['error']))}[/]")
|
|
83
|
+
raise typer.Exit(1)
|
|
84
|
+
return result if isinstance(result, dict) else {}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def dry_run_print(*, operation: str, api_call: str, parameters: dict | None = None) -> None:
|
|
88
|
+
"""Print a dry-run preview of the API call that would be made."""
|
|
89
|
+
console.print("\n[bold magenta][DRY-RUN] No changes will be made.[/]")
|
|
90
|
+
console.print(f"[magenta] Operation: {operation}[/]")
|
|
91
|
+
console.print(f"[magenta] API Call: {api_call}[/]")
|
|
92
|
+
for k, v in (parameters or {}).items():
|
|
93
|
+
console.print(f"[magenta] Param: {k} = {v}[/]")
|
|
94
|
+
console.print("[magenta] Run without --dry-run to execute.[/]\n")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def double_confirm(action: str, resource: str) -> None:
|
|
98
|
+
"""Require two confirmations for a destructive operation."""
|
|
99
|
+
console.print(f"[bold yellow]⚠️ About to: {action} '{resource}'[/]")
|
|
100
|
+
typer.confirm(f"Confirm 1/2: {action} '{resource}'?", abort=True)
|
|
101
|
+
typer.confirm(
|
|
102
|
+
f"Confirm 2/2: really {action} '{resource}'? This may be irreversible.",
|
|
103
|
+
abort=True,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def print_truncation_note(result: dict, noun: str) -> None:
|
|
108
|
+
"""Print a visible warning when a listing envelope was capped.
|
|
109
|
+
|
|
110
|
+
The JSON already carries ``truncated``, but a human scrolling a long dump
|
|
111
|
+
will not spot it — and neither will a model summarising the terminal. Say
|
|
112
|
+
it in words, on the last line, where it cannot be missed.
|
|
113
|
+
"""
|
|
114
|
+
if isinstance(result, dict) and result.get("truncated"):
|
|
115
|
+
console.print(
|
|
116
|
+
f"[yellow]… showing {result.get('returned')} of more {noun} — "
|
|
117
|
+
f"truncated, re-run with a higher --limit[/]"
|
|
118
|
+
)
|