redisctl 0.1.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.
- redisctl-0.1.0/.gitignore +18 -0
- redisctl-0.1.0/PKG-INFO +7 -0
- redisctl-0.1.0/pyproject.toml +19 -0
- redisctl-0.1.0/redisctl/__init__.py +7 -0
- redisctl-0.1.0/redisctl/cli.py +214 -0
- redisctl-0.1.0/redisctl/client.py +108 -0
- redisctl-0.1.0/redisctl/discovery.py +234 -0
- redisctl-0.1.0/redisctl/main.py +47 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
.env
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
.venv/
|
|
5
|
+
dist/
|
|
6
|
+
*.egg-info/
|
|
7
|
+
.ruff_cache/
|
|
8
|
+
.playwright-mcp/
|
|
9
|
+
.claude/settings.local.json
|
|
10
|
+
.DS_Store
|
|
11
|
+
|
|
12
|
+
# 本地 MCP 配置:各人自备、可含个人令牌,不进仓库
|
|
13
|
+
.mcp.json
|
|
14
|
+
.redis-cluster/
|
|
15
|
+
|
|
16
|
+
# 本机运行的配置(由 config/gateway.example.toml 派生),不进仓库
|
|
17
|
+
config/gateway.toml
|
|
18
|
+
.env.secret
|
redisctl-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "redisctl"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Redis MCP Gateway 命令行客户端 —— 从不直连 Redis"
|
|
5
|
+
requires-python = ">=3.12"
|
|
6
|
+
dependencies = [
|
|
7
|
+
"httpx>=0.28.0",
|
|
8
|
+
"typer>=0.15.0",
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
[project.scripts]
|
|
12
|
+
redisctl = "redisctl.main:main"
|
|
13
|
+
|
|
14
|
+
[build-system]
|
|
15
|
+
requires = ["hatchling"]
|
|
16
|
+
build-backend = "hatchling.build"
|
|
17
|
+
|
|
18
|
+
[tool.hatch.build.targets.wheel]
|
|
19
|
+
packages = ["redisctl"]
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""由**发现到的**操作清单构造 typer 应用。
|
|
2
|
+
|
|
3
|
+
与网关侧的 MCP 工具生成同理:typer 需要真实的函数签名来推导参数,
|
|
4
|
+
所以按操作声明生成带显式形参的函数,而不是用一个 `**kwargs` 的通用分发。
|
|
5
|
+
|
|
6
|
+
差别在于操作清单是**运行时从网关拉来的**,不是编译期从注册表导入的 ——
|
|
7
|
+
这样 CLI 与网关不会各有一份必然分叉的清单(章程原则 IV)。
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import sys
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import typer
|
|
17
|
+
|
|
18
|
+
from .client import GatewayError, call
|
|
19
|
+
from .discovery import Operation, Param
|
|
20
|
+
|
|
21
|
+
#: CLI 自带的命令名。发现到的操作**不得**与它们重名,
|
|
22
|
+
#: 否则子命令会被静默覆盖 —— 那种错很难查。
|
|
23
|
+
BUILTIN_COMMANDS = frozenset({"version", "operations", "refresh"})
|
|
24
|
+
|
|
25
|
+
#: 网关侧的参数类型 → typer 能推导的 Python 注解
|
|
26
|
+
_TYPER_ANNOTATION = {
|
|
27
|
+
"str": "str",
|
|
28
|
+
"int": "int",
|
|
29
|
+
"float": "float",
|
|
30
|
+
"bool": "bool",
|
|
31
|
+
# 复杂类型在命令行上以 JSON 文本传入,由 _parse_value 解析
|
|
32
|
+
"dict": "str",
|
|
33
|
+
"list": "str",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _parse_value(param: Param, raw: Any) -> Any:
|
|
38
|
+
"""把命令行上的字符串转成网关期望的类型。"""
|
|
39
|
+
if raw is None:
|
|
40
|
+
return None
|
|
41
|
+
if param.type in {"dict", "list"}:
|
|
42
|
+
try:
|
|
43
|
+
return json.loads(raw)
|
|
44
|
+
except (TypeError, ValueError) as exc:
|
|
45
|
+
example = '{"a":1}' if param.type == "dict" else '["a","b"]'
|
|
46
|
+
raise typer.BadParameter(
|
|
47
|
+
f"需要 JSON 文本,例如 {example}", param_hint=f"--{param.name}"
|
|
48
|
+
) from exc
|
|
49
|
+
return raw
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _option_source(param: Param) -> str:
|
|
53
|
+
"""生成一个 typer.Option 的源码片段。"""
|
|
54
|
+
flag = param.name.replace("_", "-")
|
|
55
|
+
help_text = repr(param.description or "")
|
|
56
|
+
if param.required:
|
|
57
|
+
default = "..."
|
|
58
|
+
elif param.default is None:
|
|
59
|
+
default = "None"
|
|
60
|
+
else:
|
|
61
|
+
default = repr(param.default)
|
|
62
|
+
return f"typer.Option({default}, '--{flag}', help={help_text})"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _make_command(operation: Operation):
|
|
66
|
+
"""按操作声明生成一个 typer 命令函数。"""
|
|
67
|
+
pieces: list[str] = []
|
|
68
|
+
for param in operation.params:
|
|
69
|
+
pieces.append(f"{param.name}: {_TYPER_ANNOTATION[param.type]} = {_option_source(param)}")
|
|
70
|
+
pieces.append(
|
|
71
|
+
"environment: str | None = typer.Option("
|
|
72
|
+
"None, '--environment', '-e', "
|
|
73
|
+
"help='目标环境(如 dev / fat / prod)。省略则用网关配置的默认环境;两者都没有时会报错。')"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
source = (
|
|
77
|
+
f"def {operation.name}({', '.join(pieces)}):\n"
|
|
78
|
+
f" raw = {{k: v for k, v in locals().items() if k != 'environment'}}\n"
|
|
79
|
+
f" return _invoke({operation.name!r}, raw, environment)\n"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
def _invoke(name: str, raw: dict, environment: str | None) -> None:
|
|
83
|
+
op = _OPERATIONS_BY_NAME[name]
|
|
84
|
+
params = {}
|
|
85
|
+
for param in op.params:
|
|
86
|
+
value = raw.get(param.name)
|
|
87
|
+
if value is None:
|
|
88
|
+
continue
|
|
89
|
+
params[param.name] = _parse_value(param, value)
|
|
90
|
+
_display(name, params, environment, write=op.write)
|
|
91
|
+
|
|
92
|
+
namespace: dict[str, Any] = {"_invoke": _invoke, "typer": typer}
|
|
93
|
+
exec(compile(source, f"<generated:{operation.name}>", "exec"), namespace)
|
|
94
|
+
fn = namespace[operation.name]
|
|
95
|
+
fn.__doc__ = _help_text(operation)
|
|
96
|
+
return fn
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _help_text(operation: Operation) -> str:
|
|
100
|
+
suffix = "【写操作,只读环境会被拒绝】" if operation.write else ""
|
|
101
|
+
return f"{operation.summary}{suffix}\n\n集群行为:{operation.cluster_note}"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _display(name: str, params: dict, environment: str | None, *, write: bool) -> None:
|
|
105
|
+
try:
|
|
106
|
+
body = call(name, params, environment, write=write)
|
|
107
|
+
except GatewayError as exc:
|
|
108
|
+
typer.secho(f"[{exc.code}] {exc.message}", fg=typer.colors.RED, err=True)
|
|
109
|
+
if exc.hint:
|
|
110
|
+
typer.secho(f"提示:{exc.hint}", fg=typer.colors.YELLOW, err=True)
|
|
111
|
+
raise typer.Exit(code=1) from exc
|
|
112
|
+
typer.echo(json.dumps(body, ensure_ascii=False, indent=2, default=str))
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
#: 由 build_app 填充。生成出来的命令函数通过它按名字回查操作声明。
|
|
116
|
+
_OPERATIONS_BY_NAME: dict[str, Operation] = {}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def build_app(operations: list[Operation]) -> typer.Typer:
|
|
120
|
+
"""由发现到的操作清单构造 typer 应用。"""
|
|
121
|
+
global _OPERATIONS_BY_NAME
|
|
122
|
+
_OPERATIONS_BY_NAME = {op.name: op for op in operations}
|
|
123
|
+
|
|
124
|
+
collisions = BUILTIN_COMMANDS & set(_OPERATIONS_BY_NAME)
|
|
125
|
+
if collisions:
|
|
126
|
+
# 静默覆盖会让某个命令凭空消失,而这在远端很难排查
|
|
127
|
+
typer.secho(
|
|
128
|
+
f"网关的操作清单与 CLI 自带命令重名:{sorted(collisions)}。"
|
|
129
|
+
"请先把这些操作改名。",
|
|
130
|
+
fg=typer.colors.RED, err=True,
|
|
131
|
+
)
|
|
132
|
+
raise typer.Exit(code=2)
|
|
133
|
+
|
|
134
|
+
built = typer.Typer(
|
|
135
|
+
name="redisctl",
|
|
136
|
+
help=(
|
|
137
|
+
"Redis MCP Gateway 命令行客户端。\n\n"
|
|
138
|
+
"所有调用都经过网关的护栏、环境路由与审计 —— 本工具**从不直连 Redis**,"
|
|
139
|
+
"也不持有 Redis 凭据。"
|
|
140
|
+
),
|
|
141
|
+
no_args_is_help=True,
|
|
142
|
+
add_completion=False,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
for operation in operations:
|
|
146
|
+
built.command(name=operation.name, help=_help_text(operation))(_make_command(operation))
|
|
147
|
+
|
|
148
|
+
_register_builtins(built, operations)
|
|
149
|
+
return built
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _register_builtins(built: typer.Typer, operations: list[Operation]) -> None:
|
|
153
|
+
"""注册 CLI 自带的三个命令。"""
|
|
154
|
+
|
|
155
|
+
@built.command(name="version", help="显示 redisctl 版本。")
|
|
156
|
+
def version() -> None:
|
|
157
|
+
from . import __version__
|
|
158
|
+
|
|
159
|
+
typer.echo(f"redisctl {__version__}")
|
|
160
|
+
|
|
161
|
+
@built.command(name="refresh", help="强制重新从网关拉取操作清单(刷新本地缓存)。")
|
|
162
|
+
def refresh() -> None:
|
|
163
|
+
from .client import gateway_token, gateway_url
|
|
164
|
+
from .discovery import DiscoveryError, fetch_operations, write_cache
|
|
165
|
+
|
|
166
|
+
try:
|
|
167
|
+
fetched = fetch_operations(gateway_url(), gateway_token())
|
|
168
|
+
except DiscoveryError as exc:
|
|
169
|
+
typer.secho(f"刷新失败:{exc}", fg=typer.colors.RED, err=True)
|
|
170
|
+
raise typer.Exit(code=1) from exc
|
|
171
|
+
|
|
172
|
+
write_cache(gateway_url(), fetched)
|
|
173
|
+
typer.echo(f"已刷新,网关当前提供 {len(fetched)} 个操作。")
|
|
174
|
+
if len(fetched) != len(operations):
|
|
175
|
+
# 本次进程的命令列表已经构造完了,改动从下次调用开始生效 ——
|
|
176
|
+
# 不说清楚的话,用户会以为刷新没生效
|
|
177
|
+
typer.secho(
|
|
178
|
+
f"注意:本次运行已经按 {len(operations)} 个操作构造好命令,"
|
|
179
|
+
"新的清单从下次执行起生效。",
|
|
180
|
+
fg=typer.colors.YELLOW,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
@built.command(name="operations", help="列出从网关发现到的操作(排查用)。")
|
|
184
|
+
def list_operations(
|
|
185
|
+
only_writes: bool = typer.Option(False, "--writes", help="只列写操作"),
|
|
186
|
+
as_json: bool = typer.Option(False, "--json", help="输出原始 JSON"),
|
|
187
|
+
) -> None:
|
|
188
|
+
selected = [op for op in operations if (op.write if only_writes else True)]
|
|
189
|
+
if as_json:
|
|
190
|
+
typer.echo(
|
|
191
|
+
json.dumps([op.to_payload() for op in selected], ensure_ascii=False, indent=2)
|
|
192
|
+
)
|
|
193
|
+
return
|
|
194
|
+
typer.echo(f"从网关发现 {len(operations)} 个操作:\n")
|
|
195
|
+
for op in selected:
|
|
196
|
+
kind = "写" if op.write else "读"
|
|
197
|
+
required = " ".join(
|
|
198
|
+
f"--{p.name.replace('_', '-')}" for p in op.params if p.required
|
|
199
|
+
)
|
|
200
|
+
typer.echo(f" [{kind}] {op.name:<28} {required}")
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def run(operations: list[Operation]) -> None:
|
|
204
|
+
"""构造应用并交给 typer 运行。"""
|
|
205
|
+
build_app(operations)()
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def warn(message: str) -> None:
|
|
209
|
+
typer.secho(f"提示:{message}", fg=typer.colors.YELLOW, err=True)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def fail(message: str, code: int = 2) -> None:
|
|
213
|
+
typer.secho(message, fg=typer.colors.RED, err=True)
|
|
214
|
+
sys.exit(code)
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""调用网关的 HTTP 客户端。
|
|
2
|
+
|
|
3
|
+
**只跟网关说话,从不连 Redis。** 本包不依赖任何 Redis 客户端 ——
|
|
4
|
+
它不知道集群在哪,也不持有 Redis 凭据。这是这套设计的核心:
|
|
5
|
+
Redis 集群可以放在开发者完全不可达的私有子网里。
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
DEFAULT_TIMEOUT = 30.0
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class GatewayError(Exception):
|
|
19
|
+
"""网关返回了业务错误。带错误码与提示,供 CLI 直接展示。"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, code: str, message: str, hint: str | None = None) -> None:
|
|
22
|
+
super().__init__(message)
|
|
23
|
+
self.code = code
|
|
24
|
+
self.message = message
|
|
25
|
+
self.hint = hint
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def gateway_url() -> str:
|
|
29
|
+
url = (os.getenv("REDISCTL_API_URL") or "").strip()
|
|
30
|
+
if not url:
|
|
31
|
+
raise SystemExit(
|
|
32
|
+
"缺少环境变量 REDISCTL_API_URL(网关地址,"
|
|
33
|
+
"例如 https://host/mcp-gateway/redis/api)"
|
|
34
|
+
)
|
|
35
|
+
return url.rstrip("/")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def gateway_token() -> str:
|
|
39
|
+
token = (os.getenv("REDISCTL_TOKEN") or "").strip()
|
|
40
|
+
if not token:
|
|
41
|
+
raise SystemExit(
|
|
42
|
+
"缺少环境变量 REDISCTL_TOKEN(网关令牌)。\n"
|
|
43
|
+
"注意:不要用含 TOKEN / KEY / SECRET / AUTH 的变量名承载它 —— "
|
|
44
|
+
"某些客户端会把这类变量读成空字符串且不报警。"
|
|
45
|
+
)
|
|
46
|
+
return token
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def caller_credential_headers() -> dict[str, str]:
|
|
50
|
+
"""调用方自带的 Redis 身份凭据。
|
|
51
|
+
|
|
52
|
+
这是**转发**给网关的,不是本地用来连 Redis 的 —— 网关收到后用这个身份
|
|
53
|
+
去操作 Redis,让操作在 Redis 侧归因到你本人。
|
|
54
|
+
|
|
55
|
+
只在不为空时才带上:头存在但值为空会被网关判为畸形并拒绝(那是刻意的,
|
|
56
|
+
因为空值通常意味着环境变量没导出,静默回落会造成「成功但归因错了」)。
|
|
57
|
+
"""
|
|
58
|
+
headers: dict[str, str] = {}
|
|
59
|
+
username = (os.getenv("REDISCTL_REDIS_USERNAME") or "").strip()
|
|
60
|
+
password = os.getenv("REDISCTL_REDIS_PASSWORD") or ""
|
|
61
|
+
if username:
|
|
62
|
+
headers["X-Redis-Username"] = username
|
|
63
|
+
if password:
|
|
64
|
+
headers["X-Redis-Password"] = password
|
|
65
|
+
return headers
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def call(
|
|
69
|
+
operation_name: str,
|
|
70
|
+
params: dict[str, Any],
|
|
71
|
+
environment: str | None,
|
|
72
|
+
*,
|
|
73
|
+
write: bool,
|
|
74
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
75
|
+
) -> dict:
|
|
76
|
+
"""调用网关的一个操作,返回响应体。
|
|
77
|
+
|
|
78
|
+
读用 GET、写用 POST —— 与网关的 REST 契约一致。
|
|
79
|
+
"""
|
|
80
|
+
base = gateway_url()
|
|
81
|
+
if environment:
|
|
82
|
+
params = {**params, "environment": environment}
|
|
83
|
+
|
|
84
|
+
headers = {"Authorization": f"Bearer {gateway_token()}", **caller_credential_headers()}
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
with httpx.Client(timeout=timeout) as client:
|
|
88
|
+
if write:
|
|
89
|
+
response = client.post(f"{base}/{operation_name}", json=params, headers=headers)
|
|
90
|
+
else:
|
|
91
|
+
response = client.get(f"{base}/{operation_name}", params=params, headers=headers)
|
|
92
|
+
except httpx.HTTPError as exc:
|
|
93
|
+
raise GatewayError("connection_failed", f"无法连接网关:{exc}") from exc
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
body = response.json()
|
|
97
|
+
except ValueError as exc:
|
|
98
|
+
raise GatewayError(
|
|
99
|
+
"invalid_response", f"网关返回了非 JSON 响应(HTTP {response.status_code})"
|
|
100
|
+
) from exc
|
|
101
|
+
|
|
102
|
+
if response.status_code >= 400 or not body.get("ok", False):
|
|
103
|
+
raise GatewayError(
|
|
104
|
+
body.get("error", "error"),
|
|
105
|
+
body.get("message", "网关返回了错误"),
|
|
106
|
+
body.get("hint"),
|
|
107
|
+
)
|
|
108
|
+
return body
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""运行时发现网关的操作清单,并缓存到本地。
|
|
2
|
+
|
|
3
|
+
## 为什么是「运行时发现」而不是「把注册表复制一份」
|
|
4
|
+
|
|
5
|
+
复制两份清单必然分叉,而网关的护栏正建立在注册表的声明之上
|
|
6
|
+
(章程原则 IV)。让 CLI 在运行时从网关拉取,单一事实来源得以保持。
|
|
7
|
+
|
|
8
|
+
额外好处:网关返回的是**能力探测之后**注册的操作,所以 CLI 永远只暴露
|
|
9
|
+
网关实际提供的能力 —— 本机集群没有 JSON/Search 模块时是 36 个,
|
|
10
|
+
上游装了模块就是 40 个,CLI 自动跟上,不会出现
|
|
11
|
+
「CLI 里有这条命令,但网关根本没有」的错位。
|
|
12
|
+
|
|
13
|
+
## 缓存策略
|
|
14
|
+
|
|
15
|
+
**网络优先,失败回退缓存。** 缓存有 TTL(默认 1 小时),陈旧就重新拉;
|
|
16
|
+
拉不到时若有旧缓存就照旧用,并在 stderr 给个提示 ——
|
|
17
|
+
命令行工具不该因为一次网络抖动就完全不能用。
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import hashlib
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import time
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
import httpx
|
|
30
|
+
|
|
31
|
+
DEFAULT_TTL_SECONDS = 3600
|
|
32
|
+
#: 缓存的格式版本。格式变了要让旧缓存失效,而不是解析出错后报莫名其妙的 KeyError。
|
|
33
|
+
CACHE_SCHEMA_VERSION = 1
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class DiscoveryError(Exception):
|
|
37
|
+
"""发现操作清单失败,且没有可用缓存。"""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class Param:
|
|
42
|
+
name: str
|
|
43
|
+
type: str
|
|
44
|
+
required: bool
|
|
45
|
+
default: object = None
|
|
46
|
+
description: str = ""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class Operation:
|
|
51
|
+
name: str
|
|
52
|
+
category: str
|
|
53
|
+
write: bool
|
|
54
|
+
summary: str
|
|
55
|
+
cluster_note: str
|
|
56
|
+
params: tuple[Param, ...] = field(default_factory=tuple)
|
|
57
|
+
requires: str | None = None
|
|
58
|
+
|
|
59
|
+
@classmethod
|
|
60
|
+
def from_payload(cls, raw: dict) -> Operation:
|
|
61
|
+
return cls(
|
|
62
|
+
name=raw["name"],
|
|
63
|
+
category=raw.get("category", ""),
|
|
64
|
+
write=bool(raw.get("write", False)),
|
|
65
|
+
summary=raw.get("summary", ""),
|
|
66
|
+
cluster_note=raw.get("cluster_note", ""),
|
|
67
|
+
requires=raw.get("requires"),
|
|
68
|
+
params=tuple(
|
|
69
|
+
Param(
|
|
70
|
+
name=p["name"],
|
|
71
|
+
type=p.get("type", "str"),
|
|
72
|
+
required=bool(p.get("required", False)),
|
|
73
|
+
default=p.get("default"),
|
|
74
|
+
description=p.get("description", ""),
|
|
75
|
+
)
|
|
76
|
+
for p in raw.get("params", [])
|
|
77
|
+
),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
def to_payload(self) -> dict:
|
|
81
|
+
return {
|
|
82
|
+
"name": self.name,
|
|
83
|
+
"category": self.category,
|
|
84
|
+
"write": self.write,
|
|
85
|
+
"summary": self.summary,
|
|
86
|
+
"cluster_note": self.cluster_note,
|
|
87
|
+
"requires": self.requires,
|
|
88
|
+
"params": [
|
|
89
|
+
{
|
|
90
|
+
"name": p.name,
|
|
91
|
+
"type": p.type,
|
|
92
|
+
"required": p.required,
|
|
93
|
+
"default": p.default,
|
|
94
|
+
"description": p.description,
|
|
95
|
+
}
|
|
96
|
+
for p in self.params
|
|
97
|
+
],
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def cache_dir() -> Path:
|
|
102
|
+
base = os.getenv("XDG_CACHE_HOME") or (Path.home() / ".cache")
|
|
103
|
+
return Path(base) / "redisctl"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def cache_path(base_url: str) -> Path:
|
|
107
|
+
"""按网关地址分别缓存 —— 不同网关的操作集可能完全不同。"""
|
|
108
|
+
digest = hashlib.sha256(base_url.encode()).hexdigest()[:16]
|
|
109
|
+
return cache_dir() / f"operations-{digest}.json"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _ttl_seconds() -> int:
|
|
113
|
+
raw = (os.getenv("REDISCTL_CACHE_TTL") or "").strip()
|
|
114
|
+
if not raw:
|
|
115
|
+
return DEFAULT_TTL_SECONDS
|
|
116
|
+
try:
|
|
117
|
+
return max(0, int(raw))
|
|
118
|
+
except ValueError:
|
|
119
|
+
return DEFAULT_TTL_SECONDS
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def read_cache(base_url: str) -> tuple[list[Operation], float] | None:
|
|
123
|
+
"""读缓存,返回 (操作列表, 抓取时间戳)。没有或不可用则返回 None。"""
|
|
124
|
+
path = cache_path(base_url)
|
|
125
|
+
if not path.is_file():
|
|
126
|
+
return None
|
|
127
|
+
try:
|
|
128
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
129
|
+
except (OSError, ValueError):
|
|
130
|
+
return None
|
|
131
|
+
|
|
132
|
+
if payload.get("schema_version") != CACHE_SCHEMA_VERSION:
|
|
133
|
+
return None
|
|
134
|
+
if payload.get("base_url") != base_url:
|
|
135
|
+
return None
|
|
136
|
+
try:
|
|
137
|
+
operations = [Operation.from_payload(o) for o in payload["operations"]]
|
|
138
|
+
except (KeyError, TypeError, ValueError):
|
|
139
|
+
return None
|
|
140
|
+
return operations, float(payload.get("fetched_at", 0))
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def write_cache(base_url: str, operations: list[Operation]) -> None:
|
|
144
|
+
"""写缓存。**写失败不算错误** —— 缓存是优化,不是依赖。"""
|
|
145
|
+
path = cache_path(base_url)
|
|
146
|
+
try:
|
|
147
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
148
|
+
path.write_text(
|
|
149
|
+
json.dumps(
|
|
150
|
+
{
|
|
151
|
+
"schema_version": CACHE_SCHEMA_VERSION,
|
|
152
|
+
"base_url": base_url,
|
|
153
|
+
"fetched_at": time.time(),
|
|
154
|
+
"operations": [op.to_payload() for op in operations],
|
|
155
|
+
},
|
|
156
|
+
ensure_ascii=False,
|
|
157
|
+
),
|
|
158
|
+
encoding="utf-8",
|
|
159
|
+
)
|
|
160
|
+
except OSError:
|
|
161
|
+
pass
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def fetch_operations(base_url: str, token: str, *, timeout: float = 10.0) -> list[Operation]:
|
|
165
|
+
"""从网关拉操作清单。任何失败都抛 DiscoveryError。"""
|
|
166
|
+
url = f"{base_url.rstrip('/')}/operations"
|
|
167
|
+
try:
|
|
168
|
+
response = httpx.get(
|
|
169
|
+
url,
|
|
170
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
171
|
+
timeout=timeout,
|
|
172
|
+
)
|
|
173
|
+
except httpx.HTTPError as exc:
|
|
174
|
+
raise DiscoveryError(f"无法连接网关 {url}:{exc}") from exc
|
|
175
|
+
|
|
176
|
+
if response.status_code == 401:
|
|
177
|
+
raise DiscoveryError(
|
|
178
|
+
"网关拒绝了令牌(401)。请检查 REDISCTL_TOKEN 是否有效、是否已过期。"
|
|
179
|
+
)
|
|
180
|
+
if response.status_code != 200:
|
|
181
|
+
raise DiscoveryError(f"网关返回 HTTP {response.status_code}:{response.text[:200]}")
|
|
182
|
+
|
|
183
|
+
try:
|
|
184
|
+
payload = response.json()
|
|
185
|
+
except ValueError as exc:
|
|
186
|
+
raise DiscoveryError(
|
|
187
|
+
f"网关返回了非 JSON 响应。请确认 REDISCTL_API_URL 指向网关的 /api 路径"
|
|
188
|
+
f"(当前为 {base_url})。"
|
|
189
|
+
) from exc
|
|
190
|
+
|
|
191
|
+
if payload.get("schema_version") != CACHE_SCHEMA_VERSION:
|
|
192
|
+
raise DiscoveryError(
|
|
193
|
+
f"网关的操作清单格式版本为 {payload.get('schema_version')},"
|
|
194
|
+
f"本版本 redisctl 支持 {CACHE_SCHEMA_VERSION}。请升级 redisctl。"
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
try:
|
|
198
|
+
return [Operation.from_payload(o) for o in payload["operations"]]
|
|
199
|
+
except (KeyError, TypeError, ValueError) as exc:
|
|
200
|
+
raise DiscoveryError(f"操作清单的响应结构无法解析:{exc}") from exc
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def discover(
|
|
204
|
+
base_url: str, token: str, *, force_refresh: bool = False
|
|
205
|
+
) -> tuple[list[Operation], str | None]:
|
|
206
|
+
"""发现操作清单。
|
|
207
|
+
|
|
208
|
+
返回 (操作列表, 提示信息)。提示信息是给 stderr 的警告,正常时为 None。
|
|
209
|
+
|
|
210
|
+
顺序:新鲜缓存 → 网络 → 陈旧缓存兜底。
|
|
211
|
+
"""
|
|
212
|
+
cached = read_cache(base_url)
|
|
213
|
+
ttl = _ttl_seconds()
|
|
214
|
+
now = time.time()
|
|
215
|
+
|
|
216
|
+
if cached and not force_refresh:
|
|
217
|
+
operations, fetched_at = cached
|
|
218
|
+
if now - fetched_at < ttl:
|
|
219
|
+
return operations, None
|
|
220
|
+
|
|
221
|
+
try:
|
|
222
|
+
operations = fetch_operations(base_url, token)
|
|
223
|
+
except DiscoveryError as exc:
|
|
224
|
+
if cached:
|
|
225
|
+
operations, fetched_at = cached
|
|
226
|
+
age = int(now - fetched_at)
|
|
227
|
+
return operations, (
|
|
228
|
+
f"无法刷新操作清单({exc}),改用 {age} 秒前的缓存。"
|
|
229
|
+
"命令列表可能不是最新的。"
|
|
230
|
+
)
|
|
231
|
+
raise
|
|
232
|
+
|
|
233
|
+
write_cache(base_url, operations)
|
|
234
|
+
return operations, None
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""`redisctl` 包入口点。
|
|
2
|
+
|
|
3
|
+
顺序是固定的:**先读配置 → 再发现操作 → 最后构造命令行**。
|
|
4
|
+
因为命令列表是从网关拉来的,不先发现就无法构造 typer 应用 ——
|
|
5
|
+
这是「运行时发现」方案的固有代价,换来的是与网关零耦合、
|
|
6
|
+
且永远不会出现「CLI 有这条命令、网关却没有」的错位。
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
|
|
13
|
+
from .cli import fail, run, warn
|
|
14
|
+
from .client import gateway_token, gateway_url
|
|
15
|
+
from .discovery import DiscoveryError, discover
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def main() -> None:
|
|
19
|
+
base_url = gateway_url()
|
|
20
|
+
token = gateway_token()
|
|
21
|
+
|
|
22
|
+
# 逃逸口:让用户在网关长时间不可达时显式选择「用旧缓存」而不是每次等超时
|
|
23
|
+
force_refresh = (os.getenv("REDISCTL_REFRESH") or "").strip().lower() in {
|
|
24
|
+
"1", "true", "yes", "on",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
operations, notice = discover(base_url, token, force_refresh=force_refresh)
|
|
29
|
+
except DiscoveryError as exc:
|
|
30
|
+
fail(
|
|
31
|
+
f"无法从网关获取操作清单:{exc}\n\n"
|
|
32
|
+
"redisctl 的命令列表由网关提供,因此这一步是必须的。\n"
|
|
33
|
+
"请检查:\n"
|
|
34
|
+
f" 1. REDISCTL_API_URL 是否指向网关的 /api 路径(当前 {base_url})\n"
|
|
35
|
+
" 2. REDISCTL_TOKEN 是否有效\n"
|
|
36
|
+
" 3. 网络是否可达网关",
|
|
37
|
+
)
|
|
38
|
+
return
|
|
39
|
+
|
|
40
|
+
if notice:
|
|
41
|
+
warn(notice)
|
|
42
|
+
|
|
43
|
+
run(operations)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
if __name__ == "__main__":
|
|
47
|
+
main()
|