queue-aiops 0.6.0__tar.gz → 0.8.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.
- {queue_aiops-0.6.0 → queue_aiops-0.8.0}/CHANGELOG.md +14 -0
- {queue_aiops-0.6.0 → queue_aiops-0.8.0}/PKG-INFO +1 -1
- queue_aiops-0.8.0/docs/VERIFICATION.md +97 -0
- {queue_aiops-0.6.0 → queue_aiops-0.8.0}/pyproject.toml +1 -1
- {queue_aiops-0.6.0 → queue_aiops-0.8.0}/queue_aiops/cli/_common.py +47 -0
- {queue_aiops-0.6.0 → queue_aiops-0.8.0}/queue_aiops/cli/rabbit_cmds.py +10 -7
- {queue_aiops-0.6.0 → queue_aiops-0.8.0}/queue_aiops/cli/redis_cmds.py +3 -2
- {queue_aiops-0.6.0 → queue_aiops-0.8.0}/queue_aiops/cli/undo.py +2 -1
- {queue_aiops-0.6.0 → queue_aiops-0.8.0}/queue_aiops/connection.py +27 -2
- {queue_aiops-0.6.0 → queue_aiops-0.8.0}/queue_aiops/ops/rabbit_reads.py +9 -3
- {queue_aiops-0.6.0 → queue_aiops-0.8.0}/queue_aiops/ops/writes.py +4 -4
- {queue_aiops-0.6.0 → queue_aiops-0.8.0}/server.json +2 -2
- queue_aiops-0.8.0/tests/test_cli_governed_exit.py +167 -0
- {queue_aiops-0.6.0 → queue_aiops-0.8.0}/tests/test_connection.py +53 -0
- {queue_aiops-0.6.0 → queue_aiops-0.8.0}/tests/test_reads.py +9 -0
- {queue_aiops-0.6.0 → queue_aiops-0.8.0}/tests/test_writes.py +36 -0
- {queue_aiops-0.6.0 → queue_aiops-0.8.0}/uv.lock +1 -1
- queue_aiops-0.6.0/docs/VERIFICATION.md +0 -41
- {queue_aiops-0.6.0 → queue_aiops-0.8.0}/.github/workflows/mcp-publish.yml +0 -0
- {queue_aiops-0.6.0 → queue_aiops-0.8.0}/.github/workflows/publish.yml +0 -0
- {queue_aiops-0.6.0 → queue_aiops-0.8.0}/.gitignore +0 -0
- {queue_aiops-0.6.0 → queue_aiops-0.8.0}/Dockerfile +0 -0
- {queue_aiops-0.6.0 → queue_aiops-0.8.0}/LICENSE +0 -0
- {queue_aiops-0.6.0 → queue_aiops-0.8.0}/README.md +0 -0
- {queue_aiops-0.6.0 → queue_aiops-0.8.0}/RELEASE_NOTES.md +0 -0
- {queue_aiops-0.6.0 → queue_aiops-0.8.0}/SECURITY.md +0 -0
- {queue_aiops-0.6.0 → queue_aiops-0.8.0}/glama.json +0 -0
- {queue_aiops-0.6.0 → queue_aiops-0.8.0}/mcp_server/__init__.py +0 -0
- {queue_aiops-0.6.0 → queue_aiops-0.8.0}/mcp_server/_shared.py +0 -0
- {queue_aiops-0.6.0 → queue_aiops-0.8.0}/mcp_server/server.py +0 -0
- {queue_aiops-0.6.0 → queue_aiops-0.8.0}/mcp_server/tools/__init__.py +0 -0
- {queue_aiops-0.6.0 → queue_aiops-0.8.0}/mcp_server/tools/analysis.py +0 -0
- {queue_aiops-0.6.0 → queue_aiops-0.8.0}/mcp_server/tools/overview.py +0 -0
- {queue_aiops-0.6.0 → queue_aiops-0.8.0}/mcp_server/tools/rabbit_reads.py +0 -0
- {queue_aiops-0.6.0 → queue_aiops-0.8.0}/mcp_server/tools/redis_reads.py +0 -0
- {queue_aiops-0.6.0 → queue_aiops-0.8.0}/mcp_server/tools/undo.py +0 -0
- {queue_aiops-0.6.0 → queue_aiops-0.8.0}/mcp_server/tools/writes.py +0 -0
- {queue_aiops-0.6.0 → queue_aiops-0.8.0}/queue_aiops/__init__.py +0 -0
- {queue_aiops-0.6.0 → queue_aiops-0.8.0}/queue_aiops/cli/__init__.py +0 -0
- {queue_aiops-0.6.0 → queue_aiops-0.8.0}/queue_aiops/cli/_root.py +0 -0
- {queue_aiops-0.6.0 → queue_aiops-0.8.0}/queue_aiops/cli/analyze.py +0 -0
- {queue_aiops-0.6.0 → queue_aiops-0.8.0}/queue_aiops/cli/doctor.py +0 -0
- {queue_aiops-0.6.0 → queue_aiops-0.8.0}/queue_aiops/cli/init.py +0 -0
- {queue_aiops-0.6.0 → queue_aiops-0.8.0}/queue_aiops/cli/overview.py +0 -0
- {queue_aiops-0.6.0 → queue_aiops-0.8.0}/queue_aiops/cli/secret.py +0 -0
- {queue_aiops-0.6.0 → queue_aiops-0.8.0}/queue_aiops/config.py +0 -0
- {queue_aiops-0.6.0 → queue_aiops-0.8.0}/queue_aiops/doctor.py +0 -0
- {queue_aiops-0.6.0 → queue_aiops-0.8.0}/queue_aiops/governance/__init__.py +0 -0
- {queue_aiops-0.6.0 → queue_aiops-0.8.0}/queue_aiops/governance/audit.py +0 -0
- {queue_aiops-0.6.0 → queue_aiops-0.8.0}/queue_aiops/governance/budget.py +0 -0
- {queue_aiops-0.6.0 → queue_aiops-0.8.0}/queue_aiops/governance/decorators.py +0 -0
- {queue_aiops-0.6.0 → queue_aiops-0.8.0}/queue_aiops/governance/outcome.py +0 -0
- {queue_aiops-0.6.0 → queue_aiops-0.8.0}/queue_aiops/governance/paths.py +0 -0
- {queue_aiops-0.6.0 → queue_aiops-0.8.0}/queue_aiops/governance/patterns.py +0 -0
- {queue_aiops-0.6.0 → queue_aiops-0.8.0}/queue_aiops/governance/policy.py +0 -0
- {queue_aiops-0.6.0 → queue_aiops-0.8.0}/queue_aiops/governance/sanitize.py +0 -0
- {queue_aiops-0.6.0 → queue_aiops-0.8.0}/queue_aiops/governance/undo.py +0 -0
- {queue_aiops-0.6.0 → queue_aiops-0.8.0}/queue_aiops/ops/__init__.py +0 -0
- {queue_aiops-0.6.0 → queue_aiops-0.8.0}/queue_aiops/ops/_util.py +0 -0
- {queue_aiops-0.6.0 → queue_aiops-0.8.0}/queue_aiops/ops/analysis.py +0 -0
- {queue_aiops-0.6.0 → queue_aiops-0.8.0}/queue_aiops/ops/overview.py +0 -0
- {queue_aiops-0.6.0 → queue_aiops-0.8.0}/queue_aiops/ops/redis_reads.py +0 -0
- {queue_aiops-0.6.0 → queue_aiops-0.8.0}/queue_aiops/platform.py +0 -0
- {queue_aiops-0.6.0 → queue_aiops-0.8.0}/queue_aiops/secretstore.py +0 -0
- {queue_aiops-0.6.0 → queue_aiops-0.8.0}/skills/queue-aiops/SKILL.md +0 -0
- {queue_aiops-0.6.0 → queue_aiops-0.8.0}/skills/queue-aiops/references/agent-guardrails.md +0 -0
- {queue_aiops-0.6.0 → queue_aiops-0.8.0}/skills/queue-aiops/references/capabilities.md +0 -0
- {queue_aiops-0.6.0 → queue_aiops-0.8.0}/skills/queue-aiops/references/cli-reference.md +0 -0
- {queue_aiops-0.6.0 → queue_aiops-0.8.0}/skills/queue-aiops/references/setup-guide.md +0 -0
- {queue_aiops-0.6.0 → queue_aiops-0.8.0}/smithery.yaml +0 -0
- {queue_aiops-0.6.0 → queue_aiops-0.8.0}/tests/conftest.py +0 -0
- {queue_aiops-0.6.0 → queue_aiops-0.8.0}/tests/fakes.py +0 -0
- {queue_aiops-0.6.0 → queue_aiops-0.8.0}/tests/test_analysis.py +0 -0
- {queue_aiops-0.6.0 → queue_aiops-0.8.0}/tests/test_cli_rabbit_writes.py +0 -0
- {queue_aiops-0.6.0 → queue_aiops-0.8.0}/tests/test_cli_reads.py +0 -0
- {queue_aiops-0.6.0 → queue_aiops-0.8.0}/tests/test_cli_secret.py +0 -0
- {queue_aiops-0.6.0 → queue_aiops-0.8.0}/tests/test_cli_writes.py +0 -0
- {queue_aiops-0.6.0 → queue_aiops-0.8.0}/tests/test_doctor.py +0 -0
- {queue_aiops-0.6.0 → queue_aiops-0.8.0}/tests/test_gov_audit.py +0 -0
- {queue_aiops-0.6.0 → queue_aiops-0.8.0}/tests/test_gov_decorators.py +0 -0
- {queue_aiops-0.6.0 → queue_aiops-0.8.0}/tests/test_gov_patterns.py +0 -0
- {queue_aiops-0.6.0 → queue_aiops-0.8.0}/tests/test_gov_policy.py +0 -0
- {queue_aiops-0.6.0 → queue_aiops-0.8.0}/tests/test_governance_persistence.py +0 -0
- {queue_aiops-0.6.0 → queue_aiops-0.8.0}/tests/test_init.py +0 -0
- {queue_aiops-0.6.0 → queue_aiops-0.8.0}/tests/test_int_fidelity.py +0 -0
- {queue_aiops-0.6.0 → queue_aiops-0.8.0}/tests/test_mcp_reads.py +0 -0
- {queue_aiops-0.6.0 → queue_aiops-0.8.0}/tests/test_optional_fields.py +0 -0
- {queue_aiops-0.6.0 → queue_aiops-0.8.0}/tests/test_platform.py +0 -0
- {queue_aiops-0.6.0 → queue_aiops-0.8.0}/tests/test_secretstore.py +0 -0
- {queue_aiops-0.6.0 → queue_aiops-0.8.0}/tests/test_self_kill.py +0 -0
- {queue_aiops-0.6.0 → queue_aiops-0.8.0}/tests/test_self_lockout.py +0 -0
- {queue_aiops-0.6.0 → queue_aiops-0.8.0}/tests/test_smoke.py +0 -0
- {queue_aiops-0.6.0 → queue_aiops-0.8.0}/tests/test_undo_executor.py +0 -0
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v0.8.0 — 2026-08-10
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
- **An undetermined outcome no longer exits as a plain failure.** A write whose response was lost carries *both* `error` and `outcomeUnknown`, and the harness deliberately judges unknown first when writing the audit row — the change may have taken effect, so a blind retry could apply it twice. The CLI guard judged `error` first, so the audit said "may have taken effect" while the exit status told a script it had not happened. The two layers now agree (exit 2, not 1), and a test pins the ordering so it cannot silently flip back.
|
|
7
|
+
- **The CLI reported a refused or failed governed write as a success.** 8 write call sites printed the governed twin's payload and exited **0** whatever it said — and `@tool_errors` flattens every refusal, guard rejection and upstream failure into `{"error": ...}` rather than raising, so nothing downstream of a `&&` chain or a CI step could tell a blocked write from a landed one. The dry-run path already exited non-zero, which made the asymmetry worse: the preview was stricter than the write it previews. Results now route through a `checked()` helper — exit 1 on an error payload, exit 2 on an undetermined outcome, unchanged on success. This defect class had been fixed repo-by-repo several times and kept coming back; an audit across the whole line found it live in **18 of the 24 tools at once (87 call sites)**, so each tool now carries an invariant test that fails if any future CLI command prints a governed result without checking it.
|
|
8
|
+
|
|
9
|
+
## v0.7.0 — 2026-08-10
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
- **`purge_queue` could never have worked against a real RabbitMQ.** The management-API client sent `Accept: application/json`, but the purge endpoint (`DELETE /api/queues/{vhost}/{name}/contents`) answers *204 No Content* and offers no JSON representation, so content negotiation failed and the broker returned **406 with an empty body** — on every purge, on every server. Accept is now `application/json, */*`; every other endpoint (GETs, PUT declare, DELETE queue, DELETE policy) was enumerated against a live broker and behaves identically either way. Verified on RabbitMQ 3.13.7 by purging a real 9-message backlog.
|
|
13
|
+
- **A 406 now explains itself.** The broker sends no body with a 406, so the generic branch produced a message with nothing in it — and, because the platform label already ends in "API", it read "RabbitMQ management API **API error**". There is now a 406 branch naming content negotiation as the cause, and the generic branch no longer doubles the word.
|
|
14
|
+
- **Two more integer quantities stayed integers** (bug class #2): a node's `memUsedBytes` / `memLimitBytes` came back as floats (`161222656.0`) while the `diskFreeBytes` beside them was an int — one payload disagreeing with itself — and the per-peer `channels` count rendered as `1.0` for one channel, from a `0.0` accumulator seed. Both found on a live broker; regression tests assert the type.
|
|
15
|
+
- **Write-path integer quantities stay integers** (bug class #2/#4). `ops/writes.py` still rendered whole-second and count fields through the float helper `num()` — `kill_client`'s `priorState.ageSeconds` came back as `49.0`, and the RabbitMQ purge/policy captures rendered `messages` and `priority` as floats too. It was missed in the earlier read-path sweep (the module did not even import `as_int`) and was inconsistent with `list_clients`, which already rendered the identical CLIENT LIST `age` field as an `int`. Now uses `as_int`; a regression test asserts the *type* (equality cannot catch `49 == 49.0`). Found live while verifying `kill-client` against a real blocked `BLPOP` client on Redis 7.4.
|
|
16
|
+
|
|
3
17
|
## v0.6.0 — 2026-08-03
|
|
4
18
|
|
|
5
19
|
### Fixed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: queue-aiops
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.0
|
|
4
4
|
Summary: Governed AI-ops for redis + rabbitmq: memory-pressure/latency/queue-backlog/connection-churn RCAs, slowlog and SCAN-budgeted big-key sampling, queues/policies/nodes, and governed writes (config set, client kill, purge/delete queue, policies) with a built-in governance harness (audit, budget, undo, risk tiers)
|
|
5
5
|
Author-email: Wei Zhou <zhouwei008@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# Live verification status
|
|
2
|
+
|
|
3
|
+
This document records what has and has not been validated against real Redis /
|
|
4
|
+
RabbitMQ servers, so the maturity claim is auditable rather than a vibe.
|
|
5
|
+
|
|
6
|
+
## Already live-verified ✅ — Redis 7.4.9, 2026-07-19
|
|
7
|
+
|
|
8
|
+
Exercised end-to-end against a real **Redis 7.4.9** server (Docker) seeded with
|
|
9
|
+
202 keys and a list-backed job queue:
|
|
10
|
+
|
|
11
|
+
- `doctor` against a live server: PING OK, no-AUTH lab path reported clearly.
|
|
12
|
+
- Every Redis read cross-checked against `redis-cli` ground truth:
|
|
13
|
+
`overview`, `redis info/memory/clients/slowlog/keyspace/bigkeys`
|
|
14
|
+
(key count matched `DBSIZE` exactly).
|
|
15
|
+
- All four analyses ran clean: `analyze memory/latency/backlog/churn`.
|
|
16
|
+
- Governance loop end-to-end: `redis_config_set` really changed
|
|
17
|
+
`maxmemory-policy`, captured `noeviction` as `priorState`, wrote an audit row,
|
|
18
|
+
and `undo_apply` restored the prior value on the live server.
|
|
19
|
+
|
|
20
|
+
**A real defect was found and fixed by this run**: integer quantities were
|
|
21
|
+
rendered as floats — `202.0` keys, `1.0` connected clients, float byte counts —
|
|
22
|
+
because they were routed through `num()`. Values were arithmetically right but
|
|
23
|
+
semantically wrong, and equality assertions could not catch it
|
|
24
|
+
(`202 == 202.0`). Fixed with `as_int()` plus a regression test that asserts the
|
|
25
|
+
*type*, keeping genuine ratios (`hitRatePct`, `usedPctOfMax`, `opsPerSec`) as
|
|
26
|
+
floats. ⚠️ That round's claim of a fix "across the Redis and RabbitMQ reads"
|
|
27
|
+
was **overstated**: the 2026-08-10 RabbitMQ run found `memUsedBytes` /
|
|
28
|
+
`memLimitBytes` and the per-peer `channels` count still floats, and an earlier
|
|
29
|
+
2026-08-03 round found the whole *write* path untouched. The sweep was never as
|
|
30
|
+
wide as the sentence claimed — treat "fixed line-wide" claims as needing their
|
|
31
|
+
own enumeration.
|
|
32
|
+
|
|
33
|
+
## Not yet live-verified ⚠️
|
|
34
|
+
|
|
35
|
+
- ~~**RabbitMQ** — the entire `rabbitmq` command group and its management-API
|
|
36
|
+
shapes are unit-tested only. This is now the largest gap in this repo.~~
|
|
37
|
+
**Closed 2026-08-10 against a real RabbitMQ 3.13.7** (management plugin), with
|
|
38
|
+
a seeded estate: 3 queues, a real backlog, a live pika consumer holding a
|
|
39
|
+
connection/channel, and a policy.
|
|
40
|
+
- All 7 reads cross-checked against `rabbitmqctl` / `rabbitmqadmin`:
|
|
41
|
+
`overview` (33 ready messages, cluster name, node count), `queues`
|
|
42
|
+
(per-queue counts and durability exact), `queue` detail, `connections`,
|
|
43
|
+
`channels` (unacked and consumer counts), `policies`, `nodes`.
|
|
44
|
+
- All 5 writes exercised end-to-end with their undo: `declare-queue` →
|
|
45
|
+
`undo_apply` deleted it; `set-policy` → `undo_apply` removed it leaving the
|
|
46
|
+
pre-existing policy untouched; `delete-queue` → `undo_apply` re-declared it
|
|
47
|
+
with the captured `durable=false`; `purge` destroyed a real 9-message
|
|
48
|
+
backlog and correctly recorded **no** undo.
|
|
49
|
+
- 🔴 **`purge_queue` had never worked against a real broker.** The HTTP client
|
|
50
|
+
sent `Accept: application/json`, and RabbitMQ's purge endpoint
|
|
51
|
+
(`DELETE /api/queues/{vhost}/{name}/contents`) answers *204 No Content* with
|
|
52
|
+
no JSON representation, so content negotiation failed and the broker
|
|
53
|
+
returned **406 with an empty body** — every purge, on every server. The
|
|
54
|
+
other endpoints were enumerated against the live broker and behave
|
|
55
|
+
identically under either Accept value, so the fix is `application/json, */*`.
|
|
56
|
+
The 406 message was also useless (no body to quote, and the label already
|
|
57
|
+
ended in "API" so it read "…management API API error"); both fixed.
|
|
58
|
+
- 🔴 Two integer quantities rendered as floats (bug class #2): a node's
|
|
59
|
+
`memUsedBytes` / `memLimitBytes` (`161222656.0` sitting next to an integer
|
|
60
|
+
`diskFreeBytes` — one payload disagreeing with itself), and the per-peer
|
|
61
|
+
`channels` count (`1.0` for one channel, from a `0.0` accumulator seed).
|
|
62
|
+
- Still untested: clustered RabbitMQ, quorum/stream queues, and TLS (AMQPS /
|
|
63
|
+
HTTPS management).
|
|
64
|
+
- ~~Redis **cluster / sentinel** topologies (only standalone was exercised).~~
|
|
65
|
+
**Both exercised 2026-08-03**, and the cluster one found a defect: a node's
|
|
66
|
+
`totalKeys` was reported as the dataset total (100 on a 3-master cluster
|
|
67
|
+
holding 300). Reads, `overview` and the RCAs otherwise behaved correctly on a
|
|
68
|
+
cluster node, on a Sentinel-fronted primary, and on its replica (`role:
|
|
69
|
+
slave`). Still untested: an actual **failover** through Sentinel, and any
|
|
70
|
+
cluster-wide aggregate — this tool talks to one endpoint by design, which is
|
|
71
|
+
exactly why the scope marker matters.
|
|
72
|
+
- ~~AUTH-enabled Redis and TLS connections.~~ **Both verified 2026-08-03**
|
|
73
|
+
against a real Redis 7.4 (Docker). AUTH: without the password every read
|
|
74
|
+
fails with a clear `Authentication required` envelope (nulls + `errors`, not
|
|
75
|
+
a silent empty); with the password via the encrypted-store/env fallback,
|
|
76
|
+
`overview`/`keyspace` matched `DBSIZE` exactly. TLS: `use_tls` with
|
|
77
|
+
`verify_ssl: false` connects over a `rediss://` socket to a self-signed
|
|
78
|
+
instance and returns the right key count; `verify_ssl: true` against the same
|
|
79
|
+
self-signed cert fails with `CERTIFICATE_VERIFY_FAILED` — TLS verification is
|
|
80
|
+
really enforced, not silently downgraded.
|
|
81
|
+
- ~~`kill-client` against a real blocked client.~~ **Verified 2026-08-03**: a
|
|
82
|
+
client genuinely blocked on `BLPOP` was listed by `redis clients` (with the
|
|
83
|
+
tool's own connection correctly excluded), the `--dry-run` preview recorded a
|
|
84
|
+
`wouldKill` audit row *without* killing, the real kill removed it server-side
|
|
85
|
+
(confirmed via `CLIENT LIST`), and its CLIENT LIST row was captured as
|
|
86
|
+
`priorState`. Both the preview and the real call wrote audit rows through the
|
|
87
|
+
same governed twin.
|
|
88
|
+
|
|
89
|
+
**A real defect was found and fixed by this run** (bug class #2/#4): the write
|
|
90
|
+
path rendered integer quantities as floats — `ageSeconds: 49.0` in
|
|
91
|
+
`kill_client`'s `priorState`, and likewise `messages`/`priority` in the
|
|
92
|
+
RabbitMQ purge/policy captures — because `ops/writes.py` still routed them
|
|
93
|
+
through `num()`. It was missed in the earlier read-path sweep (it did not even
|
|
94
|
+
import `as_int`), and was inconsistent with `list_clients`, which already
|
|
95
|
+
rendered the identical CLIENT LIST `age` field as an `int`. Fixed with
|
|
96
|
+
`as_int()` and a type-asserting regression test (equality cannot catch it:
|
|
97
|
+
`49 == 49.0`); `ageSeconds` re-confirmed as `4` (int) on a live re-kill.
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "queue-aiops"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.8.0"
|
|
8
8
|
description = "Governed AI-ops for redis + rabbitmq: memory-pressure/latency/queue-backlog/connection-churn RCAs, slowlog and SCAN-budgeted big-key sampling, queues/policies/nodes, and governed writes (config set, client kill, purge/delete queue, policies) with a built-in governance harness (audit, budget, undo, risk tiers)"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -76,6 +76,53 @@ def dry_run_print(*, operation: str, api_call: str, parameters: dict | None = No
|
|
|
76
76
|
console.print("[magenta] Run without --dry-run to execute.[/]\n")
|
|
77
77
|
|
|
78
78
|
|
|
79
|
+
#: Exit status for a write whose outcome could not be determined. Kept distinct
|
|
80
|
+
#: from 0 (confirmed) and 1 (failed) on purpose: a write whose response was lost
|
|
81
|
+
#: is not a failure, but it is emphatically not a success either, and a script
|
|
82
|
+
#: must be able to tell all three apart.
|
|
83
|
+
EXIT_UNDETERMINED = 2
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def checked(result: Any) -> Any:
|
|
87
|
+
"""Return ``result``, or abort when it reports a failed/undetermined write.
|
|
88
|
+
|
|
89
|
+
Every CLI command that calls a governed twin MUST pass the result through
|
|
90
|
+
here before printing its success line.
|
|
91
|
+
|
|
92
|
+
Governed twins are wrapped in ``@tool_errors``, which flattens any exception
|
|
93
|
+
into ``{"error": ...}`` and **returns** it. The CLI therefore never sees the
|
|
94
|
+
exception, so a command that prints its result unconditionally reports a
|
|
95
|
+
refused or failed operation exactly like a successful one — and exits 0, so
|
|
96
|
+
a script cannot tell either. The dry-run path already refused with a
|
|
97
|
+
non-zero status, which made the asymmetry worse: the preview was stricter
|
|
98
|
+
than the real call.
|
|
99
|
+
|
|
100
|
+
``outcomeUnknown`` (set by the harness when a write's response is lost) is
|
|
101
|
+
neither success nor failure — the change may still have landed. It gets its
|
|
102
|
+
own line and :data:`EXIT_UNDETERMINED`, never a silent success.
|
|
103
|
+
"""
|
|
104
|
+
if not isinstance(result, dict):
|
|
105
|
+
return result
|
|
106
|
+
error = result.get("error")
|
|
107
|
+
# ``outcomeUnknown`` is judged BEFORE ``error``, matching the harness: a
|
|
108
|
+
# write whose response was lost carries BOTH keys, and it is audited
|
|
109
|
+
# `unknown` precisely because it may have taken effect. Reporting that as a
|
|
110
|
+
# plain failure would tell a script the change did not happen and invite the
|
|
111
|
+
# double-apply the payload's own note warns about.
|
|
112
|
+
if result.get("outcomeUnknown"):
|
|
113
|
+
console.print(
|
|
114
|
+
f"[yellow]Outcome undetermined: {result.get('note') or ''}[/]"
|
|
115
|
+
)
|
|
116
|
+
raise typer.Exit(EXIT_UNDETERMINED)
|
|
117
|
+
if error:
|
|
118
|
+
console.print(f"[red]Error: {error}[/]")
|
|
119
|
+
hint = result.get("hint")
|
|
120
|
+
if hint:
|
|
121
|
+
console.print(f"[dim]{hint}[/]")
|
|
122
|
+
raise typer.Exit(1)
|
|
123
|
+
return result
|
|
124
|
+
|
|
125
|
+
|
|
79
126
|
def dry_run_preview(
|
|
80
127
|
preview: Any, *, operation: str, api_call: str, parameters: dict | None = None
|
|
81
128
|
) -> None:
|
|
@@ -10,6 +10,7 @@ import typer
|
|
|
10
10
|
from queue_aiops.cli._common import (
|
|
11
11
|
DryRunOption,
|
|
12
12
|
TargetOption,
|
|
13
|
+
checked,
|
|
13
14
|
cli_errors,
|
|
14
15
|
console,
|
|
15
16
|
double_confirm,
|
|
@@ -157,7 +158,7 @@ def rabbit_purge(
|
|
|
157
158
|
parameters={"vhost": vhost, "name": name})
|
|
158
159
|
return
|
|
159
160
|
double_confirm("purge queue", name)
|
|
160
|
-
console.print_json(json.dumps(gov.purge_queue(vhost=vhost, name=name, target=target)))
|
|
161
|
+
console.print_json(json.dumps(checked(gov.purge_queue(vhost=vhost, name=name, target=target))))
|
|
161
162
|
|
|
162
163
|
|
|
163
164
|
@rabbitmq_app.command("delete-queue")
|
|
@@ -180,7 +181,7 @@ def rabbit_delete_queue(
|
|
|
180
181
|
parameters={"vhost": vhost, "name": name})
|
|
181
182
|
return
|
|
182
183
|
double_confirm("delete queue", name)
|
|
183
|
-
console.print_json(json.dumps(gov.delete_queue(vhost=vhost, name=name, target=target)))
|
|
184
|
+
console.print_json(json.dumps(checked(gov.delete_queue(vhost=vhost, name=name, target=target))))
|
|
184
185
|
|
|
185
186
|
|
|
186
187
|
@rabbitmq_app.command("declare-queue")
|
|
@@ -211,9 +212,9 @@ def rabbit_declare_queue(
|
|
|
211
212
|
"auto_delete": auto_delete})
|
|
212
213
|
return
|
|
213
214
|
double_confirm("declare queue", name)
|
|
214
|
-
console.print_json(json.dumps(gov.declare_queue(
|
|
215
|
+
console.print_json(json.dumps(checked(gov.declare_queue(
|
|
215
216
|
vhost=vhost, name=name, durable=durable, auto_delete=auto_delete, target=target
|
|
216
|
-
)))
|
|
217
|
+
))))
|
|
217
218
|
|
|
218
219
|
|
|
219
220
|
@rabbitmq_app.command("set-policy")
|
|
@@ -252,10 +253,10 @@ def rabbit_set_policy(
|
|
|
252
253
|
"apply_to": apply_to})
|
|
253
254
|
return
|
|
254
255
|
double_confirm("set policy", name)
|
|
255
|
-
console.print_json(json.dumps(gov.set_policy(
|
|
256
|
+
console.print_json(json.dumps(checked(gov.set_policy(
|
|
256
257
|
vhost=vhost, name=name, pattern=pattern, definition=definition_obj,
|
|
257
258
|
priority=priority, apply_to=apply_to, target=target,
|
|
258
|
-
)))
|
|
259
|
+
))))
|
|
259
260
|
|
|
260
261
|
|
|
261
262
|
@rabbitmq_app.command("delete-policy")
|
|
@@ -278,4 +279,6 @@ def rabbit_delete_policy(
|
|
|
278
279
|
parameters={"vhost": vhost, "name": name})
|
|
279
280
|
return
|
|
280
281
|
double_confirm("delete policy", name)
|
|
281
|
-
console.print_json(
|
|
282
|
+
console.print_json(
|
|
283
|
+
json.dumps(checked(gov.delete_policy(vhost=vhost, name=name, target=target)))
|
|
284
|
+
)
|
|
@@ -10,6 +10,7 @@ import typer
|
|
|
10
10
|
from queue_aiops.cli._common import (
|
|
11
11
|
DryRunOption,
|
|
12
12
|
TargetOption,
|
|
13
|
+
checked,
|
|
13
14
|
cli_errors,
|
|
14
15
|
console,
|
|
15
16
|
double_confirm,
|
|
@@ -138,7 +139,7 @@ def redis_config_set(
|
|
|
138
139
|
return
|
|
139
140
|
double_confirm("set config parameter", parameter)
|
|
140
141
|
console.print_json(
|
|
141
|
-
json.dumps(gov.redis_config_set(parameter=parameter, value=value, target=target))
|
|
142
|
+
json.dumps(checked(gov.redis_config_set(parameter=parameter, value=value, target=target)))
|
|
142
143
|
)
|
|
143
144
|
|
|
144
145
|
|
|
@@ -165,5 +166,5 @@ def redis_kill_client(
|
|
|
165
166
|
return
|
|
166
167
|
double_confirm("kill client", who or "(unspecified)")
|
|
167
168
|
console.print_json(
|
|
168
|
-
json.dumps(gov.redis_kill_client(client_id=client_id, addr=addr, target=target))
|
|
169
|
+
json.dumps(checked(gov.redis_kill_client(client_id=client_id, addr=addr, target=target)))
|
|
169
170
|
)
|
|
@@ -15,6 +15,7 @@ import typer
|
|
|
15
15
|
from queue_aiops.cli._common import (
|
|
16
16
|
DryRunOption,
|
|
17
17
|
TargetOption,
|
|
18
|
+
checked,
|
|
18
19
|
cli_errors,
|
|
19
20
|
console,
|
|
20
21
|
double_confirm,
|
|
@@ -65,4 +66,4 @@ def undo_apply_cmd(
|
|
|
65
66
|
)
|
|
66
67
|
return
|
|
67
68
|
double_confirm("apply undo", undo_id)
|
|
68
|
-
console.print_json(json.dumps(gov.undo_apply(undo_id=undo_id, target=target)))
|
|
69
|
+
console.print_json(json.dumps(checked(gov.undo_apply(undo_id=undo_id, target=target))))
|
|
@@ -63,12 +63,25 @@ def _teaching_message(status: int, path: str, body: str, label: str) -> str:
|
|
|
63
63
|
f"Bad request (400) on {label} {path}. The broker rejected the "
|
|
64
64
|
f"request — check required fields and value formats. {snippet}"
|
|
65
65
|
)
|
|
66
|
+
if status == 406:
|
|
67
|
+
# Content negotiation, and the broker sends NO body with it — so the
|
|
68
|
+
# generic branch produced a message that explained nothing at all
|
|
69
|
+
# (measured while purge_queue was failing on RabbitMQ 3.13.7).
|
|
70
|
+
return (
|
|
71
|
+
f"Content negotiation failed (406) on {label} {path}. The endpoint "
|
|
72
|
+
f"has no representation matching the request's Accept header — "
|
|
73
|
+
f"endpoints that answer '204 No Content' need '*/*' in Accept, not "
|
|
74
|
+
f"'application/json' alone. The broker sends no body with a 406, so "
|
|
75
|
+
f"there is nothing further to quote. {snippet}"
|
|
76
|
+
).strip()
|
|
66
77
|
if status in (500, 502, 503, 504):
|
|
67
78
|
return (
|
|
68
79
|
f"{label} server error ({status}) on {path}. The broker may be "
|
|
69
80
|
f"busy; retry shortly. {snippet}"
|
|
70
81
|
)
|
|
71
|
-
|
|
82
|
+
# `label` already ends in "API" for rabbitmq ("RabbitMQ management API"),
|
|
83
|
+
# so the old "{label} API error" read "…management API API error".
|
|
84
|
+
return f"{label} error ({status}) on {path}. {snippet}".strip()
|
|
72
85
|
|
|
73
86
|
|
|
74
87
|
class QueueConnection:
|
|
@@ -102,7 +115,19 @@ class QueueConnection:
|
|
|
102
115
|
verify=target.verify_ssl,
|
|
103
116
|
timeout=_TIMEOUT,
|
|
104
117
|
auth=httpx.BasicAuth(target.username, target.secret),
|
|
105
|
-
|
|
118
|
+
# `*/*` must stay in Accept. RabbitMQ's purge endpoint
|
|
119
|
+
# (DELETE /api/queues/{vhost}/{name}/contents) answers 204 with no
|
|
120
|
+
# body and offers no JSON representation, so a bare
|
|
121
|
+
# "Accept: application/json" fails content negotiation and the
|
|
122
|
+
# server returns **406 with an empty body** — measured on 3.13.7.
|
|
123
|
+
# purge_queue could therefore never succeed against a real broker.
|
|
124
|
+
# Every other endpoint (GETs, PUT declare, DELETE queue, DELETE
|
|
125
|
+
# policy) behaves identically under either value; enumerated, not
|
|
126
|
+
# assumed.
|
|
127
|
+
headers={
|
|
128
|
+
"Accept": "application/json, */*",
|
|
129
|
+
"Content-Type": "application/json",
|
|
130
|
+
},
|
|
106
131
|
)
|
|
107
132
|
|
|
108
133
|
@property
|
|
@@ -132,7 +132,9 @@ def list_connections(conn: Any) -> dict:
|
|
|
132
132
|
by_host: dict[str, dict] = {}
|
|
133
133
|
for r in rows:
|
|
134
134
|
bucket = by_host.setdefault(
|
|
135
|
-
|
|
135
|
+
# A channel count seeded as 0.0 makes the whole sum a float —
|
|
136
|
+
# "1.0 channels" for one channel. Both members are counts.
|
|
137
|
+
r["peerHost"], {"peerHost": r["peerHost"], "connections": 0, "channels": 0}
|
|
136
138
|
)
|
|
137
139
|
bucket["connections"] += 1
|
|
138
140
|
bucket["channels"] += r["channels"]
|
|
@@ -211,8 +213,12 @@ def node_health(conn: Any) -> dict:
|
|
|
211
213
|
try:
|
|
212
214
|
rows = []
|
|
213
215
|
for n in conn.platform.rows(conn.get(conn.platform.path("nodes"))):
|
|
214
|
-
|
|
215
|
-
|
|
216
|
+
# Byte counts, not ratios — every sibling byte field below already
|
|
217
|
+
# used as_int, so routing these two through the float helper made a
|
|
218
|
+
# single payload disagree with itself (memUsedBytes 161222656.0
|
|
219
|
+
# next to diskFreeBytes 36616110080).
|
|
220
|
+
mem_used = as_int(n.get("mem_used"))
|
|
221
|
+
mem_limit = as_int(n.get("mem_limit"))
|
|
216
222
|
rows.append(
|
|
217
223
|
{
|
|
218
224
|
"name": opt(n.get("name"), 64),
|
|
@@ -42,7 +42,7 @@ import re
|
|
|
42
42
|
from typing import Any
|
|
43
43
|
|
|
44
44
|
from queue_aiops.connection import QueueApiError
|
|
45
|
-
from queue_aiops.ops._util import
|
|
45
|
+
from queue_aiops.ops._util import as_int, as_obj, opt, s
|
|
46
46
|
|
|
47
47
|
# CONFIG SET parameter names are plain words (e.g. maxmemory-policy); reject
|
|
48
48
|
# anything else before it reaches the wire.
|
|
@@ -220,7 +220,7 @@ def kill_client(conn: Any, client_id: int = 0, addr: str = "") -> dict:
|
|
|
220
220
|
"addr": opt(c.get("addr"), 64),
|
|
221
221
|
"name": opt(c.get("name"), 64),
|
|
222
222
|
"lastCommand": opt(c.get("cmd"), 64),
|
|
223
|
-
"ageSeconds":
|
|
223
|
+
"ageSeconds": as_int(c.get("age")),
|
|
224
224
|
}
|
|
225
225
|
break
|
|
226
226
|
killed = (
|
|
@@ -246,7 +246,7 @@ def _queue_def(conn: Any, vhost: str, name: str) -> dict:
|
|
|
246
246
|
conn.platform.normalise(conn.get(conn.platform.path("queue", vhost=vhost, name=name)))
|
|
247
247
|
)
|
|
248
248
|
return {
|
|
249
|
-
"messages":
|
|
249
|
+
"messages": as_int(obj.get("messages")),
|
|
250
250
|
"durable": bool(obj.get("durable")),
|
|
251
251
|
"autoDelete": bool(obj.get("auto_delete")),
|
|
252
252
|
"arguments": as_obj(obj.get("arguments")),
|
|
@@ -350,7 +350,7 @@ def _policy_or_none(conn: Any, vhost: str, name: str) -> dict | None:
|
|
|
350
350
|
return {
|
|
351
351
|
"pattern": opt(obj.get("pattern"), 128),
|
|
352
352
|
"definition": as_obj(obj.get("definition")),
|
|
353
|
-
"priority":
|
|
353
|
+
"priority": as_int(obj.get("priority")),
|
|
354
354
|
"applyTo": opt(obj.get("apply-to") or obj.get("apply_to") or "all", 32),
|
|
355
355
|
}
|
|
356
356
|
|
|
@@ -7,12 +7,12 @@
|
|
|
7
7
|
"url": "https://github.com/AIops-tools/Queue-AIops",
|
|
8
8
|
"source": "github"
|
|
9
9
|
},
|
|
10
|
-
"version": "0.
|
|
10
|
+
"version": "0.8.0",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "pypi",
|
|
14
14
|
"identifier": "queue-aiops",
|
|
15
|
-
"version": "0.
|
|
15
|
+
"version": "0.8.0",
|
|
16
16
|
"transport": {
|
|
17
17
|
"type": "stdio"
|
|
18
18
|
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""The CLI must never report a failed governed write as a success.
|
|
2
|
+
|
|
3
|
+
Two regressions are pinned here.
|
|
4
|
+
|
|
5
|
+
**The behaviour** — ``checked()`` aborts non-zero on an error payload and on an
|
|
6
|
+
undetermined outcome. Governed twins are wrapped in ``@tool_errors``, which
|
|
7
|
+
flattens every exception into ``{"error": ...}`` and *returns* it, so a
|
|
8
|
+
command that prints its result unconditionally exits 0 for an operation that
|
|
9
|
+
was refused or failed.
|
|
10
|
+
|
|
11
|
+
**The invariant** — every CLI call into a governed write twin routes its result
|
|
12
|
+
through that helper. This is the part that matters over time: the defect was
|
|
13
|
+
fixed repo-by-repo several times and kept coming back, because nothing stopped
|
|
14
|
+
a *new* command from printing a governed result bare. A structural assertion
|
|
15
|
+
does. It was introduced after an audit found the preview path guarded and the
|
|
16
|
+
real write path unguarded in 18 of the 24 tools at once — the preview was
|
|
17
|
+
stricter than the write it previews.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import ast
|
|
23
|
+
import pathlib
|
|
24
|
+
|
|
25
|
+
import pytest
|
|
26
|
+
import typer
|
|
27
|
+
|
|
28
|
+
from queue_aiops.cli._common import checked
|
|
29
|
+
|
|
30
|
+
CLI_DIR = pathlib.Path(__file__).resolve().parents[1] / "queue_aiops" / "cli"
|
|
31
|
+
TOOLS_DIR = pathlib.Path(__file__).resolve().parents[1] / "mcp_server" / "tools"
|
|
32
|
+
|
|
33
|
+
#: Helpers that end the command non-zero when the payload reports a failure.
|
|
34
|
+
CHECKERS = ['checked', 'dry_run_preview']
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@pytest.mark.unit
|
|
38
|
+
def test_checked_aborts_on_error_payload():
|
|
39
|
+
with pytest.raises(typer.Exit) as exc:
|
|
40
|
+
checked({"error": "refused: that would lock this tool out"})
|
|
41
|
+
assert exc.value.exit_code == 1
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@pytest.mark.unit
|
|
45
|
+
def test_checked_passes_a_successful_payload_through():
|
|
46
|
+
payload = {"action": "did_a_thing", "ok": True}
|
|
47
|
+
assert checked(payload) == payload
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _callee(node: ast.AST) -> str | None:
|
|
51
|
+
if isinstance(node, ast.Name):
|
|
52
|
+
return node.id
|
|
53
|
+
if isinstance(node, ast.Attribute):
|
|
54
|
+
return node.attr
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _write_tool_names() -> set[str]:
|
|
59
|
+
names: set[str] = set()
|
|
60
|
+
for path in TOOLS_DIR.glob("*.py"):
|
|
61
|
+
tree = ast.parse(path.read_text())
|
|
62
|
+
for node in ast.walk(tree):
|
|
63
|
+
if isinstance(node, ast.FunctionDef) and "[WRITE]" in (ast.get_docstring(node) or ""):
|
|
64
|
+
names.add(node.name)
|
|
65
|
+
return names
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _aborts_on_failure(fn: ast.FunctionDef) -> bool:
|
|
69
|
+
"""Does this function raise ``typer.Exit`` off a failure key in the payload?
|
|
70
|
+
|
|
71
|
+
Several commands guard with a locally named helper (``_require_ok``,
|
|
72
|
+
``_emit``, ...) rather than the shared one. Those are correct guards, so the
|
|
73
|
+
invariant recognises the *shape* instead of a list of blessed names.
|
|
74
|
+
"""
|
|
75
|
+
raises_exit = any(
|
|
76
|
+
isinstance(n, ast.Call) and _callee(n.func) == "Exit" for n in ast.walk(fn)
|
|
77
|
+
)
|
|
78
|
+
reads_failure_key = any(
|
|
79
|
+
isinstance(n, ast.Constant) and n.value in ("error", "outcomeUnknown")
|
|
80
|
+
for n in ast.walk(fn)
|
|
81
|
+
)
|
|
82
|
+
return raises_exit and reads_failure_key
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _checker_names() -> set[str]:
|
|
86
|
+
"""CHECKERS, every local guard, and any wrapper delegating to one of them."""
|
|
87
|
+
funcs: dict[str, list[ast.FunctionDef]] = {}
|
|
88
|
+
for path in CLI_DIR.rglob("*.py"):
|
|
89
|
+
for node in ast.walk(ast.parse(path.read_text())):
|
|
90
|
+
if isinstance(node, ast.FunctionDef):
|
|
91
|
+
funcs.setdefault(node.name, []).append(node)
|
|
92
|
+
found = set(CHECKERS) | {
|
|
93
|
+
name for name, defs in funcs.items() if any(_aborts_on_failure(f) for f in defs)
|
|
94
|
+
}
|
|
95
|
+
changed = True
|
|
96
|
+
while changed:
|
|
97
|
+
changed = False
|
|
98
|
+
for name, defs in funcs.items():
|
|
99
|
+
if name in found:
|
|
100
|
+
continue
|
|
101
|
+
for fn in defs:
|
|
102
|
+
if any(isinstance(n, ast.Call) and _callee(n.func) in found for n in ast.walk(fn)):
|
|
103
|
+
found.add(name)
|
|
104
|
+
changed = True
|
|
105
|
+
break
|
|
106
|
+
return found
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@pytest.mark.unit
|
|
110
|
+
def test_every_cli_governed_write_result_is_checked():
|
|
111
|
+
tools = _write_tool_names()
|
|
112
|
+
assert tools, "no [WRITE] tools parsed — the invariant would vacuously pass"
|
|
113
|
+
checkers = _checker_names()
|
|
114
|
+
unchecked: list[str] = []
|
|
115
|
+
|
|
116
|
+
for path in sorted(CLI_DIR.rglob("*.py")):
|
|
117
|
+
tree = ast.parse(path.read_text())
|
|
118
|
+
for func in [n for n in ast.walk(tree) if isinstance(n, ast.FunctionDef)]:
|
|
119
|
+
parents: dict[ast.AST, ast.AST] = {}
|
|
120
|
+
for node in ast.walk(func):
|
|
121
|
+
for child in ast.iter_child_nodes(node):
|
|
122
|
+
parents[child] = node
|
|
123
|
+
guarded = {
|
|
124
|
+
arg.id
|
|
125
|
+
for node in ast.walk(func)
|
|
126
|
+
if isinstance(node, ast.Call) and _callee(node.func) in checkers
|
|
127
|
+
for arg in list(node.args) + [kw.value for kw in node.keywords]
|
|
128
|
+
if isinstance(arg, ast.Name)
|
|
129
|
+
}
|
|
130
|
+
for node in ast.walk(func):
|
|
131
|
+
if not (isinstance(node, ast.Call) and _callee(node.func) in tools):
|
|
132
|
+
continue
|
|
133
|
+
parent = parents.get(node)
|
|
134
|
+
if isinstance(parent, (ast.keyword, ast.Starred)):
|
|
135
|
+
parent = parents.get(parent)
|
|
136
|
+
if isinstance(parent, ast.Call) and _callee(parent.func) in checkers:
|
|
137
|
+
continue
|
|
138
|
+
if isinstance(parent, ast.Assign) and any(
|
|
139
|
+
isinstance(t, ast.Name) and t.id in guarded for t in parent.targets
|
|
140
|
+
):
|
|
141
|
+
continue
|
|
142
|
+
unchecked.append(f"{path.name}:{node.lineno}:{_callee(node.func)}")
|
|
143
|
+
|
|
144
|
+
assert not unchecked, (
|
|
145
|
+
"these CLI call sites print a governed write's result without routing it "
|
|
146
|
+
f"through {sorted(checkers)}, so a refusal exits 0: {unchecked}"
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
@pytest.mark.unit
|
|
150
|
+
def test_undetermined_outcome_beats_error_and_is_not_a_plain_failure():
|
|
151
|
+
"""A lost response carries BOTH keys — it must exit 2, not 1.
|
|
152
|
+
|
|
153
|
+
``@tool_errors`` flattens the exception into ``{"error": ...}`` and, when
|
|
154
|
+
the failure is one where the write may still have landed, also sets
|
|
155
|
+
``outcomeUnknown``. The harness deliberately judges unknown *before* error
|
|
156
|
+
when writing the audit row, for the reason its own note gives: the change
|
|
157
|
+
may have taken effect, so a blind retry could apply it twice. The CLI has to
|
|
158
|
+
agree, or the audit says "may have taken effect" while the exit status tells
|
|
159
|
+
a script it did not happen.
|
|
160
|
+
"""
|
|
161
|
+
with pytest.raises(typer.Exit) as exc:
|
|
162
|
+
checked({
|
|
163
|
+
"error": "connection reset while awaiting the response",
|
|
164
|
+
"outcomeUnknown": True,
|
|
165
|
+
"note": "may have taken effect",
|
|
166
|
+
})
|
|
167
|
+
assert exc.value.exit_code == 2
|
|
@@ -239,3 +239,56 @@ def test_manager_unknown_target_teaches_available(fake_build):
|
|
|
239
239
|
mgr = ConnectionManager(_cfg())
|
|
240
240
|
with pytest.raises(KeyError, match="Available: cache1, broker1"):
|
|
241
241
|
mgr.connect("ghost")
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
@pytest.mark.unit
|
|
245
|
+
def test_rabbitmq_accept_header_keeps_a_wildcard_for_no_content_endpoints(monkeypatch):
|
|
246
|
+
"""Accept must not be JSON-only, or the purge endpoint 406s.
|
|
247
|
+
|
|
248
|
+
Measured on a real RabbitMQ 3.13.7: ``DELETE /api/queues/{vhost}/{name}
|
|
249
|
+
/contents`` answers 204 with no body and offers no JSON representation, so
|
|
250
|
+
a bare ``Accept: application/json`` fails content negotiation and the broker
|
|
251
|
+
returns **406 with an empty body** — meaning ``purge_queue`` could never
|
|
252
|
+
succeed against a real server. Adding ``*/*`` returns 204, and every other
|
|
253
|
+
endpoint (GETs, PUT declare, DELETE queue, DELETE policy) answers the same
|
|
254
|
+
either way; that was enumerated against the live broker, not assumed.
|
|
255
|
+
|
|
256
|
+
Nothing else pins this header, which is why the defect survived: the fake
|
|
257
|
+
HTTP client in the unit suite never negotiates content.
|
|
258
|
+
"""
|
|
259
|
+
monkeypatch.setenv("QUEUE_BROKER1_SECRET", "pw")
|
|
260
|
+
client = QueueConnection._build_client(rabbit_target())
|
|
261
|
+
try:
|
|
262
|
+
accept = client.headers["accept"]
|
|
263
|
+
finally:
|
|
264
|
+
client.close()
|
|
265
|
+
assert "application/json" in accept
|
|
266
|
+
assert "*/*" in accept, (
|
|
267
|
+
"purge (DELETE .../contents) 406s when Accept is JSON-only — keep */*"
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
@pytest.mark.unit
|
|
272
|
+
def test_rabbitmq_406_explains_content_negotiation():
|
|
273
|
+
"""A 406 carries no body, so the generic branch explained nothing.
|
|
274
|
+
|
|
275
|
+
Encountered live while purge_queue was failing: the operator got
|
|
276
|
+
"RabbitMQ management API API error (406) on /api/queues/%2F/q/contents. "
|
|
277
|
+
— a doubled word and zero diagnosis. The 406 branch now names the cause,
|
|
278
|
+
and the generic branch no longer repeats "API" after a label ending in it.
|
|
279
|
+
"""
|
|
280
|
+
conn = rabbit_conn({("GET", "/api/x"): FakeResponse(406, text="")})
|
|
281
|
+
with pytest.raises(QueueApiError) as err:
|
|
282
|
+
conn.get("/api/x")
|
|
283
|
+
msg = str(err.value)
|
|
284
|
+
assert "Content negotiation failed (406)" in msg
|
|
285
|
+
assert "Accept" in msg
|
|
286
|
+
assert "API API error" not in msg
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
@pytest.mark.unit
|
|
290
|
+
def test_rabbitmq_unmapped_status_does_not_double_the_word_api():
|
|
291
|
+
conn = rabbit_conn({("GET", "/api/x"): FakeResponse(418, text="teapot")})
|
|
292
|
+
with pytest.raises(QueueApiError) as err:
|
|
293
|
+
conn.get("/api/x")
|
|
294
|
+
assert "API API error" not in str(err.value)
|
|
@@ -239,6 +239,9 @@ def test_list_connections_groups_by_peer_host():
|
|
|
239
239
|
assert out["total"] == 3
|
|
240
240
|
assert out["byPeerHost"][0]["peerHost"] == "10.0.0.7"
|
|
241
241
|
assert out["byPeerHost"][0]["channels"] == 4
|
|
242
|
+
# A channel count, not a rate: seeding the accumulator at 0.0 rendered one
|
|
243
|
+
# channel as "1.0" on a live broker. Equality cannot catch it (4 == 4.0).
|
|
244
|
+
assert isinstance(out["byPeerHost"][0]["channels"], int)
|
|
242
245
|
|
|
243
246
|
|
|
244
247
|
@pytest.mark.unit
|
|
@@ -274,6 +277,12 @@ def test_node_health_flags_alarms():
|
|
|
274
277
|
out = rabbit_reads.node_health(conn)
|
|
275
278
|
assert out["alarms"] == 1
|
|
276
279
|
assert out["nodes"][0]["memAlarm"] is True
|
|
280
|
+
# Byte counts stay integers. On a live broker these two came back as
|
|
281
|
+
# 161222656.0 / 13462616473.0 while diskFreeBytes beside them was an int —
|
|
282
|
+
# one payload disagreeing with itself (bug class #2).
|
|
283
|
+
node = out["nodes"][0]
|
|
284
|
+
for field in ("memUsedBytes", "memLimitBytes", "diskFreeBytes"):
|
|
285
|
+
assert isinstance(node[field], int), field
|
|
277
286
|
|
|
278
287
|
|
|
279
288
|
@pytest.mark.unit
|
|
@@ -71,6 +71,42 @@ def test_kill_client_requires_id_or_addr():
|
|
|
71
71
|
ops.kill_client(conn)
|
|
72
72
|
|
|
73
73
|
|
|
74
|
+
@pytest.mark.unit
|
|
75
|
+
def test_write_prior_state_integer_quantities_stay_int():
|
|
76
|
+
"""priorState counts/whole-seconds must be int, not float (bug class #2/#4).
|
|
77
|
+
|
|
78
|
+
CLIENT LIST ``age`` (whole seconds), a queue's ``messages`` count and a
|
|
79
|
+
policy ``priority`` are integer quantities. The write path rendered them
|
|
80
|
+
through ``num`` → ``120.0``/``1234.0``/``3.0`` — inconsistent with the read
|
|
81
|
+
path (``list_clients`` already uses ``as_int``) and semantically wrong. An
|
|
82
|
+
equality assertion cannot catch this (``120 == 120.0``); assert the *type*.
|
|
83
|
+
``bool`` must not sneak through the int check (it subclasses int).
|
|
84
|
+
"""
|
|
85
|
+
kc = ops.kill_client(
|
|
86
|
+
redis_conn(clients=[{"id": "77", "addr": "10.0.0.5:5000", "cmd": "blpop",
|
|
87
|
+
"age": "120"}]),
|
|
88
|
+
client_id=77,
|
|
89
|
+
)
|
|
90
|
+
age = kc["priorState"]["client"]["ageSeconds"]
|
|
91
|
+
assert isinstance(age, int) and not isinstance(age, bool) and age == 120
|
|
92
|
+
|
|
93
|
+
purge = ops.purge_queue(
|
|
94
|
+
rabbit_conn({("GET", "/api/queues/%2F/orders"): _Q,
|
|
95
|
+
("DELETE", "/api/queues/%2F/orders/contents"): {}}),
|
|
96
|
+
"/", "orders",
|
|
97
|
+
)
|
|
98
|
+
msgs = purge["priorState"]["messages"]
|
|
99
|
+
assert isinstance(msgs, int) and not isinstance(msgs, bool) and msgs == 1234
|
|
100
|
+
|
|
101
|
+
policy = ops.set_policy(
|
|
102
|
+
rabbit_conn({("GET", "/api/policies/%2F/lim"): _POLICY,
|
|
103
|
+
("PUT", "/api/policies/%2F/lim"): {}}),
|
|
104
|
+
"/", "lim", "^work\\.", {"max-length": 9000}, priority=4,
|
|
105
|
+
)
|
|
106
|
+
prio = policy["priorState"]["policy"]["priority"]
|
|
107
|
+
assert isinstance(prio, int) and not isinstance(prio, bool) and prio == 3
|
|
108
|
+
|
|
109
|
+
|
|
74
110
|
# ── rabbitmq purge/delete prior-state capture ────────────────────────────────
|
|
75
111
|
|
|
76
112
|
_Q = {"name": "orders", "vhost": "/", "messages": 1234, "durable": True,
|
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
# Live verification status
|
|
2
|
-
|
|
3
|
-
This document records what has and has not been validated against real Redis /
|
|
4
|
-
RabbitMQ servers, so the maturity claim is auditable rather than a vibe.
|
|
5
|
-
|
|
6
|
-
## Already live-verified ✅ — Redis 7.4.9, 2026-07-19
|
|
7
|
-
|
|
8
|
-
Exercised end-to-end against a real **Redis 7.4.9** server (Docker) seeded with
|
|
9
|
-
202 keys and a list-backed job queue:
|
|
10
|
-
|
|
11
|
-
- `doctor` against a live server: PING OK, no-AUTH lab path reported clearly.
|
|
12
|
-
- Every Redis read cross-checked against `redis-cli` ground truth:
|
|
13
|
-
`overview`, `redis info/memory/clients/slowlog/keyspace/bigkeys`
|
|
14
|
-
(key count matched `DBSIZE` exactly).
|
|
15
|
-
- All four analyses ran clean: `analyze memory/latency/backlog/churn`.
|
|
16
|
-
- Governance loop end-to-end: `redis_config_set` really changed
|
|
17
|
-
`maxmemory-policy`, captured `noeviction` as `priorState`, wrote an audit row,
|
|
18
|
-
and `undo_apply` restored the prior value on the live server.
|
|
19
|
-
|
|
20
|
-
**A real defect was found and fixed by this run**: integer quantities were
|
|
21
|
-
rendered as floats — `202.0` keys, `1.0` connected clients, float byte counts —
|
|
22
|
-
because they were routed through `num()`. Values were arithmetically right but
|
|
23
|
-
semantically wrong, and equality assertions could not catch it
|
|
24
|
-
(`202 == 202.0`). Fixed with `as_int()` across the Redis and RabbitMQ reads,
|
|
25
|
-
plus a regression test that asserts the *type*, keeping genuine ratios
|
|
26
|
-
(`hitRatePct`, `usedPctOfMax`, `opsPerSec`) as floats.
|
|
27
|
-
|
|
28
|
-
## Not yet live-verified ⚠️
|
|
29
|
-
|
|
30
|
-
- **RabbitMQ** — the entire `rabbitmq` command group and its management-API
|
|
31
|
-
shapes are unit-tested only. This is now the largest gap in this repo.
|
|
32
|
-
- ~~Redis **cluster / sentinel** topologies (only standalone was exercised).~~
|
|
33
|
-
**Both exercised 2026-08-03**, and the cluster one found a defect: a node's
|
|
34
|
-
`totalKeys` was reported as the dataset total (100 on a 3-master cluster
|
|
35
|
-
holding 300). Reads, `overview` and the RCAs otherwise behaved correctly on a
|
|
36
|
-
cluster node, on a Sentinel-fronted primary, and on its replica (`role:
|
|
37
|
-
slave`). Still untested: an actual **failover** through Sentinel, and any
|
|
38
|
-
cluster-wide aggregate — this tool talks to one endpoint by design, which is
|
|
39
|
-
exactly why the scope marker matters.
|
|
40
|
-
- AUTH-enabled Redis and TLS connections.
|
|
41
|
-
- `kill-client` against a real blocked client.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|