oed-cli 0.1.4__tar.gz → 0.1.6__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.
- {oed_cli-0.1.4/src/oed_cli.egg-info → oed_cli-0.1.6}/PKG-INFO +25 -4
- {oed_cli-0.1.4 → oed_cli-0.1.6}/README.md +24 -3
- {oed_cli-0.1.4 → oed_cli-0.1.6}/pyproject.toml +1 -1
- {oed_cli-0.1.4 → oed_cli-0.1.6}/src/oed_cli/__init__.py +1 -1
- {oed_cli-0.1.4 → oed_cli-0.1.6}/src/oed_cli/invoke.py +84 -7
- {oed_cli-0.1.4 → oed_cli-0.1.6}/src/oed_cli/main.py +13 -1
- {oed_cli-0.1.4 → oed_cli-0.1.6/src/oed_cli.egg-info}/PKG-INFO +25 -4
- {oed_cli-0.1.4 → oed_cli-0.1.6}/src/oed_cli.egg-info/SOURCES.txt +1 -0
- oed_cli-0.1.6/tests/test_ag_auth_cli.py +153 -0
- {oed_cli-0.1.4 → oed_cli-0.1.6}/tests/test_auth.py +25 -0
- {oed_cli-0.1.4 → oed_cli-0.1.6}/tests/test_dynamic.py +29 -2
- {oed_cli-0.1.4 → oed_cli-0.1.6}/tests/test_invoke.py +97 -0
- {oed_cli-0.1.4 → oed_cli-0.1.6}/LICENSE +0 -0
- {oed_cli-0.1.4 → oed_cli-0.1.6}/setup.cfg +0 -0
- {oed_cli-0.1.4 → oed_cli-0.1.6}/src/oed_cli/__main__.py +0 -0
- {oed_cli-0.1.4 → oed_cli-0.1.6}/src/oed_cli/auth.py +0 -0
- {oed_cli-0.1.4 → oed_cli-0.1.6}/src/oed_cli/cli.py +0 -0
- {oed_cli-0.1.4 → oed_cli-0.1.6}/src/oed_cli/discovery.py +0 -0
- {oed_cli-0.1.4 → oed_cli-0.1.6}/src/oed_cli/dynamic.py +0 -0
- {oed_cli-0.1.4 → oed_cli-0.1.6}/src/oed_cli/errors.py +0 -0
- {oed_cli-0.1.4 → oed_cli-0.1.6}/src/oed_cli/http.py +0 -0
- {oed_cli-0.1.4 → oed_cli-0.1.6}/src/oed_cli/py.typed +0 -0
- {oed_cli-0.1.4 → oed_cli-0.1.6}/src/oed_cli.egg-info/dependency_links.txt +0 -0
- {oed_cli-0.1.4 → oed_cli-0.1.6}/src/oed_cli.egg-info/entry_points.txt +0 -0
- {oed_cli-0.1.4 → oed_cli-0.1.6}/src/oed_cli.egg-info/requires.txt +0 -0
- {oed_cli-0.1.4 → oed_cli-0.1.6}/src/oed_cli.egg-info/top_level.txt +0 -0
- {oed_cli-0.1.4 → oed_cli-0.1.6}/tests/test_cli.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: oed-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.6
|
|
4
4
|
Summary: oed — openEuler Infra command line. Auto-discovered, AI-friendly.
|
|
5
5
|
Author: oed-cli contributors
|
|
6
6
|
License: Apache-2.0
|
|
@@ -97,7 +97,7 @@ pip install -e .
|
|
|
97
97
|
Verify:
|
|
98
98
|
|
|
99
99
|
```bash
|
|
100
|
-
oed --version # → oed, version 0.1.
|
|
100
|
+
oed --version # → oed, version 0.1.6
|
|
101
101
|
```
|
|
102
102
|
|
|
103
103
|
---
|
|
@@ -108,6 +108,22 @@ oed --version # → oed, version 0.1.4
|
|
|
108
108
|
pip install oed-cli
|
|
109
109
|
oed cve getSecurityNoticeByCveId --cve-id CVE-2019-10082
|
|
110
110
|
```
|
|
111
|
+
#### (optional) Call an AtomGit operation — store a token once
|
|
112
|
+
|
|
113
|
+
Operations on the `ag` (AtomGit) service authenticate via an `access_token`
|
|
114
|
+
query parameter. Store a personal access token once, and every `oed ag ...`
|
|
115
|
+
call injects it automatically:
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
oed ag login
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Then `oed ag listAuthenticatedUserIssues` just works — no per-call token
|
|
122
|
+
flag needed. Full details (`oed ag login --token <pat> [--no-verify]`,
|
|
123
|
+
`--status`, `oed ag logout`, how the token is stored, auto-injection rules)
|
|
124
|
+
are in the [AtomGit (`ag`) authentication](#atomgit-ag-authentication)
|
|
125
|
+
section below.
|
|
126
|
+
|
|
111
127
|
|
|
112
128
|
That's it. `oed` discovers the service from the gateway, pulls its OpenAPI
|
|
113
129
|
spec, derives `--cve-id` from the declared `query` parameter, fills WAF-safe
|
|
@@ -217,13 +233,16 @@ oed software-package-server getSoftwarePackage --id 12345 --language zh_CN
|
|
|
217
233
|
# Integer / number flags auto-coerce from string
|
|
218
234
|
oed software-package-server listSoftwarePackages --page-num 1 --count-per-page 5
|
|
219
235
|
|
|
220
|
-
# POST with JSON body —
|
|
236
|
+
# POST with JSON body — request bodies go via `--json`, never `--params`
|
|
221
237
|
oed software-package-server applyNewSoftwarePackage \
|
|
222
238
|
--json '{"pkg_name":"demo","version":"1.0.0"}'
|
|
223
239
|
|
|
224
|
-
# Bulk
|
|
240
|
+
# Bulk query/path params for scripts — `--params` only covers query/path, not the body
|
|
225
241
|
oed cve getSecurityNoticeByCveId --params '{"cveId":"1"}'
|
|
226
242
|
|
|
243
|
+
# If you send body fields via `--params` on a POST, oed refuses with a
|
|
244
|
+
# `body_fields_via_params` error and a `--json` hint instead of a gateway 400.
|
|
245
|
+
|
|
227
246
|
# Raw OpenAPI spec, for debugging
|
|
228
247
|
oed schema cve | jq '.paths | keys'
|
|
229
248
|
```
|
|
@@ -373,6 +392,8 @@ to eyeball `oed services` output every week.
|
|
|
373
392
|
| Chinese output garbled on Windows | console codepage not UTF-8 | `chcp 65001`, or pipe `\| python`, or `PYTHONIOENCODING=utf-8 oed …` |
|
|
374
393
|
| `error="spec_missing"` (exit 4) on a known service | upstream hasn't published the spec yet | wait for the gateway-side OpenAPI yaml; nothing to do on the oed side |
|
|
375
394
|
| `error="ag_token_missing"` on an `ag` call | operation needs a token, none stored | `oed ag login` (or pass `--access-token <pat>`) |
|
|
395
|
+
| `error="body_fields_via_params"` (exit 1) on a POST | body fields were passed via `--params` (which only covers query/path) | resend the fields with `--json '{...}'` — `oed <service> <op> --help` lists the body schema |
|
|
396
|
+
| `error="gateway_managed_param"` (exit 1) on a `forum` call | `Api-Key` / `Api-Username` were passed (`--api-key` or `--params`) | drop them — `oed` auto-fills both placeholder headers on every `forum` call and the gateway converts them |
|
|
376
397
|
| A `cve` call exits 2 (`waf_block`) | spec points to a `.test.osinfra.cn` host | already handled — `oed` reads `base_url` from the discovery feed (no fallback) and ignores the spec's `x-apigateway-backend.httpEndpoints.address` for the host |
|
|
377
398
|
|
|
378
399
|
### Offline mode
|
|
@@ -63,7 +63,7 @@ pip install -e .
|
|
|
63
63
|
Verify:
|
|
64
64
|
|
|
65
65
|
```bash
|
|
66
|
-
oed --version # → oed, version 0.1.
|
|
66
|
+
oed --version # → oed, version 0.1.6
|
|
67
67
|
```
|
|
68
68
|
|
|
69
69
|
---
|
|
@@ -74,6 +74,22 @@ oed --version # → oed, version 0.1.4
|
|
|
74
74
|
pip install oed-cli
|
|
75
75
|
oed cve getSecurityNoticeByCveId --cve-id CVE-2019-10082
|
|
76
76
|
```
|
|
77
|
+
#### (optional) Call an AtomGit operation — store a token once
|
|
78
|
+
|
|
79
|
+
Operations on the `ag` (AtomGit) service authenticate via an `access_token`
|
|
80
|
+
query parameter. Store a personal access token once, and every `oed ag ...`
|
|
81
|
+
call injects it automatically:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
oed ag login
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Then `oed ag listAuthenticatedUserIssues` just works — no per-call token
|
|
88
|
+
flag needed. Full details (`oed ag login --token <pat> [--no-verify]`,
|
|
89
|
+
`--status`, `oed ag logout`, how the token is stored, auto-injection rules)
|
|
90
|
+
are in the [AtomGit (`ag`) authentication](#atomgit-ag-authentication)
|
|
91
|
+
section below.
|
|
92
|
+
|
|
77
93
|
|
|
78
94
|
That's it. `oed` discovers the service from the gateway, pulls its OpenAPI
|
|
79
95
|
spec, derives `--cve-id` from the declared `query` parameter, fills WAF-safe
|
|
@@ -183,13 +199,16 @@ oed software-package-server getSoftwarePackage --id 12345 --language zh_CN
|
|
|
183
199
|
# Integer / number flags auto-coerce from string
|
|
184
200
|
oed software-package-server listSoftwarePackages --page-num 1 --count-per-page 5
|
|
185
201
|
|
|
186
|
-
# POST with JSON body —
|
|
202
|
+
# POST with JSON body — request bodies go via `--json`, never `--params`
|
|
187
203
|
oed software-package-server applyNewSoftwarePackage \
|
|
188
204
|
--json '{"pkg_name":"demo","version":"1.0.0"}'
|
|
189
205
|
|
|
190
|
-
# Bulk
|
|
206
|
+
# Bulk query/path params for scripts — `--params` only covers query/path, not the body
|
|
191
207
|
oed cve getSecurityNoticeByCveId --params '{"cveId":"1"}'
|
|
192
208
|
|
|
209
|
+
# If you send body fields via `--params` on a POST, oed refuses with a
|
|
210
|
+
# `body_fields_via_params` error and a `--json` hint instead of a gateway 400.
|
|
211
|
+
|
|
193
212
|
# Raw OpenAPI spec, for debugging
|
|
194
213
|
oed schema cve | jq '.paths | keys'
|
|
195
214
|
```
|
|
@@ -339,6 +358,8 @@ to eyeball `oed services` output every week.
|
|
|
339
358
|
| Chinese output garbled on Windows | console codepage not UTF-8 | `chcp 65001`, or pipe `\| python`, or `PYTHONIOENCODING=utf-8 oed …` |
|
|
340
359
|
| `error="spec_missing"` (exit 4) on a known service | upstream hasn't published the spec yet | wait for the gateway-side OpenAPI yaml; nothing to do on the oed side |
|
|
341
360
|
| `error="ag_token_missing"` on an `ag` call | operation needs a token, none stored | `oed ag login` (or pass `--access-token <pat>`) |
|
|
361
|
+
| `error="body_fields_via_params"` (exit 1) on a POST | body fields were passed via `--params` (which only covers query/path) | resend the fields with `--json '{...}'` — `oed <service> <op> --help` lists the body schema |
|
|
362
|
+
| `error="gateway_managed_param"` (exit 1) on a `forum` call | `Api-Key` / `Api-Username` were passed (`--api-key` or `--params`) | drop them — `oed` auto-fills both placeholder headers on every `forum` call and the gateway converts them |
|
|
342
363
|
| A `cve` call exits 2 (`waf_block`) | spec points to a `.test.osinfra.cn` host | already handled — `oed` reads `base_url` from the discovery feed (no fallback) and ignores the spec's `x-apigateway-backend.httpEndpoints.address` for the host |
|
|
343
364
|
|
|
344
365
|
### Offline mode
|
|
@@ -127,6 +127,36 @@ def _render_response(resp: httpx.Response) -> Any:
|
|
|
127
127
|
}
|
|
128
128
|
|
|
129
129
|
|
|
130
|
+
# Api-Key / Api-Username are Discourse auth headers that the gateway (APIG)
|
|
131
|
+
# injects during its header conversion. oed must neither send them nor let
|
|
132
|
+
# callers supply them — surfacing them makes agents hunt for credentials that
|
|
133
|
+
# are already handled upstream.
|
|
134
|
+
GATEWAY_MANAGED_PARAMS = frozenset({"Api-Key", "Api-Username"})
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _reject_gateway_managed_params(params: dict[str, Any] | None) -> None:
|
|
138
|
+
"""Refuse Api-Key / Api-Username supplied by the caller.
|
|
139
|
+
|
|
140
|
+
``forum`` authenticates at the gateway layer; the spec still declares
|
|
141
|
+
these as required header params, so agents reading the raw spec keep
|
|
142
|
+
trying to "fill" them. An explicit error is the strongest signal that
|
|
143
|
+
the value is injected upstream and must not be supplied.
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
supplied = sorted(k for k in (params or {}) if k in GATEWAY_MANAGED_PARAMS)
|
|
147
|
+
if not supplied:
|
|
148
|
+
return
|
|
149
|
+
raise UserError(
|
|
150
|
+
f"{', '.join(supplied)} are injected by the gateway and must not be supplied",
|
|
151
|
+
kind="gateway_managed_param",
|
|
152
|
+
hint=(
|
|
153
|
+
"Api-Key / Api-Username are auto-filled by oed as placeholder "
|
|
154
|
+
"headers on every forum call, then converted by the gateway — "
|
|
155
|
+
"do not supply them."
|
|
156
|
+
),
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
130
160
|
def _inject_ag_token(op: Operation, query_params: dict[str, Any]) -> None:
|
|
131
161
|
"""Auto-fill the ``access_token`` query param for AtomGit operations.
|
|
132
162
|
|
|
@@ -154,6 +184,32 @@ def _inject_ag_token(op: Operation, query_params: dict[str, Any]) -> None:
|
|
|
154
184
|
)
|
|
155
185
|
|
|
156
186
|
|
|
187
|
+
def _reject_body_fields_via_params(op: Operation, unused: list[str]) -> None:
|
|
188
|
+
"""Turn a common misuse into an actionable error instead of an opaque 400.
|
|
189
|
+
|
|
190
|
+
``--params`` only carries query/path parameters. Body fields passed that
|
|
191
|
+
way land in ``unused`` and the request goes out with ``body: null``, which
|
|
192
|
+
the gateway rejects with a generic 400. When those keys line up with the
|
|
193
|
+
operation's request-body schema, raise a hint that names the right flag.
|
|
194
|
+
"""
|
|
195
|
+
|
|
196
|
+
if op.body_schema is None or not unused:
|
|
197
|
+
return
|
|
198
|
+
body_props = set(op.body_schema.get("properties") or {})
|
|
199
|
+
body_keys = sorted(k for k in unused if k in body_props)
|
|
200
|
+
if not body_keys:
|
|
201
|
+
return
|
|
202
|
+
raise UserError(
|
|
203
|
+
f"{', '.join(body_keys)} match request-body fields for this operation "
|
|
204
|
+
"but no body was sent",
|
|
205
|
+
kind="body_fields_via_params",
|
|
206
|
+
hint=(
|
|
207
|
+
"`--params` only covers query/path parameters; request bodies go "
|
|
208
|
+
f"through `--json '{{...}}'`. See `oed <service> {op.display_name} --help`."
|
|
209
|
+
),
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
|
|
157
213
|
def call_operation(
|
|
158
214
|
op: Operation,
|
|
159
215
|
*,
|
|
@@ -175,7 +231,10 @@ def call_operation(
|
|
|
175
231
|
"""
|
|
176
232
|
|
|
177
233
|
path_params, query_params, unused = _select_params(op, params)
|
|
234
|
+
_reject_gateway_managed_params(params)
|
|
178
235
|
query_params = coerce_param_types(op, query_params)
|
|
236
|
+
if body is None:
|
|
237
|
+
_reject_body_fields_via_params(op, unused)
|
|
179
238
|
|
|
180
239
|
filled_path, missing = _fill_path(op.path, path_params)
|
|
181
240
|
if missing:
|
|
@@ -189,8 +248,11 @@ def call_operation(
|
|
|
189
248
|
_inject_ag_token(op, query_params)
|
|
190
249
|
url = f"{op.base_url}{filled_path}"
|
|
191
250
|
|
|
192
|
-
# Discourse (forum) authenticates via Api-Key/Api-Username
|
|
193
|
-
#
|
|
251
|
+
# Discourse (forum) authenticates via Api-Key / Api-Username headers. oed
|
|
252
|
+
# fills them with the gateway's expected placeholder — the APIG header
|
|
253
|
+
# conversion swaps them for the real credentials at the edge, so oed MUST
|
|
254
|
+
# send them or the gateway rejects the call. Callers never supply values
|
|
255
|
+
# themselves; _reject_gateway_managed_params enforces that.
|
|
194
256
|
api_headers: dict[str, str] = {}
|
|
195
257
|
if op.service_name == "forum":
|
|
196
258
|
api_headers = {"Api-Key": "oed-placeholder", "Api-Username": "oed-placeholder"}
|
|
@@ -283,9 +345,10 @@ def describe_operation(op: Operation) -> dict[str, Any]:
|
|
|
283
345
|
"description": op.description,
|
|
284
346
|
"path_params": [p["name"] for p in op.path_params],
|
|
285
347
|
"query_params": [p["name"] for p in op.query_params],
|
|
348
|
+
"has_body": op.body_schema is not None,
|
|
286
349
|
"body_required": op.body_required,
|
|
287
350
|
}
|
|
288
|
-
if op.
|
|
351
|
+
if op.body_schema is not None:
|
|
289
352
|
out["body_required_fields"] = _body_field_summary(op.body_schema)
|
|
290
353
|
if op.display_name != op.operation_id:
|
|
291
354
|
out["operation_id_raw"] = op.operation_id
|
|
@@ -346,6 +409,7 @@ def describe_operation_help(op: Operation, service) -> dict[str, Any]:
|
|
|
346
409
|
"url": f"{op.base_url}{rendered}",
|
|
347
410
|
"summary": op.summary,
|
|
348
411
|
"description": op.description,
|
|
412
|
+
"has_body": op.body_schema is not None,
|
|
349
413
|
"body_required": op.body_required,
|
|
350
414
|
"parameters": params,
|
|
351
415
|
"usage": _usage_example(op),
|
|
@@ -353,7 +417,7 @@ def describe_operation_help(op: Operation, service) -> dict[str, Any]:
|
|
|
353
417
|
}
|
|
354
418
|
if name != op.operation_id:
|
|
355
419
|
out["operation_id_raw"] = op.operation_id
|
|
356
|
-
if op.
|
|
420
|
+
if op.body_schema is not None:
|
|
357
421
|
out["body_required_fields"] = _body_field_summary(op.body_schema)
|
|
358
422
|
out["body_schema"] = op.body_schema
|
|
359
423
|
return out
|
|
@@ -368,17 +432,25 @@ def _usage_example(op: Operation) -> str:
|
|
|
368
432
|
continue
|
|
369
433
|
flag = f"--{to_flag(p['name'])} <value>"
|
|
370
434
|
parts.append(flag if p.get("required") else f"[{flag}]")
|
|
371
|
-
if op.
|
|
435
|
+
if op.body_schema is not None:
|
|
372
436
|
parts.append("--json '{...}'")
|
|
373
437
|
parts.append("[--dry-run]")
|
|
374
438
|
return " ".join(parts)
|
|
375
439
|
|
|
376
440
|
|
|
377
441
|
def _usage_examples(op: Operation) -> list[str]:
|
|
378
|
-
"""A couple of ready-to-paste example commands.
|
|
442
|
+
"""A couple of ready-to-paste example commands.
|
|
443
|
+
|
|
444
|
+
The ``--params`` example only makes sense when the operation declares
|
|
445
|
+
query/path params. A body-only operation gets a ``--json`` example
|
|
446
|
+
instead — otherwise agents get steered into sending body fields via
|
|
447
|
+
``--params``, which silently drops them (``--params`` never carries a
|
|
448
|
+
request body).
|
|
449
|
+
"""
|
|
379
450
|
|
|
380
451
|
name = op.display_name
|
|
381
452
|
examples: list[str] = []
|
|
453
|
+
has_params = any(p.get("in") in {"query", "path"} for p in op.parameters)
|
|
382
454
|
required = [
|
|
383
455
|
p
|
|
384
456
|
for p in op.parameters
|
|
@@ -390,7 +462,12 @@ def _usage_examples(op: Operation) -> list[str]:
|
|
|
390
462
|
cmd += "".join(f" --{to_flag(p['name'])} <value>" for p in required)
|
|
391
463
|
cmd += " [--dry-run]"
|
|
392
464
|
examples.append(cmd)
|
|
393
|
-
|
|
465
|
+
if op.body_schema is not None:
|
|
466
|
+
examples.append(f"oed <service> {name} --json '{{\"...\"}}'")
|
|
467
|
+
if has_params:
|
|
468
|
+
examples.append(f"oed <service> {name} --params '{name}_PARAMS_JSON'")
|
|
469
|
+
if not examples:
|
|
470
|
+
examples.append(f"oed <service> {name} [--dry-run]")
|
|
394
471
|
return examples
|
|
395
472
|
|
|
396
473
|
|
|
@@ -50,6 +50,7 @@ from .dynamic import (
|
|
|
50
50
|
)
|
|
51
51
|
from .errors import NotFoundError, OedError
|
|
52
52
|
from .invoke import (
|
|
53
|
+
GATEWAY_MANAGED_PARAMS,
|
|
53
54
|
call_operation,
|
|
54
55
|
describe_operation_help,
|
|
55
56
|
describe_service,
|
|
@@ -184,6 +185,16 @@ def _merge_params(
|
|
|
184
185
|
if flag_key in _VALUE_FLAGS or flag_key in _BOOL_FLAGS:
|
|
185
186
|
continue
|
|
186
187
|
if flag_key not in declared:
|
|
188
|
+
if flag_key.lower() in {p.lower() for p in GATEWAY_MANAGED_PARAMS}:
|
|
189
|
+
raise OedError(
|
|
190
|
+
f"--{flag_key} is injected by the gateway for forum calls",
|
|
191
|
+
kind="gateway_managed_param",
|
|
192
|
+
hint=(
|
|
193
|
+
"Do not pass Api-Key / Api-Username — oed auto-fills "
|
|
194
|
+
"the placeholder headers on every forum call and the "
|
|
195
|
+
"gateway converts them."
|
|
196
|
+
),
|
|
197
|
+
)
|
|
187
198
|
raise OedError(
|
|
188
199
|
f"unknown flag: --{flag_key}",
|
|
189
200
|
kind="unknown_flag",
|
|
@@ -205,7 +216,8 @@ _AG_LOGIN_INSTRUCTIONS = (
|
|
|
205
216
|
"AtomGit login\n"
|
|
206
217
|
"=============\n"
|
|
207
218
|
"1. Open https://atomgit.com and sign in.\n"
|
|
208
|
-
"2. Create a personal access token under Settings
|
|
219
|
+
"2. Create a personal access token under Settings "
|
|
220
|
+
"(https://atomgit.com/setting/token-classic/create)\n"
|
|
209
221
|
"3. Paste the token below. It is stored encrypted for this Windows/Linux user "
|
|
210
222
|
"and is never echoed back.\n"
|
|
211
223
|
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: oed-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.6
|
|
4
4
|
Summary: oed — openEuler Infra command line. Auto-discovered, AI-friendly.
|
|
5
5
|
Author: oed-cli contributors
|
|
6
6
|
License: Apache-2.0
|
|
@@ -97,7 +97,7 @@ pip install -e .
|
|
|
97
97
|
Verify:
|
|
98
98
|
|
|
99
99
|
```bash
|
|
100
|
-
oed --version # → oed, version 0.1.
|
|
100
|
+
oed --version # → oed, version 0.1.6
|
|
101
101
|
```
|
|
102
102
|
|
|
103
103
|
---
|
|
@@ -108,6 +108,22 @@ oed --version # → oed, version 0.1.4
|
|
|
108
108
|
pip install oed-cli
|
|
109
109
|
oed cve getSecurityNoticeByCveId --cve-id CVE-2019-10082
|
|
110
110
|
```
|
|
111
|
+
#### (optional) Call an AtomGit operation — store a token once
|
|
112
|
+
|
|
113
|
+
Operations on the `ag` (AtomGit) service authenticate via an `access_token`
|
|
114
|
+
query parameter. Store a personal access token once, and every `oed ag ...`
|
|
115
|
+
call injects it automatically:
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
oed ag login
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Then `oed ag listAuthenticatedUserIssues` just works — no per-call token
|
|
122
|
+
flag needed. Full details (`oed ag login --token <pat> [--no-verify]`,
|
|
123
|
+
`--status`, `oed ag logout`, how the token is stored, auto-injection rules)
|
|
124
|
+
are in the [AtomGit (`ag`) authentication](#atomgit-ag-authentication)
|
|
125
|
+
section below.
|
|
126
|
+
|
|
111
127
|
|
|
112
128
|
That's it. `oed` discovers the service from the gateway, pulls its OpenAPI
|
|
113
129
|
spec, derives `--cve-id` from the declared `query` parameter, fills WAF-safe
|
|
@@ -217,13 +233,16 @@ oed software-package-server getSoftwarePackage --id 12345 --language zh_CN
|
|
|
217
233
|
# Integer / number flags auto-coerce from string
|
|
218
234
|
oed software-package-server listSoftwarePackages --page-num 1 --count-per-page 5
|
|
219
235
|
|
|
220
|
-
# POST with JSON body —
|
|
236
|
+
# POST with JSON body — request bodies go via `--json`, never `--params`
|
|
221
237
|
oed software-package-server applyNewSoftwarePackage \
|
|
222
238
|
--json '{"pkg_name":"demo","version":"1.0.0"}'
|
|
223
239
|
|
|
224
|
-
# Bulk
|
|
240
|
+
# Bulk query/path params for scripts — `--params` only covers query/path, not the body
|
|
225
241
|
oed cve getSecurityNoticeByCveId --params '{"cveId":"1"}'
|
|
226
242
|
|
|
243
|
+
# If you send body fields via `--params` on a POST, oed refuses with a
|
|
244
|
+
# `body_fields_via_params` error and a `--json` hint instead of a gateway 400.
|
|
245
|
+
|
|
227
246
|
# Raw OpenAPI spec, for debugging
|
|
228
247
|
oed schema cve | jq '.paths | keys'
|
|
229
248
|
```
|
|
@@ -373,6 +392,8 @@ to eyeball `oed services` output every week.
|
|
|
373
392
|
| Chinese output garbled on Windows | console codepage not UTF-8 | `chcp 65001`, or pipe `\| python`, or `PYTHONIOENCODING=utf-8 oed …` |
|
|
374
393
|
| `error="spec_missing"` (exit 4) on a known service | upstream hasn't published the spec yet | wait for the gateway-side OpenAPI yaml; nothing to do on the oed side |
|
|
375
394
|
| `error="ag_token_missing"` on an `ag` call | operation needs a token, none stored | `oed ag login` (or pass `--access-token <pat>`) |
|
|
395
|
+
| `error="body_fields_via_params"` (exit 1) on a POST | body fields were passed via `--params` (which only covers query/path) | resend the fields with `--json '{...}'` — `oed <service> <op> --help` lists the body schema |
|
|
396
|
+
| `error="gateway_managed_param"` (exit 1) on a `forum` call | `Api-Key` / `Api-Username` were passed (`--api-key` or `--params`) | drop them — `oed` auto-fills both placeholder headers on every `forum` call and the gateway converts them |
|
|
376
397
|
| A `cve` call exits 2 (`waf_block`) | spec points to a `.test.osinfra.cn` host | already handled — `oed` reads `base_url` from the discovery feed (no fallback) and ignores the spec's `x-apigateway-backend.httpEndpoints.address` for the host |
|
|
377
398
|
|
|
378
399
|
### Offline mode
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""CLI-level tests for ``oed ag login / logout / status`` (v0.4 auth).
|
|
2
|
+
|
|
3
|
+
These run the real ``main()`` dispatcher end to end: the token store points at
|
|
4
|
+
a throwaway cache dir and the AtomGit verify backend is stubbed, so no network
|
|
5
|
+
or real credentials are involved.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import pytest
|
|
14
|
+
|
|
15
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
16
|
+
SRC = ROOT / "src"
|
|
17
|
+
if str(SRC) not in sys.path:
|
|
18
|
+
sys.path.insert(0, str(SRC))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@pytest.fixture()
|
|
22
|
+
def ag_env(monkeypatch, tmp_path):
|
|
23
|
+
"""Isolate the token store and stub the verify backend used by ``login``."""
|
|
24
|
+
|
|
25
|
+
from oed_cli import main as main_mod
|
|
26
|
+
|
|
27
|
+
monkeypatch.setenv("OED_CACHE_DIR", str(tmp_path))
|
|
28
|
+
|
|
29
|
+
class FakeService:
|
|
30
|
+
service_name = "ag"
|
|
31
|
+
name = "openeuler/ag"
|
|
32
|
+
|
|
33
|
+
class FakeOp:
|
|
34
|
+
service_name = "ag"
|
|
35
|
+
operation_id = "listAuthenticatedUserIssues"
|
|
36
|
+
display_name = "listAuthenticatedUserIssues"
|
|
37
|
+
|
|
38
|
+
state = {
|
|
39
|
+
"main": main_mod.main,
|
|
40
|
+
"verify_result": {"ok": True},
|
|
41
|
+
"resolve_error": None,
|
|
42
|
+
"call_error": None,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
def _resolve(name):
|
|
46
|
+
if state["resolve_error"] is not None:
|
|
47
|
+
raise state["resolve_error"]
|
|
48
|
+
return FakeService()
|
|
49
|
+
|
|
50
|
+
monkeypatch.setattr(main_mod, "resolve_service_by_name", _resolve)
|
|
51
|
+
monkeypatch.setattr(main_mod, "fetch_service_spec", lambda svc, **kw: {})
|
|
52
|
+
monkeypatch.setattr(main_mod, "resolve_runtime_gateway", lambda svc: "https://apig.osinfra.cn")
|
|
53
|
+
monkeypatch.setattr(main_mod, "operations_table", lambda spec, name, base_url="": {})
|
|
54
|
+
monkeypatch.setattr(main_mod, "resolve_operation", lambda table, name: FakeOp())
|
|
55
|
+
|
|
56
|
+
def _call(op, **kw):
|
|
57
|
+
if state["call_error"] is not None:
|
|
58
|
+
raise state["call_error"]
|
|
59
|
+
return state["verify_result"]
|
|
60
|
+
|
|
61
|
+
monkeypatch.setattr(main_mod, "call_operation", _call)
|
|
62
|
+
return state
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_ag_login_help(capsys, ag_env):
|
|
66
|
+
assert ag_env["main"](["ag", "login", "--help"]) == 0
|
|
67
|
+
assert '"help_for": "ag login"' in capsys.readouterr().out
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_ag_login_status_reports_unconfigured(capsys, ag_env):
|
|
71
|
+
assert ag_env["main"](["ag", "login", "--status"]) == 0
|
|
72
|
+
assert '"configured": false' in capsys.readouterr().out
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_ag_login_status_configured(capsys, ag_env):
|
|
76
|
+
assert ag_env["main"](["ag", "login", "--token", "t", "--no-verify"]) == 0
|
|
77
|
+
assert ag_env["main"](["ag", "login", "--status"]) == 0
|
|
78
|
+
assert '"configured": true' in capsys.readouterr().out
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_ag_login_empty_token_flag(capsys, ag_env):
|
|
82
|
+
assert ag_env["main"](["ag", "login", "--token", ""]) == 1
|
|
83
|
+
assert '"missing_flag_value"' in capsys.readouterr().err
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_ag_login_interactive_prompt(capsys, ag_env, monkeypatch):
|
|
87
|
+
monkeypatch.setattr("click.prompt", lambda *a, **k: "prompted-token")
|
|
88
|
+
assert ag_env["main"](["ag", "login"]) == 0
|
|
89
|
+
assert "AtomGit login" in capsys.readouterr().err
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_ag_login_interactive_abort(capsys, ag_env, monkeypatch):
|
|
93
|
+
from click import Abort
|
|
94
|
+
|
|
95
|
+
monkeypatch.setattr("click.prompt", lambda *a, **k: (_ for _ in ()).throw(Abort()))
|
|
96
|
+
assert ag_env["main"](["ag", "login"]) == 1
|
|
97
|
+
assert '"aborted"' in capsys.readouterr().err
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_ag_login_whitespace_token(capsys, ag_env):
|
|
101
|
+
assert ag_env["main"](["ag", "login", "--token", " "]) == 1
|
|
102
|
+
assert '"missing_token"' in capsys.readouterr().err
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_ag_login_verify_success_and_store(capsys, ag_env):
|
|
106
|
+
assert ag_env["main"](["ag", "login", "--token", "good-token"]) == 0
|
|
107
|
+
assert '"configured": true' in capsys.readouterr().out
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_ag_login_no_verify_skips_backend(capsys, ag_env):
|
|
111
|
+
assert ag_env["main"](["ag", "login", "--token", "t", "--no-verify"]) == 0
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_ag_login_verify_rejected_token(capsys, ag_env):
|
|
115
|
+
ag_env["verify_result"] = {"ok": False}
|
|
116
|
+
assert ag_env["main"](["ag", "login", "--token", "bad-token"]) == 1
|
|
117
|
+
assert '"invalid_token"' in capsys.readouterr().err
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_ag_login_verify_backend_missing_is_ok(capsys, ag_env):
|
|
121
|
+
from oed_cli.errors import NotFoundError
|
|
122
|
+
|
|
123
|
+
ag_env["resolve_error"] = NotFoundError("no ag service")
|
|
124
|
+
assert ag_env["main"](["ag", "login", "--token", "t"]) == 0
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_ag_login_verify_resolve_error(capsys, ag_env):
|
|
128
|
+
from oed_cli.errors import UserError
|
|
129
|
+
|
|
130
|
+
ag_env["resolve_error"] = UserError("boom", kind="boom")
|
|
131
|
+
assert ag_env["main"](["ag", "login", "--token", "t"]) == 1
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_ag_login_verify_call_error(capsys, ag_env):
|
|
135
|
+
from oed_cli.errors import UserError
|
|
136
|
+
|
|
137
|
+
ag_env["call_error"] = UserError("upstream boom", kind="boom")
|
|
138
|
+
assert ag_env["main"](["ag", "login", "--token", "t"]) == 1
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_ag_logout(capsys, ag_env):
|
|
142
|
+
assert ag_env["main"](["ag", "logout"]) == 0
|
|
143
|
+
assert '"was_set": false' in capsys.readouterr().out
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_ag_logout_help(capsys, ag_env):
|
|
147
|
+
assert ag_env["main"](["ag", "logout", "--help"]) == 0
|
|
148
|
+
assert '"help_for": "ag logout"' in capsys.readouterr().out
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def test_ag_login_too_many_positional(capsys, ag_env):
|
|
152
|
+
assert ag_env["main"](["ag", "login", "extra", "--token", "t"]) == 1
|
|
153
|
+
assert '"too_many_positional"' in capsys.readouterr().err
|
|
@@ -105,3 +105,28 @@ def test_dpapi_marker_unreadable_off_windows(isolated, monkeypatch):
|
|
|
105
105
|
)
|
|
106
106
|
monkeypatch.setattr(isolated, "_is_windows", lambda: False)
|
|
107
107
|
assert isolated.read_token("ag") is None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_version_mismatch_returns_none(isolated):
|
|
111
|
+
p = isolated.token_path("ag")
|
|
112
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
113
|
+
p.write_text(
|
|
114
|
+
json.dumps(
|
|
115
|
+
{"version": 99, "service": "ag", "encryption": "base64", "secret": "eA=="}
|
|
116
|
+
),
|
|
117
|
+
encoding="utf-8",
|
|
118
|
+
)
|
|
119
|
+
assert isolated.read_token("ag") is None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_unknown_encryption_marker_returns_none(isolated, monkeypatch):
|
|
123
|
+
monkeypatch.setattr(isolated, "_is_windows", lambda: False)
|
|
124
|
+
p = isolated.token_path("ag")
|
|
125
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
126
|
+
p.write_text(
|
|
127
|
+
json.dumps(
|
|
128
|
+
{"version": 1, "service": "ag", "encryption": "unknown", "secret": "c2VjcmV0"}
|
|
129
|
+
),
|
|
130
|
+
encoding="utf-8",
|
|
131
|
+
)
|
|
132
|
+
assert isolated.read_token("ag") is None
|
|
@@ -153,6 +153,14 @@ def patched(monkeypatch):
|
|
|
153
153
|
monkeypatch.setattr(dyn, "fetch_discovery", lambda **_: fake_feed)
|
|
154
154
|
monkeypatch.setattr(dyn, "fetch_service_spec", lambda svc, **_: SAMPLE_SPEC)
|
|
155
155
|
|
|
156
|
+
# main.py binds fetch_service_spec by value at import time, so rebind it
|
|
157
|
+
# here too — otherwise these tests only pass if main happens to be imported
|
|
158
|
+
# after the dyn patches above (order-dependent when other test files import
|
|
159
|
+
# main first).
|
|
160
|
+
from oed_cli import main as main_mod
|
|
161
|
+
|
|
162
|
+
monkeypatch.setattr(main_mod, "fetch_service_spec", lambda svc, **_: SAMPLE_SPEC)
|
|
163
|
+
|
|
156
164
|
# Stub http request so we can verify URL building without going to network
|
|
157
165
|
|
|
158
166
|
captured = {}
|
|
@@ -518,8 +526,9 @@ def test_call_operation_post_sends_body(patched):
|
|
|
518
526
|
|
|
519
527
|
|
|
520
528
|
def test_call_operation_forum_sends_api_headers(patched):
|
|
521
|
-
"""Forum (Discourse) requests carry the Api-Key / Api-Username
|
|
522
|
-
on the real request and in the dry-run view
|
|
529
|
+
"""Forum (Discourse) requests carry the Api-Key / Api-Username placeholder
|
|
530
|
+
headers on the real request and in the dry-run view — the gateway converts
|
|
531
|
+
them, so oed must send them or the call is rejected."""
|
|
523
532
|
|
|
524
533
|
from oed_cli.dynamic import operations_table
|
|
525
534
|
from oed_cli.invoke import call_operation
|
|
@@ -538,6 +547,24 @@ def test_call_operation_forum_sends_api_headers(patched):
|
|
|
538
547
|
assert dry["request"]["headers"]["Api-Username"] == "oed-placeholder"
|
|
539
548
|
|
|
540
549
|
|
|
550
|
+
def test_call_operation_rejects_gateway_managed_params(patched):
|
|
551
|
+
"""Api-Key / Api-Username are injected by the gateway; passing them via
|
|
552
|
+
params is refused with a hint instead of being silently forwarded."""
|
|
553
|
+
|
|
554
|
+
from oed_cli.dynamic import operations_table
|
|
555
|
+
from oed_cli.errors import UserError
|
|
556
|
+
from oed_cli.invoke import call_operation
|
|
557
|
+
|
|
558
|
+
table = operations_table(SAMPLE_SPEC, "forum", base_url="https://apig.osinfra.cn")
|
|
559
|
+
op = table["API_getSoftwarePackage"]
|
|
560
|
+
|
|
561
|
+
with pytest.raises(UserError) as excinfo:
|
|
562
|
+
call_operation(op, params={"id": "1", "Api-Key": "super-secret"})
|
|
563
|
+
err = excinfo.value.to_dict()
|
|
564
|
+
assert err["error"] == "gateway_managed_param"
|
|
565
|
+
assert "injected by the gateway" in err["message"]
|
|
566
|
+
|
|
567
|
+
|
|
541
568
|
def test_call_operation_non_forum_omits_api_headers(patched):
|
|
542
569
|
"""Non-forum services get no extra auth headers."""
|
|
543
570
|
|
|
@@ -198,3 +198,100 @@ def test_ag_dry_run_masks_stored_token(captured):
|
|
|
198
198
|
dry = call_operation(op, dry_run=True)
|
|
199
199
|
assert dry["request"]["query"]["access_token"] == "<stored>"
|
|
200
200
|
assert "super-secret-token" not in str(dry)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# ── request-body visibility & the --params-vs--json trap ────────────────────
|
|
204
|
+
# Regression for the forum createTopicPostPM incident: the gateway spec marks
|
|
205
|
+
# body fields required at the *schema* level but never sets requestBody.required,
|
|
206
|
+
# so body_required=False and old help hid the body entirely while still showing
|
|
207
|
+
# a --params example — which silently drops body fields (body: null → 400).
|
|
208
|
+
|
|
209
|
+
BODY_SPEC = {
|
|
210
|
+
"openapi": "3.0.3",
|
|
211
|
+
"info": {"title": "forum", "version": "1.0.0"},
|
|
212
|
+
"paths": {
|
|
213
|
+
"/posts": {
|
|
214
|
+
"post": {
|
|
215
|
+
"summary": "create a topic post",
|
|
216
|
+
"operationId": "createTopicPostPM",
|
|
217
|
+
"requestBody": {
|
|
218
|
+
"content": {
|
|
219
|
+
"application/json": {
|
|
220
|
+
"schema": {
|
|
221
|
+
"type": "object",
|
|
222
|
+
"required": ["raw"],
|
|
223
|
+
"properties": {
|
|
224
|
+
"title": {"type": "string"},
|
|
225
|
+
"raw": {"type": "string"},
|
|
226
|
+
},
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
},
|
|
231
|
+
}
|
|
232
|
+
},
|
|
233
|
+
"/plain": {
|
|
234
|
+
"get": {"summary": "no params, no body", "operationId": "getPlain", "parameters": []},
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _body_op(name: str):
|
|
241
|
+
from oed_cli.dynamic import operations_table
|
|
242
|
+
|
|
243
|
+
return operations_table(BODY_SPEC, "forum", base_url="https://apig.osinfra.cn")[name]
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def test_help_exposes_body_when_not_marked_required():
|
|
247
|
+
"""A body present but not marked requestBody.required still surfaces in help."""
|
|
248
|
+
from types import SimpleNamespace
|
|
249
|
+
|
|
250
|
+
from oed_cli.invoke import describe_operation_help
|
|
251
|
+
|
|
252
|
+
op = _body_op("createTopicPostPM")
|
|
253
|
+
svc = SimpleNamespace(name="openeuler/forum", service_name="forum", title="forum")
|
|
254
|
+
doc = describe_operation_help(op, svc)
|
|
255
|
+
assert doc["has_body"] is True
|
|
256
|
+
assert doc["body_required"] is False
|
|
257
|
+
fields = {f["name"]: f for f in doc["body_required_fields"]}
|
|
258
|
+
assert set(fields) == {"title", "raw"}
|
|
259
|
+
assert fields["raw"]["required"] is True
|
|
260
|
+
assert fields["title"]["required"] is False
|
|
261
|
+
assert "title" in doc["body_schema"]["properties"]
|
|
262
|
+
assert "--json" in doc["usage"]
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def test_body_only_op_usage_uses_json_not_params():
|
|
266
|
+
from oed_cli.invoke import _usage_examples
|
|
267
|
+
|
|
268
|
+
examples = _usage_examples(_body_op("createTopicPostPM"))
|
|
269
|
+
assert examples and "--json" in examples[0]
|
|
270
|
+
assert all("--params" not in ex for ex in examples)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def test_no_params_no_body_usage_is_dry_run():
|
|
274
|
+
from oed_cli.invoke import _usage_example, _usage_examples
|
|
275
|
+
|
|
276
|
+
op = _body_op("getPlain")
|
|
277
|
+
assert _usage_example(op) == "oed <service> getPlain [--dry-run]"
|
|
278
|
+
assert _usage_examples(op) == ["oed <service> getPlain [--dry-run]"]
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def test_body_fields_via_params_raises_hint(captured):
|
|
282
|
+
from oed_cli.errors import UserError
|
|
283
|
+
from oed_cli.invoke import call_operation
|
|
284
|
+
|
|
285
|
+
op = _body_op("createTopicPostPM")
|
|
286
|
+
with pytest.raises(UserError) as exc:
|
|
287
|
+
call_operation(op, params={"title": "hi", "raw": "body"})
|
|
288
|
+
assert exc.value.kind == "body_fields_via_params"
|
|
289
|
+
assert "--json" in exc.value.hint
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def test_body_fields_via_params_ok_when_json_passed(captured):
|
|
293
|
+
from oed_cli.invoke import call_operation
|
|
294
|
+
|
|
295
|
+
op = _body_op("createTopicPostPM")
|
|
296
|
+
call_operation(op, params={"title": "hi"}, body={"raw": "body"})
|
|
297
|
+
assert captured["body"] == {"raw": "body"}
|
|
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
|