queue-aiops 0.5.0__tar.gz → 0.7.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.5.0 → queue_aiops-0.7.0}/CHANGELOG.md +14 -0
- {queue_aiops-0.5.0 → queue_aiops-0.7.0}/PKG-INFO +1 -1
- queue_aiops-0.7.0/docs/VERIFICATION.md +97 -0
- {queue_aiops-0.5.0 → queue_aiops-0.7.0}/mcp_server/tools/undo.py +30 -2
- {queue_aiops-0.5.0 → queue_aiops-0.7.0}/pyproject.toml +1 -1
- {queue_aiops-0.5.0 → queue_aiops-0.7.0}/queue_aiops/connection.py +27 -2
- {queue_aiops-0.5.0 → queue_aiops-0.7.0}/queue_aiops/ops/overview.py +4 -0
- {queue_aiops-0.5.0 → queue_aiops-0.7.0}/queue_aiops/ops/rabbit_reads.py +9 -3
- {queue_aiops-0.5.0 → queue_aiops-0.7.0}/queue_aiops/ops/redis_reads.py +36 -2
- {queue_aiops-0.5.0 → queue_aiops-0.7.0}/queue_aiops/ops/writes.py +4 -4
- {queue_aiops-0.5.0 → queue_aiops-0.7.0}/server.json +2 -2
- {queue_aiops-0.5.0 → queue_aiops-0.7.0}/tests/test_connection.py +53 -0
- {queue_aiops-0.5.0 → queue_aiops-0.7.0}/tests/test_reads.py +42 -0
- {queue_aiops-0.5.0 → queue_aiops-0.7.0}/tests/test_undo_executor.py +49 -0
- {queue_aiops-0.5.0 → queue_aiops-0.7.0}/tests/test_writes.py +36 -0
- {queue_aiops-0.5.0 → queue_aiops-0.7.0}/uv.lock +1 -1
- queue_aiops-0.5.0/docs/VERIFICATION.md +0 -34
- {queue_aiops-0.5.0 → queue_aiops-0.7.0}/.github/workflows/mcp-publish.yml +0 -0
- {queue_aiops-0.5.0 → queue_aiops-0.7.0}/.github/workflows/publish.yml +0 -0
- {queue_aiops-0.5.0 → queue_aiops-0.7.0}/.gitignore +0 -0
- {queue_aiops-0.5.0 → queue_aiops-0.7.0}/Dockerfile +0 -0
- {queue_aiops-0.5.0 → queue_aiops-0.7.0}/LICENSE +0 -0
- {queue_aiops-0.5.0 → queue_aiops-0.7.0}/README.md +0 -0
- {queue_aiops-0.5.0 → queue_aiops-0.7.0}/RELEASE_NOTES.md +0 -0
- {queue_aiops-0.5.0 → queue_aiops-0.7.0}/SECURITY.md +0 -0
- {queue_aiops-0.5.0 → queue_aiops-0.7.0}/glama.json +0 -0
- {queue_aiops-0.5.0 → queue_aiops-0.7.0}/mcp_server/__init__.py +0 -0
- {queue_aiops-0.5.0 → queue_aiops-0.7.0}/mcp_server/_shared.py +0 -0
- {queue_aiops-0.5.0 → queue_aiops-0.7.0}/mcp_server/server.py +0 -0
- {queue_aiops-0.5.0 → queue_aiops-0.7.0}/mcp_server/tools/__init__.py +0 -0
- {queue_aiops-0.5.0 → queue_aiops-0.7.0}/mcp_server/tools/analysis.py +0 -0
- {queue_aiops-0.5.0 → queue_aiops-0.7.0}/mcp_server/tools/overview.py +0 -0
- {queue_aiops-0.5.0 → queue_aiops-0.7.0}/mcp_server/tools/rabbit_reads.py +0 -0
- {queue_aiops-0.5.0 → queue_aiops-0.7.0}/mcp_server/tools/redis_reads.py +0 -0
- {queue_aiops-0.5.0 → queue_aiops-0.7.0}/mcp_server/tools/writes.py +0 -0
- {queue_aiops-0.5.0 → queue_aiops-0.7.0}/queue_aiops/__init__.py +0 -0
- {queue_aiops-0.5.0 → queue_aiops-0.7.0}/queue_aiops/cli/__init__.py +0 -0
- {queue_aiops-0.5.0 → queue_aiops-0.7.0}/queue_aiops/cli/_common.py +0 -0
- {queue_aiops-0.5.0 → queue_aiops-0.7.0}/queue_aiops/cli/_root.py +0 -0
- {queue_aiops-0.5.0 → queue_aiops-0.7.0}/queue_aiops/cli/analyze.py +0 -0
- {queue_aiops-0.5.0 → queue_aiops-0.7.0}/queue_aiops/cli/doctor.py +0 -0
- {queue_aiops-0.5.0 → queue_aiops-0.7.0}/queue_aiops/cli/init.py +0 -0
- {queue_aiops-0.5.0 → queue_aiops-0.7.0}/queue_aiops/cli/overview.py +0 -0
- {queue_aiops-0.5.0 → queue_aiops-0.7.0}/queue_aiops/cli/rabbit_cmds.py +0 -0
- {queue_aiops-0.5.0 → queue_aiops-0.7.0}/queue_aiops/cli/redis_cmds.py +0 -0
- {queue_aiops-0.5.0 → queue_aiops-0.7.0}/queue_aiops/cli/secret.py +0 -0
- {queue_aiops-0.5.0 → queue_aiops-0.7.0}/queue_aiops/cli/undo.py +0 -0
- {queue_aiops-0.5.0 → queue_aiops-0.7.0}/queue_aiops/config.py +0 -0
- {queue_aiops-0.5.0 → queue_aiops-0.7.0}/queue_aiops/doctor.py +0 -0
- {queue_aiops-0.5.0 → queue_aiops-0.7.0}/queue_aiops/governance/__init__.py +0 -0
- {queue_aiops-0.5.0 → queue_aiops-0.7.0}/queue_aiops/governance/audit.py +0 -0
- {queue_aiops-0.5.0 → queue_aiops-0.7.0}/queue_aiops/governance/budget.py +0 -0
- {queue_aiops-0.5.0 → queue_aiops-0.7.0}/queue_aiops/governance/decorators.py +0 -0
- {queue_aiops-0.5.0 → queue_aiops-0.7.0}/queue_aiops/governance/outcome.py +0 -0
- {queue_aiops-0.5.0 → queue_aiops-0.7.0}/queue_aiops/governance/paths.py +0 -0
- {queue_aiops-0.5.0 → queue_aiops-0.7.0}/queue_aiops/governance/patterns.py +0 -0
- {queue_aiops-0.5.0 → queue_aiops-0.7.0}/queue_aiops/governance/policy.py +0 -0
- {queue_aiops-0.5.0 → queue_aiops-0.7.0}/queue_aiops/governance/sanitize.py +0 -0
- {queue_aiops-0.5.0 → queue_aiops-0.7.0}/queue_aiops/governance/undo.py +0 -0
- {queue_aiops-0.5.0 → queue_aiops-0.7.0}/queue_aiops/ops/__init__.py +0 -0
- {queue_aiops-0.5.0 → queue_aiops-0.7.0}/queue_aiops/ops/_util.py +0 -0
- {queue_aiops-0.5.0 → queue_aiops-0.7.0}/queue_aiops/ops/analysis.py +0 -0
- {queue_aiops-0.5.0 → queue_aiops-0.7.0}/queue_aiops/platform.py +0 -0
- {queue_aiops-0.5.0 → queue_aiops-0.7.0}/queue_aiops/secretstore.py +0 -0
- {queue_aiops-0.5.0 → queue_aiops-0.7.0}/skills/queue-aiops/SKILL.md +0 -0
- {queue_aiops-0.5.0 → queue_aiops-0.7.0}/skills/queue-aiops/references/agent-guardrails.md +0 -0
- {queue_aiops-0.5.0 → queue_aiops-0.7.0}/skills/queue-aiops/references/capabilities.md +0 -0
- {queue_aiops-0.5.0 → queue_aiops-0.7.0}/skills/queue-aiops/references/cli-reference.md +0 -0
- {queue_aiops-0.5.0 → queue_aiops-0.7.0}/skills/queue-aiops/references/setup-guide.md +0 -0
- {queue_aiops-0.5.0 → queue_aiops-0.7.0}/smithery.yaml +0 -0
- {queue_aiops-0.5.0 → queue_aiops-0.7.0}/tests/conftest.py +0 -0
- {queue_aiops-0.5.0 → queue_aiops-0.7.0}/tests/fakes.py +0 -0
- {queue_aiops-0.5.0 → queue_aiops-0.7.0}/tests/test_analysis.py +0 -0
- {queue_aiops-0.5.0 → queue_aiops-0.7.0}/tests/test_cli_rabbit_writes.py +0 -0
- {queue_aiops-0.5.0 → queue_aiops-0.7.0}/tests/test_cli_reads.py +0 -0
- {queue_aiops-0.5.0 → queue_aiops-0.7.0}/tests/test_cli_secret.py +0 -0
- {queue_aiops-0.5.0 → queue_aiops-0.7.0}/tests/test_cli_writes.py +0 -0
- {queue_aiops-0.5.0 → queue_aiops-0.7.0}/tests/test_doctor.py +0 -0
- {queue_aiops-0.5.0 → queue_aiops-0.7.0}/tests/test_gov_audit.py +0 -0
- {queue_aiops-0.5.0 → queue_aiops-0.7.0}/tests/test_gov_decorators.py +0 -0
- {queue_aiops-0.5.0 → queue_aiops-0.7.0}/tests/test_gov_patterns.py +0 -0
- {queue_aiops-0.5.0 → queue_aiops-0.7.0}/tests/test_gov_policy.py +0 -0
- {queue_aiops-0.5.0 → queue_aiops-0.7.0}/tests/test_governance_persistence.py +0 -0
- {queue_aiops-0.5.0 → queue_aiops-0.7.0}/tests/test_init.py +0 -0
- {queue_aiops-0.5.0 → queue_aiops-0.7.0}/tests/test_int_fidelity.py +0 -0
- {queue_aiops-0.5.0 → queue_aiops-0.7.0}/tests/test_mcp_reads.py +0 -0
- {queue_aiops-0.5.0 → queue_aiops-0.7.0}/tests/test_optional_fields.py +0 -0
- {queue_aiops-0.5.0 → queue_aiops-0.7.0}/tests/test_platform.py +0 -0
- {queue_aiops-0.5.0 → queue_aiops-0.7.0}/tests/test_secretstore.py +0 -0
- {queue_aiops-0.5.0 → queue_aiops-0.7.0}/tests/test_self_kill.py +0 -0
- {queue_aiops-0.5.0 → queue_aiops-0.7.0}/tests/test_self_lockout.py +0 -0
- {queue_aiops-0.5.0 → queue_aiops-0.7.0}/tests/test_smoke.py +0 -0
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v0.7.0 — 2026-08-10
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
- **`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.
|
|
7
|
+
- **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.
|
|
8
|
+
- **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.
|
|
9
|
+
- **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.
|
|
10
|
+
|
|
11
|
+
## v0.6.0 — 2026-08-03
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
- **A cluster node's key count no longer poses as the whole dataset.** `INFO keyspace` on a Redis Cluster node answers for that node's slots only, so `keyspace` / `overview` reported `totalKeys: 100` for a 3-master cluster actually holding 300 — a dataset-wide claim wrong by the shard count, with nothing in the payload to say otherwise. Both now carry `scope` (`node` vs `server`), `clusterMode`, and on a cluster a note explaining that the figure covers one node. Measured on a real 3-master Redis 7.4 cluster (100 / 92 / 108).
|
|
15
|
+
- **`undo apply` replays against the target the original write ran on.** It dispatched the inverse against whatever target the *caller* named — in practice the config's first entry — while the write's own target sat unused in the undo record. On a multi-target config the inverse therefore ran against the wrong host; it only looks harmless because the resource usually is not there, but two hosts holding the same name and the inverse **succeeds on the wrong one, silently**. An explicitly named target still wins. Line-wide: all 24 copies had the identical defect. Caught live in container-host-aiops, where a stop recorded against a Podman target replayed against a Portainer one.
|
|
16
|
+
|
|
3
17
|
## v0.5.0 — 2026-08-02
|
|
4
18
|
|
|
5
19
|
### Changed (BREAKING)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: queue-aiops
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.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.
|
|
@@ -87,6 +87,32 @@ def undo_list(limit: int = 50, target: Optional[str] = None) -> dict:
|
|
|
87
87
|
}
|
|
88
88
|
|
|
89
89
|
|
|
90
|
+
def _origin_target(rec: dict) -> Optional[str]:
|
|
91
|
+
"""The target the ORIGINAL write ran against, read from its recorded params.
|
|
92
|
+
|
|
93
|
+
Without this the replay goes to whatever target the caller names — in
|
|
94
|
+
practice the config's first entry — so on a multi-target config the inverse
|
|
95
|
+
runs against the wrong host. It only *looks* harmless because the resource
|
|
96
|
+
usually does not exist there; when two hosts both hold a resource with the
|
|
97
|
+
same name the inverse succeeds on the wrong one, silently.
|
|
98
|
+
|
|
99
|
+
Caught live on 2026-08-03 in container-host-aiops: a ``stop_container``
|
|
100
|
+
recorded against a Podman target replayed against a Portainer target.
|
|
101
|
+
|
|
102
|
+
A ``target`` in ``orig_params`` also means the original call accepted one,
|
|
103
|
+
so the fallback cannot hand a keyword to a tool that has no such parameter.
|
|
104
|
+
"""
|
|
105
|
+
raw = rec.get("orig_params") or ""
|
|
106
|
+
try:
|
|
107
|
+
orig = json.loads(raw) if raw else {}
|
|
108
|
+
except (ValueError, TypeError):
|
|
109
|
+
return None # unreadable: fall back to the caller/default, as before
|
|
110
|
+
if not isinstance(orig, dict):
|
|
111
|
+
return None
|
|
112
|
+
value = orig.get("target")
|
|
113
|
+
return value if isinstance(value, str) and value else None
|
|
114
|
+
|
|
115
|
+
|
|
90
116
|
@mcp.tool()
|
|
91
117
|
@governed_tool(risk_level="medium")
|
|
92
118
|
@tool_errors("dict")
|
|
@@ -150,8 +176,10 @@ def undo_apply(undo_id: str, dry_run: bool = False, target: Optional[str] = None
|
|
|
150
176
|
}
|
|
151
177
|
|
|
152
178
|
call_params = dict(params)
|
|
153
|
-
if
|
|
154
|
-
|
|
179
|
+
if "target" not in call_params:
|
|
180
|
+
chosen = target if target is not None else _origin_target(rec)
|
|
181
|
+
if chosen is not None:
|
|
182
|
+
call_params["target"] = chosen
|
|
155
183
|
result = fn(**call_params)
|
|
156
184
|
|
|
157
185
|
# Only mark applied when the inverse did not itself return an error dict.
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "queue-aiops"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.7.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"
|
|
@@ -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
|
|
@@ -54,6 +54,10 @@ def _redis_overview(conn: Any) -> dict:
|
|
|
54
54
|
"maxmemoryPolicy": mem.get("maxmemoryPolicy"),
|
|
55
55
|
"fragmentationRatio": mem.get("fragmentationRatio"),
|
|
56
56
|
"totalKeys": keys.get("totalKeys"),
|
|
57
|
+
# On a cluster node totalKeys covers this node's slots only — the scope
|
|
58
|
+
# marker has to travel with the number, not be left in a sibling call.
|
|
59
|
+
"keyScope": keys.get("scope"),
|
|
60
|
+
"clusterMode": keys.get("clusterMode"),
|
|
57
61
|
"errors": errors,
|
|
58
62
|
}
|
|
59
63
|
|
|
@@ -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),
|
|
@@ -190,8 +190,38 @@ def config_get(conn: Any, pattern: str = "*") -> dict:
|
|
|
190
190
|
return {"error": s(exc, 200)}
|
|
191
191
|
|
|
192
192
|
|
|
193
|
+
def _scope(conn: Any) -> dict:
|
|
194
|
+
"""Whether the counts that follow describe the whole dataset or one shard.
|
|
195
|
+
|
|
196
|
+
A Redis Cluster node answers ``INFO keyspace`` for **its own slots only**, so
|
|
197
|
+
on a three-master cluster holding 300 keys this reports 100 — a number that
|
|
198
|
+
is wrong by the shard count and, called ``totalKeys`` with nothing else in
|
|
199
|
+
the payload, reads as the whole dataset. Measured on a real 3-master Redis
|
|
200
|
+
7.4 cluster. The marker travels with the counts so a consumer can tell a
|
|
201
|
+
complete answer from a partial one.
|
|
202
|
+
"""
|
|
203
|
+
try:
|
|
204
|
+
mode = str(as_obj(conn.redis_info("server")).get("redis_mode") or "")
|
|
205
|
+
except Exception: # noqa: BLE001 — scope is an annotation, never the answer
|
|
206
|
+
return {"scope": "server", "clusterMode": None}
|
|
207
|
+
if mode != "cluster":
|
|
208
|
+
return {"scope": "server", "clusterMode": False}
|
|
209
|
+
return {
|
|
210
|
+
"scope": "node",
|
|
211
|
+
"clusterMode": True,
|
|
212
|
+
"note": (
|
|
213
|
+
"Redis Cluster: these counts cover THIS node's slots only, not the "
|
|
214
|
+
"whole cluster. Query each master, or use a cluster-aware client, "
|
|
215
|
+
"for a dataset-wide figure."
|
|
216
|
+
),
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
|
|
193
220
|
def keyspace(conn: Any) -> dict:
|
|
194
|
-
"""[READ] Per-db key counts + expiry coverage from INFO keyspace.
|
|
221
|
+
"""[READ] Per-db key counts + expiry coverage from INFO keyspace.
|
|
222
|
+
|
|
223
|
+
On a cluster node the counts are per-shard; the returned ``scope`` says so.
|
|
224
|
+
"""
|
|
195
225
|
try:
|
|
196
226
|
info = as_obj(conn.redis_info("keyspace"))
|
|
197
227
|
dbs = []
|
|
@@ -208,7 +238,11 @@ def keyspace(conn: Any) -> dict:
|
|
|
208
238
|
"avgTtlMs": as_int(cell.get("avg_ttl")),
|
|
209
239
|
}
|
|
210
240
|
)
|
|
211
|
-
return {
|
|
241
|
+
return {
|
|
242
|
+
"databases": dbs,
|
|
243
|
+
"totalKeys": sum(d["keys"] for d in dbs),
|
|
244
|
+
**_scope(conn),
|
|
245
|
+
}
|
|
212
246
|
except Exception as exc: # noqa: BLE001 — report as partial
|
|
213
247
|
return {"error": s(exc, 200)}
|
|
214
248
|
|
|
@@ -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.7.0",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "pypi",
|
|
14
14
|
"identifier": "queue-aiops",
|
|
15
|
-
"version": "0.
|
|
15
|
+
"version": "0.7.0",
|
|
16
16
|
"transport": {
|
|
17
17
|
"type": "stdio"
|
|
18
18
|
}
|
|
@@ -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
|
|
@@ -308,3 +317,36 @@ def test_queue_overview_rabbitmq_shape_degrades_partially():
|
|
|
308
317
|
assert out["platform"] == "rabbitmq"
|
|
309
318
|
assert out["queues"] == 12
|
|
310
319
|
assert any("nodes:" in e for e in out["errors"])
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
@pytest.mark.unit
|
|
323
|
+
def test_keyspace_marks_a_cluster_node_count_as_partial():
|
|
324
|
+
"""A cluster node's key count is per-shard and must say so.
|
|
325
|
+
|
|
326
|
+
``INFO keyspace`` on a Redis Cluster node answers for its own slots only, so
|
|
327
|
+
a 3-master cluster holding 300 keys reports 100 here. Emitting that as
|
|
328
|
+
``totalKeys`` with nothing else in the payload states a dataset-wide figure
|
|
329
|
+
that is wrong by the shard count. Measured on a real 3-master Redis 7.4
|
|
330
|
+
cluster: 100 / 92 / 108.
|
|
331
|
+
"""
|
|
332
|
+
conn = redis_conn(info={
|
|
333
|
+
"keyspace": {"db0": "keys=100,expires=0,avg_ttl=0"},
|
|
334
|
+
"server": {"redis_mode": "cluster"},
|
|
335
|
+
})
|
|
336
|
+
out = redis_reads.keyspace(conn)
|
|
337
|
+
assert out["totalKeys"] == 100
|
|
338
|
+
assert out["scope"] == "node"
|
|
339
|
+
assert out["clusterMode"] is True
|
|
340
|
+
assert "THIS node" in out["note"]
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
@pytest.mark.unit
|
|
344
|
+
def test_keyspace_on_a_standalone_server_is_scoped_whole():
|
|
345
|
+
conn = redis_conn(info={
|
|
346
|
+
"keyspace": {"db0": "keys=7,expires=0,avg_ttl=0"},
|
|
347
|
+
"server": {"redis_mode": "standalone"},
|
|
348
|
+
})
|
|
349
|
+
out = redis_reads.keyspace(conn)
|
|
350
|
+
assert out["scope"] == "server"
|
|
351
|
+
assert out["clusterMode"] is False
|
|
352
|
+
assert "note" not in out
|
|
@@ -21,18 +21,21 @@ from mcp_server.tools import undo as gov
|
|
|
21
21
|
from queue_aiops.governance import governed_tool
|
|
22
22
|
|
|
23
23
|
_CALLS: list[dict] = []
|
|
24
|
+
_TARGETS: list = []
|
|
24
25
|
|
|
25
26
|
|
|
26
27
|
@governed_tool(risk_level="low")
|
|
27
28
|
def _undo_probe(value: str = "", target=None) -> dict:
|
|
28
29
|
"""Synthetic inverse target used only by the undo-executor tests."""
|
|
29
30
|
_CALLS.append({"value": value})
|
|
31
|
+
_TARGETS.append(target)
|
|
30
32
|
return {"ok": True, "value": value}
|
|
31
33
|
|
|
32
34
|
|
|
33
35
|
@pytest.fixture
|
|
34
36
|
def gov_home(tmp_path, monkeypatch):
|
|
35
37
|
_CALLS.clear()
|
|
38
|
+
_TARGETS.clear()
|
|
36
39
|
# Register the probe only for the duration of these tests so it never
|
|
37
40
|
# pollutes the real tool registry (which exact-count smoke tests assert on).
|
|
38
41
|
mcp.add_tool(_undo_probe, name="_undo_probe")
|
|
@@ -77,6 +80,52 @@ def test_undo_apply_dispatches_inverse_and_marks_applied(gov_home):
|
|
|
77
80
|
assert uid not in {u["undoId"] for u in gov.undo_list()["undos"]}
|
|
78
81
|
|
|
79
82
|
|
|
83
|
+
@pytest.mark.unit
|
|
84
|
+
def test_undo_apply_replays_against_the_target_the_write_ran_on(gov_home):
|
|
85
|
+
"""A replay must go to the host the original write went to.
|
|
86
|
+
|
|
87
|
+
Without this the inverse ran against whatever target the caller named — in
|
|
88
|
+
practice the config's first entry — so on a multi-target config it hit the
|
|
89
|
+
wrong host. It only looked harmless because the resource usually does not
|
|
90
|
+
exist there; two hosts holding the same name and the inverse succeeds on the
|
|
91
|
+
wrong one, silently. Caught live on 2026-08-03 in container-host-aiops: a
|
|
92
|
+
stop recorded against a Podman target replayed against a Portainer target.
|
|
93
|
+
"""
|
|
94
|
+
uid = undo_mod.get_undo_store().record(
|
|
95
|
+
skill="probe",
|
|
96
|
+
tool="orig_op",
|
|
97
|
+
undo_descriptor={"tool": "_undo_probe", "params": {"value": "v1"}},
|
|
98
|
+
orig_params={"value": "v1", "target": "second-host"},
|
|
99
|
+
)
|
|
100
|
+
result = gov.undo_apply(undo_id=uid)
|
|
101
|
+
assert result["applied"] is True
|
|
102
|
+
assert _TARGETS == ["second-host"]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@pytest.mark.unit
|
|
106
|
+
def test_undo_apply_prefers_an_explicit_caller_target(gov_home):
|
|
107
|
+
"""An explicitly named target still wins — the fallback is only a default."""
|
|
108
|
+
uid = undo_mod.get_undo_store().record(
|
|
109
|
+
skill="probe",
|
|
110
|
+
tool="orig_op",
|
|
111
|
+
undo_descriptor={"tool": "_undo_probe", "params": {"value": "v1"}},
|
|
112
|
+
orig_params={"value": "v1", "target": "second-host"},
|
|
113
|
+
)
|
|
114
|
+
gov.undo_apply(undo_id=uid, target="chosen-host")
|
|
115
|
+
assert _TARGETS == ["chosen-host"]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@pytest.mark.unit
|
|
119
|
+
def test_undo_apply_survives_unreadable_orig_params(gov_home):
|
|
120
|
+
"""Corrupt orig_params must not break the replay — just lose the fallback."""
|
|
121
|
+
uid = _record(params={"value": "v1"})
|
|
122
|
+
with sqlite3.connect(gov_home / "undo.db") as db:
|
|
123
|
+
db.execute("UPDATE undo_log SET orig_params = ? WHERE undo_id = ?",
|
|
124
|
+
("{not json", uid))
|
|
125
|
+
assert gov.undo_apply(undo_id=uid)["applied"] is True
|
|
126
|
+
assert _TARGETS == [None]
|
|
127
|
+
|
|
128
|
+
|
|
80
129
|
@pytest.mark.unit
|
|
81
130
|
def test_undo_apply_dry_run_previews_without_running(gov_home):
|
|
82
131
|
uid = _record()
|
|
@@ -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,34 +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
|
-
- AUTH-enabled Redis and TLS connections.
|
|
34
|
-
- `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
|