aukeys-opscli 0.0.6__py3-none-any.whl
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.
- aukeys_opscli-0.0.6.dist-info/METADATA +15 -0
- aukeys_opscli-0.0.6.dist-info/RECORD +92 -0
- aukeys_opscli-0.0.6.dist-info/WHEEL +4 -0
- aukeys_opscli-0.0.6.dist-info/entry_points.txt +2 -0
- opscli/__init__.py +9 -0
- opscli/amazon/__init__.py +13 -0
- opscli/amazon/cli.py +5 -0
- opscli/amazon/client.py +5 -0
- opscli/amazon/commands/__init__.py +1 -0
- opscli/amazon/commands/cli.py +200 -0
- opscli/amazon/domain/__init__.py +25 -0
- opscli/amazon/domain/exceptions.py +73 -0
- opscli/amazon/domain/models.py +74 -0
- opscli/amazon/exceptions.py +21 -0
- opscli/amazon/manager.py +5 -0
- opscli/amazon/models.py +5 -0
- opscli/amazon/parser.py +5 -0
- opscli/amazon/scraper.py +5 -0
- opscli/amazon/scraping/__init__.py +12 -0
- opscli/amazon/scraping/parser.py +66 -0
- opscli/amazon/scraping/scraper.py +315 -0
- opscli/amazon/services/__init__.py +5 -0
- opscli/amazon/services/manager.py +105 -0
- opscli/amazon/transport/__init__.py +5 -0
- opscli/amazon/transport/client.py +77 -0
- opscli/auth/__init__.py +79 -0
- opscli/auth/cli.py +255 -0
- opscli/auth/commands/__init__.py +1 -0
- opscli/auth/commands/cli.py +5 -0
- opscli/auth/config.py +55 -0
- opscli/auth/core/__init__.py +1 -0
- opscli/auth/core/device_flow.py +80 -0
- opscli/auth/core/system_registry.py +89 -0
- opscli/auth/core/token_manager.py +167 -0
- opscli/auth/domain/__init__.py +23 -0
- opscli/auth/domain/exceptions.py +33 -0
- opscli/auth/exceptions.py +23 -0
- opscli/auth/storage/__init__.py +1 -0
- opscli/auth/storage/credential_store.py +163 -0
- opscli/auth/storage/crypto.py +67 -0
- opscli/cli.py +36 -0
- opscli/config.py +3 -0
- opscli/query/__init__.py +6 -0
- opscli/query/cli.py +5 -0
- opscli/query/client.py +5 -0
- opscli/query/commands/__init__.py +1 -0
- opscli/query/commands/cli.py +140 -0
- opscli/query/domain/__init__.py +23 -0
- opscli/query/domain/exceptions.py +73 -0
- opscli/query/domain/models.py +21 -0
- opscli/query/exceptions.py +21 -0
- opscli/query/manager.py +5 -0
- opscli/query/models.py +5 -0
- opscli/query/services/__init__.py +5 -0
- opscli/query/services/manager.py +373 -0
- opscli/query/transport/__init__.py +5 -0
- opscli/query/transport/client.py +57 -0
- opscli/skills/__init__.py +37 -0
- opscli/skills/cli.py +5 -0
- opscli/skills/commands/__init__.py +1 -0
- opscli/skills/commands/cli.py +389 -0
- opscli/skills/detector.py +5 -0
- opscli/skills/discovery/__init__.py +5 -0
- opscli/skills/discovery/detector.py +225 -0
- opscli/skills/domain/__init__.py +23 -0
- opscli/skills/domain/exceptions.py +51 -0
- opscli/skills/domain/models.py +144 -0
- opscli/skills/exceptions.py +5 -0
- opscli/skills/manager.py +5 -0
- opscli/skills/models.py +19 -0
- opscli/skills/services/__init__.py +5 -0
- opscli/skills/services/manager.py +276 -0
- opscli/skills/sync/__init__.py +5 -0
- opscli/skills/sync/updater.py +274 -0
- opscli/skills/templates/ops-amazon/SKILL.md +181 -0
- opscli/skills/templates/ops-amazon/data/VERSION.json +4 -0
- opscli/skills/templates/ops-auth/SKILL.md +466 -0
- opscli/skills/templates/ops-auth/data/VERSION.json +4 -0
- opscli/skills/templates/ops-dataset-query/SKILL.md +691 -0
- opscli/skills/templates/ops-dataset-query/data/VERSION.json +4 -0
- opscli/skills/templates/ops-dataset-query/data/dataset_fields.csv +1 -0
- opscli/skills/templates/ops-dataset-query/data/datasets.csv +1 -0
- opscli/skills/templates/ops-dataset-query/data/query_metadata.json +4 -0
- opscli/skills/templates/ops-dataset-query/references//346/225/260/346/215/256/346/237/245/350/257/242/346/234/215/345/212/241/345/274/200/345/217/221/350/257/264/346/230/216/346/226/207/346/241/243.md +1126 -0
- opscli/skills/templates/ops-dataset-query/scripts/core.py +140 -0
- opscli/skills/templates/ops-dataset-query/scripts/query.py +145 -0
- opscli/skills/templates/ops-dataset-query/scripts/search.py +36 -0
- opscli/skills/templates/ops-dataset-query/scripts/updater.py +106 -0
- opscli/skills/templates/ops-skills/SKILL.md +494 -0
- opscli/skills/templates/ops-skills/data/VERSION.json +4 -0
- opscli/skills/updater.py +5 -0
- opscli/version.py +19 -0
opscli/auth/cli.py
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""auth 子命令 CLI 层。
|
|
2
|
+
|
|
3
|
+
提供认证授权相关的所有 CLI 命令,包括:
|
|
4
|
+
- 授权管理:login(Device Flow 登录)、logout(清除凭证)、doctor(环境诊断)
|
|
5
|
+
- Token 操作:status(查看状态)、get(获取 JWT)、check(检测有效性)、refresh(刷新)
|
|
6
|
+
- 系统管理:list(列出系统)、sync(从 ops 同步)、add/remove(手动管理)
|
|
7
|
+
"""
|
|
8
|
+
import sys
|
|
9
|
+
import base64
|
|
10
|
+
import json as _json
|
|
11
|
+
import webbrowser
|
|
12
|
+
import typer
|
|
13
|
+
import httpx
|
|
14
|
+
from rich.console import Console
|
|
15
|
+
from rich.table import Table
|
|
16
|
+
from opscli.auth import AuthClient, BUILTIN_SYSTEMS, OPS_URL
|
|
17
|
+
from opscli.auth.storage.credential_store import CredentialStore
|
|
18
|
+
from opscli.auth.core.system_registry import SystemRegistry
|
|
19
|
+
from opscli.auth.core.device_flow import DeviceFlow
|
|
20
|
+
from opscli.auth.exceptions import (
|
|
21
|
+
NotAuthenticatedError,
|
|
22
|
+
SystemNotFoundError,
|
|
23
|
+
DeviceFlowExpiredError,
|
|
24
|
+
DeviceFlowDeniedError,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
app = typer.Typer(help="认证授权管理")
|
|
28
|
+
token_app = typer.Typer(help="JWT Token 管理")
|
|
29
|
+
system_app = typer.Typer(help="系统注册管理")
|
|
30
|
+
app.add_typer(token_app, name="token")
|
|
31
|
+
app.add_typer(system_app, name="system")
|
|
32
|
+
|
|
33
|
+
console = Console()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _display_scope(jwt: str, system: str):
|
|
37
|
+
"""解析 JWT payload 展示已授权权限范围。
|
|
38
|
+
|
|
39
|
+
JWT 格式为 header.payload.signature,此处仅解码 payload 部分。
|
|
40
|
+
Base64 URL 安全编码需要补齐 padding(=)才能正确解码。
|
|
41
|
+
"""
|
|
42
|
+
try:
|
|
43
|
+
parts = jwt.split(".")
|
|
44
|
+
if len(parts) < 2:
|
|
45
|
+
return
|
|
46
|
+
# Base64 URL 安全编码可能缺少 padding,按 4 的倍数补齐
|
|
47
|
+
padding = "=" * (-len(parts[1]) % 4)
|
|
48
|
+
payload = _json.loads(base64.urlsafe_b64decode(parts[1] + padding))
|
|
49
|
+
scope = payload.get("scope", "")
|
|
50
|
+
if scope:
|
|
51
|
+
scopes = scope.split()
|
|
52
|
+
console.print(f" [dim]权限({len(scopes)} 项):{', '.join(scopes)}[/dim]")
|
|
53
|
+
except Exception:
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _client() -> AuthClient:
|
|
58
|
+
"""创建默认配置的 AuthClient 实例。"""
|
|
59
|
+
return AuthClient()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _registry() -> SystemRegistry:
|
|
63
|
+
"""创建包含内置系统的 SystemRegistry 实例。"""
|
|
64
|
+
return SystemRegistry(builtin_systems=BUILTIN_SYSTEMS)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# ── 授权管理 ──────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
@app.command()
|
|
70
|
+
def login():
|
|
71
|
+
"""发起 Device Flow 授权(自动打开浏览器)。"""
|
|
72
|
+
store = CredentialStore()
|
|
73
|
+
flow = DeviceFlow(ops_url=OPS_URL, store=store)
|
|
74
|
+
try:
|
|
75
|
+
code = flow.request_device_code()
|
|
76
|
+
console.print(f"\n[bold]请在浏览器打开:[/bold] {code['verification_url']}")
|
|
77
|
+
console.print(f"[bold]输入验证码: [/bold] [green]{code['user_code']}[/green]")
|
|
78
|
+
console.print(f"等待授权中...({code['expires_in']} 秒内完成)\n")
|
|
79
|
+
webbrowser.open(f"{code['verification_url']}?code={code['user_code']}")
|
|
80
|
+
result = flow.poll(code["device_code"], interval=code.get("interval", 3))
|
|
81
|
+
# 登录成功后自动同步系统列表,免去用户手动运行 opscli auth system sync
|
|
82
|
+
try:
|
|
83
|
+
client = _client()
|
|
84
|
+
resp = httpx.get(
|
|
85
|
+
f"{OPS_URL}/v1/cli/systems",
|
|
86
|
+
headers=client.build_session_headers("ops"),
|
|
87
|
+
timeout=10,
|
|
88
|
+
)
|
|
89
|
+
if resp.status_code == 200:
|
|
90
|
+
_registry().sync_from_ops(resp.json().get("systems", []))
|
|
91
|
+
except Exception:
|
|
92
|
+
pass
|
|
93
|
+
console.print(f"[green]✓ 授权成功!账号:{result.get('email', '')}[/green]")
|
|
94
|
+
except (DeviceFlowExpiredError, DeviceFlowDeniedError) as e:
|
|
95
|
+
console.print(f"[red]✗ {e}[/red]")
|
|
96
|
+
raise typer.Exit(1)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@app.command()
|
|
100
|
+
def logout():
|
|
101
|
+
"""清除本地所有凭证"""
|
|
102
|
+
CredentialStore().clear()
|
|
103
|
+
console.print("[green]✓ 已退出,本地凭证已清除[/green]")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@token_app.command("status")
|
|
107
|
+
def status():
|
|
108
|
+
"""查看当前登录状态与各系统 Token 情况"""
|
|
109
|
+
c = _client()
|
|
110
|
+
data = c._store.load()
|
|
111
|
+
if not data:
|
|
112
|
+
console.print("[yellow]未登录[/yellow]")
|
|
113
|
+
return
|
|
114
|
+
console.print(f"[green]已登录[/green] {data.get('email', '')}")
|
|
115
|
+
console.print(f"Session 过期:{data.get('session_expires_at', 'N/A')}\n")
|
|
116
|
+
t = Table("别名", "系统", "Token 状态", "剩余时间(s)")
|
|
117
|
+
for s in c._registry.list_all():
|
|
118
|
+
r = c.check_token(s["alias"])
|
|
119
|
+
t.add_row(
|
|
120
|
+
s["alias"],
|
|
121
|
+
s["system_key"],
|
|
122
|
+
"[green]有效[/green]" if r["valid"] else "[red]无效/未获取[/red]",
|
|
123
|
+
str(r["expires_in"]),
|
|
124
|
+
)
|
|
125
|
+
console.print(t)
|
|
126
|
+
# 展示各系统已授权权限(从已缓存 JWT 解析)
|
|
127
|
+
store_data = c._store.load() or {}
|
|
128
|
+
tokens = store_data.get("tokens", {})
|
|
129
|
+
for s in c._registry.list_all():
|
|
130
|
+
td = tokens.get(s["system_key"])
|
|
131
|
+
if td and td.get("jwt"):
|
|
132
|
+
_display_scope(td["jwt"], s["alias"])
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# ── Token 操作 ─────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
@token_app.command("get")
|
|
138
|
+
def token_get(
|
|
139
|
+
system: str = typer.Option(..., "--system", "-s", help="系统别名"),
|
|
140
|
+
):
|
|
141
|
+
"""获取指定系统 JWT(纯文本输出,适合脚本)"""
|
|
142
|
+
try:
|
|
143
|
+
typer.echo(_client().get_token(system))
|
|
144
|
+
except (NotAuthenticatedError, SystemNotFoundError) as e:
|
|
145
|
+
console.print(f"[red]{e}[/red]", err=True)
|
|
146
|
+
raise typer.Exit(1)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@token_app.command("check")
|
|
150
|
+
def token_check(system: str = typer.Option(..., "--system", "-s")):
|
|
151
|
+
"""检测指定系统 JWT 有效性"""
|
|
152
|
+
r = _client().check_token(system)
|
|
153
|
+
if r["valid"]:
|
|
154
|
+
console.print(f"[green]✓ 有效[/green] 剩余 {r['expires_in']} 秒")
|
|
155
|
+
else:
|
|
156
|
+
console.print("[red]✗ 已过期或未获取[/red]")
|
|
157
|
+
raise typer.Exit(1)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@token_app.command("refresh")
|
|
161
|
+
def token_refresh(
|
|
162
|
+
system: str = typer.Option(None, "--system", "-s"),
|
|
163
|
+
all_systems: bool = typer.Option(False, "--all"),
|
|
164
|
+
):
|
|
165
|
+
"""刷新 JWT(--system 指定单个,--all 刷新全部)"""
|
|
166
|
+
c = _client()
|
|
167
|
+
if all_systems:
|
|
168
|
+
for alias, st in c._tm.refresh_all().items():
|
|
169
|
+
icon = "[green]✓[/green]" if st == "ok" else "[red]✗[/red]"
|
|
170
|
+
console.print(f"{icon} {alias}: {st}")
|
|
171
|
+
elif system:
|
|
172
|
+
try:
|
|
173
|
+
c.refresh_token(system)
|
|
174
|
+
console.print(f"[green]✓ {system} JWT 已刷新[/green]")
|
|
175
|
+
except Exception as e:
|
|
176
|
+
console.print(f"[red]✗ {e}[/red]")
|
|
177
|
+
raise typer.Exit(1)
|
|
178
|
+
else:
|
|
179
|
+
console.print("[red]请指定 --system 或 --all[/red]")
|
|
180
|
+
raise typer.Exit(1)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# ── 系统管理 ───────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
@system_app.command("list")
|
|
186
|
+
def system_list():
|
|
187
|
+
"""列出所有已注册系统"""
|
|
188
|
+
t = Table("别名", "System Key", "URL", "来源")
|
|
189
|
+
for s in _registry().list_all():
|
|
190
|
+
t.add_row(s["alias"], s["system_key"], s["url"], s.get("source", ""))
|
|
191
|
+
console.print(t)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@system_app.command("sync")
|
|
195
|
+
def system_sync():
|
|
196
|
+
"""从 ops 同步多实例系统列表"""
|
|
197
|
+
client = _client()
|
|
198
|
+
if not client.is_authenticated():
|
|
199
|
+
console.print("[red]未登录,请先运行: opscli auth login[/red]")
|
|
200
|
+
raise typer.Exit(1)
|
|
201
|
+
try:
|
|
202
|
+
resp = httpx.get(
|
|
203
|
+
f"{OPS_URL}/api/v1/cli/systems",
|
|
204
|
+
headers=client.build_session_headers("ops"),
|
|
205
|
+
timeout=10,
|
|
206
|
+
)
|
|
207
|
+
resp.raise_for_status()
|
|
208
|
+
systems = resp.json().get("systems", [])
|
|
209
|
+
_registry().sync_from_ops(systems)
|
|
210
|
+
console.print(f"[green]✓ 同步完成,共 {len(systems)} 个系统[/green]")
|
|
211
|
+
except Exception as e:
|
|
212
|
+
console.print(f"[red]✗ 同步失败: {e}[/red]")
|
|
213
|
+
raise typer.Exit(1)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@system_app.command("add")
|
|
217
|
+
def system_add(
|
|
218
|
+
alias: str = typer.Option(..., "--alias"),
|
|
219
|
+
url: str = typer.Option(..., "--url"),
|
|
220
|
+
key: str = typer.Option(None, "--key", help="存储键,默认由 alias 生成"),
|
|
221
|
+
):
|
|
222
|
+
"""手动添加系统实例(source=local)"""
|
|
223
|
+
system_key = key or alias.replace(" ", "_").lower()
|
|
224
|
+
_registry().add_local(alias, system_key, url)
|
|
225
|
+
console.print(f"[green]✓ 已添加:{alias}[/green]")
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
@system_app.command("remove")
|
|
229
|
+
def system_remove(alias: str = typer.Option(..., "--alias")):
|
|
230
|
+
"""移除手动添加的系统"""
|
|
231
|
+
try:
|
|
232
|
+
_registry().remove(alias)
|
|
233
|
+
console.print(f"[green]✓ 已移除:{alias}[/green]")
|
|
234
|
+
except SystemNotFoundError as e:
|
|
235
|
+
console.print(f"[red]{e}[/red]")
|
|
236
|
+
raise typer.Exit(1)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
# ── 诊断 ───────────────────────────────────────────────
|
|
240
|
+
|
|
241
|
+
@app.command()
|
|
242
|
+
def doctor():
|
|
243
|
+
"""检查登录状态与各系统连通性"""
|
|
244
|
+
c = _client()
|
|
245
|
+
console.print("[bold]opscli auth 环境检查\n[/bold]")
|
|
246
|
+
if c.is_authenticated():
|
|
247
|
+
console.print("[green]✓ 已登录[/green]")
|
|
248
|
+
else:
|
|
249
|
+
console.print("[red]✗ 未登录(运行 opscli auth login)[/red]")
|
|
250
|
+
for s in c._registry.list_all():
|
|
251
|
+
try:
|
|
252
|
+
httpx.get(s["url"], timeout=5)
|
|
253
|
+
console.print(f"[green]✓ {s['alias']} 可访问[/green]")
|
|
254
|
+
except Exception:
|
|
255
|
+
console.print(f"[red]✗ {s['alias']} 不可达 ({s['url']})[/red]")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""auth 命令层。"""
|
opscli/auth/config.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import configparser
|
|
2
|
+
from opscli.config import CONFIG_DIR
|
|
3
|
+
|
|
4
|
+
# 默认配置(生产环境)
|
|
5
|
+
DEFAULTS = {
|
|
6
|
+
"ops_url": "https://ops.api.qa.aukeyit.com/api",
|
|
7
|
+
"ops_system_url": "https://ops.api.qa.aukeyit.com",
|
|
8
|
+
"ops_token_endpoint": "/api/v1/auth/cli-token",
|
|
9
|
+
"polaris_system_url": "https://bi.api.test.aukeyit.com",
|
|
10
|
+
"polaris_token_endpoint": "/api/auth/cli-token",
|
|
11
|
+
"amazon_submit_endpoint": "",
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
CONFIG_PATH = CONFIG_DIR / "config.ini"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def load_config() -> dict:
|
|
18
|
+
"""读取配置文件,不存在则返回默认值"""
|
|
19
|
+
config = configparser.ConfigParser()
|
|
20
|
+
if CONFIG_PATH.exists():
|
|
21
|
+
config.read(CONFIG_PATH, encoding="utf-8")
|
|
22
|
+
|
|
23
|
+
section = "systems"
|
|
24
|
+
result = {}
|
|
25
|
+
for key, default in DEFAULTS.items():
|
|
26
|
+
result[key] = config.get(section, key, fallback=default)
|
|
27
|
+
return result
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def get_ops_url() -> str:
|
|
31
|
+
return load_config()["ops_url"]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def get_builtin_systems() -> list[dict]:
|
|
35
|
+
cfg = load_config()
|
|
36
|
+
return [
|
|
37
|
+
{
|
|
38
|
+
"alias": "ops",
|
|
39
|
+
"system_key": "ops",
|
|
40
|
+
"url": cfg["ops_system_url"],
|
|
41
|
+
"token_endpoint": cfg["ops_token_endpoint"],
|
|
42
|
+
"source": "builtin",
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"alias": "polaris",
|
|
46
|
+
"system_key": "polaris",
|
|
47
|
+
"url": cfg["polaris_system_url"],
|
|
48
|
+
"token_endpoint": cfg["polaris_token_endpoint"],
|
|
49
|
+
"source": "builtin",
|
|
50
|
+
},
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def get_amazon_submit_endpoint() -> str:
|
|
55
|
+
return load_config()["amazon_submit_endpoint"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# core 模块
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""OAuth2 Device Flow 授权模块(RFC 8628)。
|
|
2
|
+
|
|
3
|
+
实现 CLI 场景下的设备授权流程:
|
|
4
|
+
1. 向后端请求设备码(device_code)和验证 URL
|
|
5
|
+
2. 用户在浏览器中打开验证 URL 并输入验证码
|
|
6
|
+
3. CLI 以固定间隔轮询后端,等待用户完成授权
|
|
7
|
+
4. 授权成功后自动保存 session 到本地凭证存储
|
|
8
|
+
"""
|
|
9
|
+
import time
|
|
10
|
+
import httpx
|
|
11
|
+
from opscli.auth.exceptions import DeviceFlowExpiredError, DeviceFlowDeniedError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DeviceFlow:
|
|
15
|
+
"""OAuth2 Device Flow 授权器,管理设备码获取与轮询等待。"""
|
|
16
|
+
|
|
17
|
+
def __init__(self, ops_url: str, store):
|
|
18
|
+
"""
|
|
19
|
+
Args:
|
|
20
|
+
ops_url: 运营系统后端地址(如 https://ops.aukeys.com)
|
|
21
|
+
store: CredentialStore 实例,用于授权成功后持久化 session
|
|
22
|
+
"""
|
|
23
|
+
self._url = ops_url.rstrip("/")
|
|
24
|
+
self._store = store
|
|
25
|
+
|
|
26
|
+
def request_device_code(self) -> dict:
|
|
27
|
+
"""向后端请求设备码和验证信息。
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
dict: 包含 device_code、user_code、verification_url、expires_in 等字段
|
|
31
|
+
"""
|
|
32
|
+
resp = httpx.post(f"{self._url}/v1/cli/device/code", timeout=10)
|
|
33
|
+
resp.raise_for_status()
|
|
34
|
+
return resp.json()
|
|
35
|
+
|
|
36
|
+
def poll(self, device_code: str, interval: int = 3, max_wait: int = 300) -> dict:
|
|
37
|
+
"""轮询后端等待用户完成授权。
|
|
38
|
+
|
|
39
|
+
以 interval 为间隔持续轮询,直到用户授权、拒绝或超时。
|
|
40
|
+
授权成功后自动调用 store.save_session() 持久化凭证。
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
device_code: 设备码,由 request_device_code() 返回
|
|
44
|
+
interval: 轮询间隔(秒),默认 3 秒,由后端返回的值决定
|
|
45
|
+
max_wait: 最大等待时间(秒),默认 300 秒(5 分钟)
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
dict: 包含 session_id、email、expires_at 等授权信息
|
|
49
|
+
|
|
50
|
+
Raises:
|
|
51
|
+
DeviceFlowExpiredError: 设备码超时或等待超时
|
|
52
|
+
DeviceFlowDeniedError: 用户在浏览器中拒绝授权
|
|
53
|
+
"""
|
|
54
|
+
elapsed = 0
|
|
55
|
+
while elapsed < max_wait:
|
|
56
|
+
time.sleep(interval)
|
|
57
|
+
elapsed += interval
|
|
58
|
+
resp = httpx.get(
|
|
59
|
+
f"{self._url}/v1/cli/device/poll",
|
|
60
|
+
params={"device_code": device_code},
|
|
61
|
+
timeout=10,
|
|
62
|
+
)
|
|
63
|
+
resp.raise_for_status()
|
|
64
|
+
body = resp.json()
|
|
65
|
+
status = body.get("status")
|
|
66
|
+
if status == "authorized":
|
|
67
|
+
# 授权成功,保存 session 到本地凭证存储
|
|
68
|
+
self._store.save_session(
|
|
69
|
+
body["session_id"],
|
|
70
|
+
body.get("email", ""),
|
|
71
|
+
body.get("expires_at", ""),
|
|
72
|
+
device_code=device_code,
|
|
73
|
+
)
|
|
74
|
+
return body
|
|
75
|
+
elif status == "expired":
|
|
76
|
+
raise DeviceFlowExpiredError("设备码已超时,请重新运行: opscli auth login")
|
|
77
|
+
elif status == "denied":
|
|
78
|
+
raise DeviceFlowDeniedError("用户拒绝授权")
|
|
79
|
+
# 超过最大等待时间仍未授权
|
|
80
|
+
raise DeviceFlowExpiredError("等待超时,请重新运行: opscli auth login")
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""系统注册表模块。
|
|
2
|
+
|
|
3
|
+
管理三类系统来源:
|
|
4
|
+
- builtin:内置系统(ops/polaris),代码硬编码,不可删除(铁律9)
|
|
5
|
+
- local:用户手动添加的系统,持久化到 systems.json
|
|
6
|
+
- ops_sync:从运营系统后端同步的系统列表
|
|
7
|
+
|
|
8
|
+
系统合并优先级:local/ops_sync > builtin(同别名时用户配置覆盖内置)。
|
|
9
|
+
"""
|
|
10
|
+
import json
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from opscli.auth.exceptions import SystemNotFoundError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SystemRegistry:
|
|
16
|
+
"""系统注册表,管理内置、本地和远端同步的系统实例。"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, base_dir: Path | None = None, builtin_systems: list | None = None):
|
|
19
|
+
"""
|
|
20
|
+
Args:
|
|
21
|
+
base_dir: 配置存储目录,默认使用 CONFIG_DIR(~/.config/opscli/)
|
|
22
|
+
builtin_systems: 内置系统列表(由 auth/config.py 的 get_builtin_systems() 提供)
|
|
23
|
+
"""
|
|
24
|
+
from opscli.config import CONFIG_DIR
|
|
25
|
+
base = Path(base_dir or CONFIG_DIR)
|
|
26
|
+
base.mkdir(parents=True, exist_ok=True)
|
|
27
|
+
self._path = base / "systems.json"
|
|
28
|
+
# 内置系统以 alias 为 key 构建字典,方便快速查找和去重
|
|
29
|
+
self._builtin = {s["alias"]: s for s in (builtin_systems or [])}
|
|
30
|
+
|
|
31
|
+
def _load(self) -> list:
|
|
32
|
+
"""从 systems.json 加载用户自定义和远端同步的系统列表。"""
|
|
33
|
+
return json.loads(self._path.read_text()) if self._path.exists() else []
|
|
34
|
+
|
|
35
|
+
def _save(self, systems: list):
|
|
36
|
+
"""将系统列表持久化到 systems.json。"""
|
|
37
|
+
self._path.write_text(json.dumps(systems, ensure_ascii=False, indent=2))
|
|
38
|
+
|
|
39
|
+
def list_all(self) -> list:
|
|
40
|
+
"""返回所有系统(内置 + 用户配置),同别名时用户配置覆盖内置。"""
|
|
41
|
+
stored = {s["alias"]: s for s in self._load()}
|
|
42
|
+
# 合并策略:先 builtin 再 stored,stored 的同名项会覆盖 builtin
|
|
43
|
+
return list({**self._builtin, **stored}.values())
|
|
44
|
+
|
|
45
|
+
def get(self, alias: str) -> dict:
|
|
46
|
+
"""按别名查找系统,找不到则抛出 SystemNotFoundError。"""
|
|
47
|
+
for s in self.list_all():
|
|
48
|
+
if s["alias"] == alias:
|
|
49
|
+
return s
|
|
50
|
+
raise SystemNotFoundError(f"系统 '{alias}' 未注册")
|
|
51
|
+
|
|
52
|
+
def add_local(self, alias: str, system_key: str, url: str,
|
|
53
|
+
token_endpoint: str = "/api/auth/cli-token"):
|
|
54
|
+
"""添加或更新用户自定义系统(source=local)。
|
|
55
|
+
|
|
56
|
+
如果同别名已存在,执行替换(upsert 语义)。
|
|
57
|
+
"""
|
|
58
|
+
# 先移除同别名旧记录,再追加新记录
|
|
59
|
+
systems = [s for s in self._load() if s["alias"] != alias]
|
|
60
|
+
systems.append({
|
|
61
|
+
"alias": alias,
|
|
62
|
+
"system_key": system_key,
|
|
63
|
+
"url": url,
|
|
64
|
+
"token_endpoint": token_endpoint,
|
|
65
|
+
"source": "local",
|
|
66
|
+
})
|
|
67
|
+
self._save(systems)
|
|
68
|
+
|
|
69
|
+
def remove(self, alias: str):
|
|
70
|
+
"""移除系统(仅限 local 类型,内置系统受保护不可删除)。"""
|
|
71
|
+
if alias in self._builtin:
|
|
72
|
+
raise SystemNotFoundError(f"内置系统 '{alias}' 不可删除")
|
|
73
|
+
self._save([s for s in self._load() if s["alias"] != alias])
|
|
74
|
+
|
|
75
|
+
def sync_from_ops(self, ops_systems: list):
|
|
76
|
+
"""从运营系统后端同步系统列表。
|
|
77
|
+
|
|
78
|
+
合并策略:
|
|
79
|
+
- 保留所有非 ops_sync 来源的系统(builtin/local 不受影响)
|
|
80
|
+
- ops_sync 来源的系统以远端最新数据全量替换
|
|
81
|
+
"""
|
|
82
|
+
existing = {s["alias"]: s for s in self._load()}
|
|
83
|
+
# 保留所有非 ops_sync 的条目
|
|
84
|
+
kept = {a: s for a, s in existing.items() if s.get("source") != "ops_sync"}
|
|
85
|
+
# 用远端数据填充(不覆盖 local/builtin 的同名条目)
|
|
86
|
+
for s in ops_systems:
|
|
87
|
+
if s["alias"] not in kept:
|
|
88
|
+
kept[s["alias"]] = {**s, "source": "ops_sync"}
|
|
89
|
+
self._save(list(kept.values()))
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""JWT Token 管理模块。
|
|
2
|
+
|
|
3
|
+
负责 Token 的获取、缓存、刷新和有效性检测。
|
|
4
|
+
采用双层并发锁机制(铁律6)防止多进程/多线程重复获取 JWT:
|
|
5
|
+
- Layer 1:threading.Lock(防同进程多线程并发)
|
|
6
|
+
- Layer 2:fcntl.flock()(防多 CLI 进程并发,Windows 自动跳过)
|
|
7
|
+
|
|
8
|
+
Token 生命周期三态:valid / needs_refresh / expired
|
|
9
|
+
刷新阈值 REFRESH_THRESHOLD = 300s(距过期 5 分钟内触发)。
|
|
10
|
+
"""
|
|
11
|
+
import httpx
|
|
12
|
+
import threading
|
|
13
|
+
from datetime import datetime, timezone, timedelta
|
|
14
|
+
from opscli.auth.exceptions import NotAuthenticatedError, TokenFetchError
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
import fcntl
|
|
18
|
+
_FCNTL_AVAILABLE = True
|
|
19
|
+
except ImportError:
|
|
20
|
+
_FCNTL_AVAILABLE = False # Windows 不支持 fcntl
|
|
21
|
+
|
|
22
|
+
# 进程内线程锁:key = system_key
|
|
23
|
+
_thread_locks: dict[str, threading.Lock] = {}
|
|
24
|
+
_thread_locks_mutex = threading.Lock()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _get_thread_lock(key: str) -> threading.Lock:
|
|
28
|
+
with _thread_locks_mutex:
|
|
29
|
+
if key not in _thread_locks:
|
|
30
|
+
_thread_locks[key] = threading.Lock()
|
|
31
|
+
return _thread_locks[key]
|
|
32
|
+
|
|
33
|
+
REFRESH_THRESHOLD = 300 # 距过期 5 分钟内触发刷新(原 30 分钟过于保守)
|
|
34
|
+
MAX_JWT_TTL = 86400 # JWT 最大有效期 24 小时(86400 秒)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class TokenManager:
|
|
38
|
+
"""JWT Token 管理器,协调凭证存储和系统注册表完成 Token 生命周期管理。"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, store, registry):
|
|
41
|
+
"""
|
|
42
|
+
Args:
|
|
43
|
+
store: CredentialStore 实例,用于读写凭证
|
|
44
|
+
registry: SystemRegistry 实例,用于查找系统配置
|
|
45
|
+
"""
|
|
46
|
+
self._store = store
|
|
47
|
+
self._registry = registry
|
|
48
|
+
|
|
49
|
+
def _get_session_id(self) -> str:
|
|
50
|
+
data = self._store.load()
|
|
51
|
+
if not data or not data.get("session_id"):
|
|
52
|
+
raise NotAuthenticatedError("未登录,请运行: opscli auth login")
|
|
53
|
+
exp = datetime.fromisoformat(data["session_expires_at"])
|
|
54
|
+
# 无时区信息时视为 UTC
|
|
55
|
+
if exp.tzinfo is None:
|
|
56
|
+
exp = exp.replace(tzinfo=timezone.utc)
|
|
57
|
+
if exp < datetime.now(timezone.utc):
|
|
58
|
+
raise NotAuthenticatedError("登录已过期,请重新运行: opscli auth login")
|
|
59
|
+
return data["session_id"]
|
|
60
|
+
|
|
61
|
+
def get_session_id(self) -> str:
|
|
62
|
+
"""返回当前有效 session_id。
|
|
63
|
+
|
|
64
|
+
对外暴露公共接口,避免 SDK 直接依赖私有实现细节。
|
|
65
|
+
"""
|
|
66
|
+
return self._get_session_id()
|
|
67
|
+
|
|
68
|
+
def token_status(self, token_data: dict) -> str:
|
|
69
|
+
"""返回 Token 三态:valid / needs_refresh / expired"""
|
|
70
|
+
try:
|
|
71
|
+
exp = datetime.fromisoformat(token_data["expires_at"])
|
|
72
|
+
if exp.tzinfo is None:
|
|
73
|
+
exp = exp.replace(tzinfo=timezone.utc)
|
|
74
|
+
remaining = (exp - datetime.now(timezone.utc)).total_seconds()
|
|
75
|
+
if remaining > REFRESH_THRESHOLD:
|
|
76
|
+
return "valid"
|
|
77
|
+
if remaining > 0:
|
|
78
|
+
return "needs_refresh"
|
|
79
|
+
return "expired"
|
|
80
|
+
except (KeyError, ValueError):
|
|
81
|
+
return "expired"
|
|
82
|
+
|
|
83
|
+
def _is_valid(self, token_data: dict) -> bool:
|
|
84
|
+
"""判断 Token 是否处于 valid 状态(无需刷新)。"""
|
|
85
|
+
return self.token_status(token_data) == "valid"
|
|
86
|
+
|
|
87
|
+
def _fetch_token(self, system_key: str, url: str, endpoint: str) -> str:
|
|
88
|
+
"""向后端请求新的 JWT 并保存到凭证存储。"""
|
|
89
|
+
session_id = self.get_session_id()
|
|
90
|
+
try:
|
|
91
|
+
resp = httpx.post(
|
|
92
|
+
f"{url}{endpoint}",
|
|
93
|
+
json={"session_id": session_id},
|
|
94
|
+
timeout=10,
|
|
95
|
+
)
|
|
96
|
+
resp.raise_for_status()
|
|
97
|
+
body = resp.json()
|
|
98
|
+
# 后端返回的 expires_in 可能超出合理范围,限制为最大 24 小时
|
|
99
|
+
expires_in = min(body.get("expires_in", 7200), MAX_JWT_TTL)
|
|
100
|
+
self._store.save_token(system_key, body["jwt"], expires_in)
|
|
101
|
+
return body["jwt"]
|
|
102
|
+
except httpx.HTTPStatusError as e:
|
|
103
|
+
raise TokenFetchError(f"获取 {system_key} JWT 失败: {e.response.status_code}")
|
|
104
|
+
except Exception as e:
|
|
105
|
+
raise TokenFetchError(f"获取 {system_key} JWT 异常: {e}")
|
|
106
|
+
|
|
107
|
+
def get_token(self, alias: str) -> str:
|
|
108
|
+
sys = self._registry.get(alias)
|
|
109
|
+
system_key = sys["system_key"]
|
|
110
|
+
|
|
111
|
+
# 层1:进程内线程锁,防止同进程多线程并发重复换取
|
|
112
|
+
thread_lock = _get_thread_lock(system_key)
|
|
113
|
+
with thread_lock:
|
|
114
|
+
# 双重检查:可能其他线程已完成刷新
|
|
115
|
+
data = self._store.load() or {}
|
|
116
|
+
td = data.get("tokens", {}).get(system_key)
|
|
117
|
+
if td and self._is_valid(td):
|
|
118
|
+
return td["jwt"]
|
|
119
|
+
|
|
120
|
+
# 层2:跨进程文件锁,防止多个 CLI 进程并发重复换取
|
|
121
|
+
if _FCNTL_AVAILABLE:
|
|
122
|
+
lock_path = self._store.base_dir / f".lock_{system_key}"
|
|
123
|
+
with open(lock_path, "w") as lf:
|
|
124
|
+
try:
|
|
125
|
+
fcntl.flock(lf, fcntl.LOCK_EX)
|
|
126
|
+
# 再次双重检查:其他进程可能已完成刷新
|
|
127
|
+
data = self._store.load() or {}
|
|
128
|
+
td = data.get("tokens", {}).get(system_key)
|
|
129
|
+
if td and self._is_valid(td):
|
|
130
|
+
return td["jwt"]
|
|
131
|
+
return self._fetch_token(system_key, sys["url"], sys["token_endpoint"])
|
|
132
|
+
finally:
|
|
133
|
+
fcntl.flock(lf, fcntl.LOCK_UN)
|
|
134
|
+
else:
|
|
135
|
+
return self._fetch_token(system_key, sys["url"], sys["token_endpoint"])
|
|
136
|
+
|
|
137
|
+
def check_token(self, alias: str) -> dict:
|
|
138
|
+
"""检查指定系统 Token 有效性,返回 {valid: bool, expires_in: int}。"""
|
|
139
|
+
sys = self._registry.get(alias)
|
|
140
|
+
data = self._store.load() or {}
|
|
141
|
+
td = data.get("tokens", {}).get(sys["system_key"])
|
|
142
|
+
if not td:
|
|
143
|
+
return {"valid": False, "expires_in": 0}
|
|
144
|
+
try:
|
|
145
|
+
exp = datetime.fromisoformat(td["expires_at"])
|
|
146
|
+
if exp.tzinfo is None:
|
|
147
|
+
exp = exp.replace(tzinfo=timezone.utc)
|
|
148
|
+
rem = int((exp - datetime.now(timezone.utc)).total_seconds())
|
|
149
|
+
return {"valid": rem > 0, "expires_in": max(0, rem)}
|
|
150
|
+
except (KeyError, ValueError):
|
|
151
|
+
return {"valid": False, "expires_in": 0}
|
|
152
|
+
|
|
153
|
+
def refresh_token(self, alias: str) -> str:
|
|
154
|
+
"""强制刷新指定系统的 JWT(忽略缓存有效期)。"""
|
|
155
|
+
sys = self._registry.get(alias)
|
|
156
|
+
return self._fetch_token(sys["system_key"], sys["url"], sys["token_endpoint"])
|
|
157
|
+
|
|
158
|
+
def refresh_all(self) -> dict:
|
|
159
|
+
"""刷新所有已注册系统的 JWT,返回 {alias: "ok"/"失败: 原因"} 字典。"""
|
|
160
|
+
results = {}
|
|
161
|
+
for sys in self._registry.list_all():
|
|
162
|
+
try:
|
|
163
|
+
self._fetch_token(sys["system_key"], sys["url"], sys["token_endpoint"])
|
|
164
|
+
results[sys["alias"]] = "ok"
|
|
165
|
+
except Exception as e:
|
|
166
|
+
results[sys["alias"]] = f"失败: {e}"
|
|
167
|
+
return results
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""auth 领域异常。"""
|
|
2
|
+
|
|
3
|
+
from opscli.auth.domain.exceptions import (
|
|
4
|
+
AuthError,
|
|
5
|
+
DeviceFlowDeniedError,
|
|
6
|
+
DeviceFlowError,
|
|
7
|
+
DeviceFlowExpiredError,
|
|
8
|
+
NotAuthenticatedError,
|
|
9
|
+
SessionExpiredError,
|
|
10
|
+
SystemNotFoundError,
|
|
11
|
+
TokenFetchError,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"AuthError",
|
|
16
|
+
"NotAuthenticatedError",
|
|
17
|
+
"SessionExpiredError",
|
|
18
|
+
"TokenFetchError",
|
|
19
|
+
"SystemNotFoundError",
|
|
20
|
+
"DeviceFlowError",
|
|
21
|
+
"DeviceFlowExpiredError",
|
|
22
|
+
"DeviceFlowDeniedError",
|
|
23
|
+
]
|