netbox-super-cli 1.0.0__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.
- netbox_super_cli-1.0.0.dist-info/METADATA +182 -0
- netbox_super_cli-1.0.0.dist-info/RECORD +71 -0
- netbox_super_cli-1.0.0.dist-info/WHEEL +4 -0
- netbox_super_cli-1.0.0.dist-info/entry_points.txt +3 -0
- netbox_super_cli-1.0.0.dist-info/licenses/LICENSE +201 -0
- nsc/__init__.py +5 -0
- nsc/__main__.py +6 -0
- nsc/_version.py +3 -0
- nsc/aliases/__init__.py +24 -0
- nsc/aliases/resolver.py +112 -0
- nsc/auth/__init__.py +5 -0
- nsc/auth/verify.py +143 -0
- nsc/builder/__init__.py +5 -0
- nsc/builder/build.py +514 -0
- nsc/cache/__init__.py +5 -0
- nsc/cache/store.py +295 -0
- nsc/cli/__init__.py +1 -0
- nsc/cli/aliases_commands.py +264 -0
- nsc/cli/app.py +291 -0
- nsc/cli/cache_commands.py +159 -0
- nsc/cli/commands_dump.py +57 -0
- nsc/cli/config_commands.py +156 -0
- nsc/cli/globals.py +65 -0
- nsc/cli/handlers.py +660 -0
- nsc/cli/init_commands.py +95 -0
- nsc/cli/login_commands.py +265 -0
- nsc/cli/profiles_commands.py +256 -0
- nsc/cli/registration.py +465 -0
- nsc/cli/runtime.py +290 -0
- nsc/cli/skill_commands.py +186 -0
- nsc/cli/writes/__init__.py +10 -0
- nsc/cli/writes/apply.py +177 -0
- nsc/cli/writes/bulk.py +231 -0
- nsc/cli/writes/coercion.py +9 -0
- nsc/cli/writes/confirmation.py +96 -0
- nsc/cli/writes/input.py +358 -0
- nsc/cli/writes/preflight.py +182 -0
- nsc/config/__init__.py +23 -0
- nsc/config/loader.py +69 -0
- nsc/config/models.py +54 -0
- nsc/config/settings.py +36 -0
- nsc/config/writer.py +207 -0
- nsc/http/__init__.py +6 -0
- nsc/http/audit.py +183 -0
- nsc/http/client.py +365 -0
- nsc/http/errors.py +35 -0
- nsc/http/retry.py +90 -0
- nsc/model/__init__.py +23 -0
- nsc/model/command_model.py +125 -0
- nsc/output/__init__.py +1 -0
- nsc/output/csv_.py +34 -0
- nsc/output/errors.py +346 -0
- nsc/output/explain.py +194 -0
- nsc/output/flatten.py +25 -0
- nsc/output/headers.py +9 -0
- nsc/output/json_.py +21 -0
- nsc/output/jsonl.py +21 -0
- nsc/output/render.py +47 -0
- nsc/output/table.py +50 -0
- nsc/output/yaml_.py +28 -0
- nsc/schema/__init__.py +1 -0
- nsc/schema/hashing.py +24 -0
- nsc/schema/loader.py +66 -0
- nsc/schema/models.py +109 -0
- nsc/schema/source.py +120 -0
- nsc/schemas/__init__.py +1 -0
- nsc/schemas/bundled/__init__.py +1 -0
- nsc/schemas/bundled/manifest.yaml +5 -0
- nsc/schemas/bundled/netbox-4.6.0-beta2.json.gz +0 -0
- nsc/skill/__init__.py +35 -0
- skills/netbox-super-cli/SKILL.md +127 -0
nsc/cli/handlers.py
ADDED
|
@@ -0,0 +1,660 @@
|
|
|
1
|
+
"""Read and write handlers consumed by the dynamic Typer commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json as _json
|
|
6
|
+
import sys
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, TextIO
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
|
|
13
|
+
from nsc.cli.runtime import RuntimeContext, apply_limit, emit_envelope, map_error
|
|
14
|
+
from nsc.cli.writes.apply import ResolvedRequest
|
|
15
|
+
from nsc.cli.writes.apply import resolve as resolve_request
|
|
16
|
+
from nsc.cli.writes.bulk import (
|
|
17
|
+
BulkCapability,
|
|
18
|
+
RoutingDecision,
|
|
19
|
+
RoutingMode,
|
|
20
|
+
UnsupportedBulkError,
|
|
21
|
+
detect_bulk_capability,
|
|
22
|
+
route_to_bulk_or_loop,
|
|
23
|
+
run_loop,
|
|
24
|
+
)
|
|
25
|
+
from nsc.cli.writes.confirmation import (
|
|
26
|
+
refuse_all_on_writes,
|
|
27
|
+
refuse_bulk_and_no_bulk_together,
|
|
28
|
+
refuse_delete_without_id,
|
|
29
|
+
refuse_unknown_on_error,
|
|
30
|
+
refuse_unsupported_bulk,
|
|
31
|
+
)
|
|
32
|
+
from nsc.cli.writes.input import InputError, NDJSONParseError, RawWriteInput
|
|
33
|
+
from nsc.cli.writes.input import collect as collect_input
|
|
34
|
+
from nsc.cli.writes.preflight import PreflightResult
|
|
35
|
+
from nsc.cli.writes.preflight import check as preflight_check
|
|
36
|
+
from nsc.config.models import OutputFormat
|
|
37
|
+
from nsc.config.settings import default_paths
|
|
38
|
+
from nsc.http.audit import AuditEntry, append_audit_jsonl
|
|
39
|
+
from nsc.http.errors import NetBoxAPIError, NetBoxClientError
|
|
40
|
+
from nsc.model.command_model import HttpMethod, Operation, ParameterLocation
|
|
41
|
+
from nsc.output.errors import (
|
|
42
|
+
ClientError,
|
|
43
|
+
ErrorEnvelope,
|
|
44
|
+
ErrorType,
|
|
45
|
+
client_envelope,
|
|
46
|
+
input_error_envelope,
|
|
47
|
+
summary_envelope,
|
|
48
|
+
)
|
|
49
|
+
from nsc.output.explain import (
|
|
50
|
+
ExplainTrace,
|
|
51
|
+
)
|
|
52
|
+
from nsc.output.explain import (
|
|
53
|
+
render_to_json as render_explain_json,
|
|
54
|
+
)
|
|
55
|
+
from nsc.output.explain import (
|
|
56
|
+
render_to_rich_stdout as render_explain_rich,
|
|
57
|
+
)
|
|
58
|
+
from nsc.output.render import render
|
|
59
|
+
|
|
60
|
+
_STATUS_NOT_FOUND_DELETE = 404
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def parse_filters(raw: list[str]) -> dict[str, str]:
|
|
64
|
+
out: dict[str, str] = {}
|
|
65
|
+
for item in raw:
|
|
66
|
+
if "=" not in item:
|
|
67
|
+
raise ValueError(f"--filter expects key=value, got: {item!r}")
|
|
68
|
+
key, _, value = item.partition("=")
|
|
69
|
+
out[key.strip()] = value.strip()
|
|
70
|
+
return out
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def handle_list(
|
|
74
|
+
operation: Operation,
|
|
75
|
+
op_tag: str,
|
|
76
|
+
op_resource: str,
|
|
77
|
+
ctx: RuntimeContext,
|
|
78
|
+
*,
|
|
79
|
+
stream: TextIO | None = None,
|
|
80
|
+
**kwargs: Any,
|
|
81
|
+
) -> None:
|
|
82
|
+
try:
|
|
83
|
+
params, _ = _split_params(operation, kwargs)
|
|
84
|
+
params.update(parse_filters([f"{k}={v}" for k, v in ctx.filters]))
|
|
85
|
+
iterator = ctx.client.paginate(operation.path, params)
|
|
86
|
+
rows = list(
|
|
87
|
+
apply_limit(
|
|
88
|
+
iterator,
|
|
89
|
+
limit=ctx.limit,
|
|
90
|
+
fetch_all=ctx.fetch_all,
|
|
91
|
+
page_size=ctx.page_size,
|
|
92
|
+
)
|
|
93
|
+
)
|
|
94
|
+
render(
|
|
95
|
+
rows,
|
|
96
|
+
format=ctx.output_format,
|
|
97
|
+
columns=ctx.resolve_columns(op_tag, op_resource, operation),
|
|
98
|
+
stream=stream if stream is not None else sys.stdout,
|
|
99
|
+
compact=ctx.compact,
|
|
100
|
+
)
|
|
101
|
+
except (NetBoxAPIError, NetBoxClientError) as exc:
|
|
102
|
+
env = map_error(exc, operation_id=operation.operation_id)
|
|
103
|
+
code = emit_envelope(env, output_format=ctx.output_format)
|
|
104
|
+
raise typer.Exit(code) from exc
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def handle_get(
|
|
108
|
+
operation: Operation,
|
|
109
|
+
op_tag: str,
|
|
110
|
+
op_resource: str,
|
|
111
|
+
ctx: RuntimeContext,
|
|
112
|
+
*,
|
|
113
|
+
stream: TextIO | None = None,
|
|
114
|
+
**kwargs: Any,
|
|
115
|
+
) -> None:
|
|
116
|
+
try:
|
|
117
|
+
params, path_vars = _split_params(operation, kwargs)
|
|
118
|
+
obj = ctx.client.get(operation.path.format(**path_vars), params)
|
|
119
|
+
render(
|
|
120
|
+
obj,
|
|
121
|
+
format=ctx.output_format,
|
|
122
|
+
columns=ctx.resolve_columns(op_tag, op_resource, operation),
|
|
123
|
+
stream=stream if stream is not None else sys.stdout,
|
|
124
|
+
compact=ctx.compact,
|
|
125
|
+
)
|
|
126
|
+
except (NetBoxAPIError, NetBoxClientError) as exc:
|
|
127
|
+
env = map_error(exc, operation_id=operation.operation_id)
|
|
128
|
+
code = emit_envelope(env, output_format=ctx.output_format)
|
|
129
|
+
raise typer.Exit(code) from exc
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def handle_custom_action(
|
|
133
|
+
operation: Operation,
|
|
134
|
+
op_tag: str,
|
|
135
|
+
op_resource: str,
|
|
136
|
+
ctx: RuntimeContext,
|
|
137
|
+
*,
|
|
138
|
+
stream: TextIO | None = None,
|
|
139
|
+
**kwargs: Any,
|
|
140
|
+
) -> None:
|
|
141
|
+
handle_get(operation, op_tag, op_resource, ctx, stream=stream, **kwargs)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def handle_create(
|
|
145
|
+
operation: Operation,
|
|
146
|
+
op_tag: str,
|
|
147
|
+
op_resource: str,
|
|
148
|
+
ctx: RuntimeContext,
|
|
149
|
+
*,
|
|
150
|
+
stream: TextIO | None = None,
|
|
151
|
+
**kwargs: Any,
|
|
152
|
+
) -> None:
|
|
153
|
+
_handle_write(
|
|
154
|
+
operation,
|
|
155
|
+
op_tag=op_tag,
|
|
156
|
+
op_resource=op_resource,
|
|
157
|
+
ctx=ctx,
|
|
158
|
+
path_vars=_extract_path_vars(operation, kwargs),
|
|
159
|
+
stream=stream,
|
|
160
|
+
require_id=False,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def handle_update(
|
|
165
|
+
operation: Operation,
|
|
166
|
+
op_tag: str,
|
|
167
|
+
op_resource: str,
|
|
168
|
+
ctx: RuntimeContext,
|
|
169
|
+
*,
|
|
170
|
+
stream: TextIO | None = None,
|
|
171
|
+
**kwargs: Any,
|
|
172
|
+
) -> None:
|
|
173
|
+
_handle_write(
|
|
174
|
+
operation,
|
|
175
|
+
op_tag=op_tag,
|
|
176
|
+
op_resource=op_resource,
|
|
177
|
+
ctx=ctx,
|
|
178
|
+
path_vars=_extract_path_vars(operation, kwargs),
|
|
179
|
+
stream=stream,
|
|
180
|
+
require_id=True,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def handle_delete(
|
|
185
|
+
operation: Operation,
|
|
186
|
+
op_tag: str,
|
|
187
|
+
op_resource: str,
|
|
188
|
+
ctx: RuntimeContext,
|
|
189
|
+
*,
|
|
190
|
+
stream: TextIO | None = None,
|
|
191
|
+
**kwargs: Any,
|
|
192
|
+
) -> None:
|
|
193
|
+
_handle_write(
|
|
194
|
+
operation,
|
|
195
|
+
op_tag=op_tag,
|
|
196
|
+
op_resource=op_resource,
|
|
197
|
+
ctx=ctx,
|
|
198
|
+
path_vars=_extract_path_vars(operation, kwargs),
|
|
199
|
+
stream=stream,
|
|
200
|
+
require_id=True,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def handle_custom_action_write(
|
|
205
|
+
operation: Operation,
|
|
206
|
+
op_tag: str,
|
|
207
|
+
op_resource: str,
|
|
208
|
+
ctx: RuntimeContext,
|
|
209
|
+
*,
|
|
210
|
+
stream: TextIO | None = None,
|
|
211
|
+
**kwargs: Any,
|
|
212
|
+
) -> None:
|
|
213
|
+
_handle_write(
|
|
214
|
+
operation,
|
|
215
|
+
op_tag=op_tag,
|
|
216
|
+
op_resource=op_resource,
|
|
217
|
+
ctx=ctx,
|
|
218
|
+
path_vars=_extract_path_vars(operation, kwargs),
|
|
219
|
+
stream=stream,
|
|
220
|
+
require_id=False,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _handle_write(
|
|
225
|
+
operation: Operation,
|
|
226
|
+
*,
|
|
227
|
+
op_tag: str,
|
|
228
|
+
op_resource: str,
|
|
229
|
+
ctx: RuntimeContext,
|
|
230
|
+
path_vars: dict[str, str],
|
|
231
|
+
stream: TextIO | None,
|
|
232
|
+
require_id: bool,
|
|
233
|
+
) -> None:
|
|
234
|
+
out = stream if stream is not None else sys.stdout
|
|
235
|
+
try:
|
|
236
|
+
if ctx.fetch_all:
|
|
237
|
+
refuse_all_on_writes(operation_id=operation.operation_id)
|
|
238
|
+
path_param_names = {
|
|
239
|
+
p.name for p in operation.parameters if p.location is ParameterLocation.PATH
|
|
240
|
+
}
|
|
241
|
+
if require_id and "id" in path_param_names and not path_vars.get("id"):
|
|
242
|
+
refuse_delete_without_id(operation_id=operation.operation_id)
|
|
243
|
+
|
|
244
|
+
refuse_bulk_and_no_bulk_together(
|
|
245
|
+
bulk=ctx.bulk is True,
|
|
246
|
+
no_bulk=ctx.no_bulk is True,
|
|
247
|
+
operation_id=operation.operation_id,
|
|
248
|
+
)
|
|
249
|
+
refuse_unknown_on_error(ctx.on_error)
|
|
250
|
+
|
|
251
|
+
is_delete = operation.http_method is HttpMethod.DELETE
|
|
252
|
+
if is_delete:
|
|
253
|
+
raw = RawWriteInput(records=[{}], source="fields_only")
|
|
254
|
+
else:
|
|
255
|
+
raw = collect_input(
|
|
256
|
+
file=Path(ctx.file) if ctx.file else None,
|
|
257
|
+
fields=list(ctx.fields),
|
|
258
|
+
stdin=sys.stdin if ctx.file == "-" else None,
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
decision = _decide_routing(operation, ctx, raw)
|
|
262
|
+
|
|
263
|
+
preflight = preflight_check(raw, operation)
|
|
264
|
+
resolved = resolve_request(
|
|
265
|
+
raw,
|
|
266
|
+
operation,
|
|
267
|
+
path_vars=path_vars,
|
|
268
|
+
base_url=str(ctx.resolved_profile.url),
|
|
269
|
+
headers={
|
|
270
|
+
"Authorization": f"Token {ctx.resolved_profile.token}",
|
|
271
|
+
"Accept": "application/json",
|
|
272
|
+
},
|
|
273
|
+
mode=decision.mode,
|
|
274
|
+
)
|
|
275
|
+
field_overrides = {f.split("=", 1)[0].split(".")[0] for f in ctx.fields if "=" in f}
|
|
276
|
+
trace = ExplainTrace.build_for(
|
|
277
|
+
operation,
|
|
278
|
+
raw,
|
|
279
|
+
preflight,
|
|
280
|
+
resolved,
|
|
281
|
+
field_overrides=field_overrides,
|
|
282
|
+
routing_decision=decision,
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
if _handle_dry_run_or_preflight(operation, ctx, resolved, preflight, trace, out=out):
|
|
286
|
+
return
|
|
287
|
+
|
|
288
|
+
if ctx.explain:
|
|
289
|
+
_render_explain_or_dry_run(trace, ctx, stream=out)
|
|
290
|
+
|
|
291
|
+
if decision.mode is RoutingMode.LOOP:
|
|
292
|
+
_execute_loop(
|
|
293
|
+
operation,
|
|
294
|
+
resolved,
|
|
295
|
+
ctx,
|
|
296
|
+
op_tag=op_tag,
|
|
297
|
+
op_resource=op_resource,
|
|
298
|
+
stream=out,
|
|
299
|
+
total_records=len(raw.records),
|
|
300
|
+
is_delete=is_delete,
|
|
301
|
+
)
|
|
302
|
+
return
|
|
303
|
+
|
|
304
|
+
response = _send_one(operation, resolved[0], ctx)
|
|
305
|
+
_render_response(
|
|
306
|
+
operation,
|
|
307
|
+
response,
|
|
308
|
+
ctx,
|
|
309
|
+
op_tag=op_tag,
|
|
310
|
+
op_resource=op_resource,
|
|
311
|
+
stream=out,
|
|
312
|
+
is_delete=is_delete,
|
|
313
|
+
)
|
|
314
|
+
except ClientError as exc:
|
|
315
|
+
code = emit_envelope(exc.envelope, output_format=ctx.output_format)
|
|
316
|
+
raise typer.Exit(code) from exc
|
|
317
|
+
except NDJSONParseError as exc:
|
|
318
|
+
env = input_error_envelope(
|
|
319
|
+
message=str(exc),
|
|
320
|
+
bad_lines=exc.bad_lines,
|
|
321
|
+
operation_id=operation.operation_id,
|
|
322
|
+
)
|
|
323
|
+
code = emit_envelope(env, output_format=ctx.output_format)
|
|
324
|
+
raise typer.Exit(code) from exc
|
|
325
|
+
except InputError as exc:
|
|
326
|
+
env = client_envelope(str(exc), operation_id=operation.operation_id)
|
|
327
|
+
code = emit_envelope(env, output_format=ctx.output_format)
|
|
328
|
+
raise typer.Exit(code) from exc
|
|
329
|
+
except (NetBoxAPIError, NetBoxClientError) as exc:
|
|
330
|
+
if (
|
|
331
|
+
isinstance(exc, NetBoxAPIError)
|
|
332
|
+
and exc.status_code == _STATUS_NOT_FOUND_DELETE
|
|
333
|
+
and operation.http_method is HttpMethod.DELETE
|
|
334
|
+
and not ctx.strict
|
|
335
|
+
):
|
|
336
|
+
_render_delete_already_absent(ctx, stream=out)
|
|
337
|
+
return
|
|
338
|
+
env = map_error(exc, operation_id=operation.operation_id)
|
|
339
|
+
code = emit_envelope(env, output_format=ctx.output_format)
|
|
340
|
+
raise typer.Exit(code) from exc
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _handle_dry_run_or_preflight(
|
|
344
|
+
operation: Operation,
|
|
345
|
+
ctx: RuntimeContext,
|
|
346
|
+
resolved: list[ResolvedRequest],
|
|
347
|
+
preflight: PreflightResult,
|
|
348
|
+
trace: ExplainTrace,
|
|
349
|
+
*,
|
|
350
|
+
out: TextIO,
|
|
351
|
+
) -> bool:
|
|
352
|
+
"""Handle dry-run rendering and preflight-blocked envelope emission.
|
|
353
|
+
|
|
354
|
+
Returns True if the caller should return immediately (dry-run path);
|
|
355
|
+
False to continue to the apply path. Raises `typer.Exit` if preflight
|
|
356
|
+
failed (in either dry-run or apply mode).
|
|
357
|
+
"""
|
|
358
|
+
if not ctx.apply:
|
|
359
|
+
_emit_dry_run_audit(operation, resolved, preflight, ctx)
|
|
360
|
+
_render_explain_or_dry_run(trace, ctx, stream=out)
|
|
361
|
+
if not preflight.ok:
|
|
362
|
+
env = _preflight_envelope(operation, preflight, applied=False)
|
|
363
|
+
code = emit_envelope(env, output_format=ctx.output_format)
|
|
364
|
+
raise typer.Exit(code)
|
|
365
|
+
return True
|
|
366
|
+
if not preflight.ok:
|
|
367
|
+
_emit_dry_run_audit(operation, resolved, preflight, ctx, preflight_blocked=True)
|
|
368
|
+
env = _preflight_envelope(operation, preflight, applied=False)
|
|
369
|
+
code = emit_envelope(env, output_format=ctx.output_format)
|
|
370
|
+
raise typer.Exit(code)
|
|
371
|
+
return False
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _decide_routing(
|
|
375
|
+
operation: Operation,
|
|
376
|
+
ctx: RuntimeContext,
|
|
377
|
+
raw: RawWriteInput,
|
|
378
|
+
) -> RoutingDecision:
|
|
379
|
+
"""Compute the routing decision and emit the AMBIGUOUS warning.
|
|
380
|
+
|
|
381
|
+
Wraps `UnsupportedBulkError` into a ClientError via `refuse_unsupported_bulk`.
|
|
382
|
+
"""
|
|
383
|
+
capability = detect_bulk_capability(operation)
|
|
384
|
+
if ctx.bulk is True:
|
|
385
|
+
bulk_flag: bool | None = True
|
|
386
|
+
elif ctx.no_bulk is True:
|
|
387
|
+
bulk_flag = False
|
|
388
|
+
else:
|
|
389
|
+
bulk_flag = None
|
|
390
|
+
try:
|
|
391
|
+
decision = route_to_bulk_or_loop(
|
|
392
|
+
record_count=len(raw.records),
|
|
393
|
+
capability=capability,
|
|
394
|
+
bulk_flag=bulk_flag,
|
|
395
|
+
)
|
|
396
|
+
except UnsupportedBulkError as exc:
|
|
397
|
+
refuse_unsupported_bulk(exc, operation_id=operation.operation_id)
|
|
398
|
+
raise # unreachable; refuse_unsupported_bulk always raises
|
|
399
|
+
if capability is BulkCapability.AMBIGUOUS:
|
|
400
|
+
print(
|
|
401
|
+
f"warning: bulk capability for {operation.operation_id} is ambiguous; "
|
|
402
|
+
f"treating as {decision.mode.value} (use --bulk or --no-bulk to be explicit)",
|
|
403
|
+
file=sys.stderr,
|
|
404
|
+
)
|
|
405
|
+
return decision
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _execute_loop(
|
|
409
|
+
operation: Operation,
|
|
410
|
+
requests: list[ResolvedRequest],
|
|
411
|
+
ctx: RuntimeContext,
|
|
412
|
+
*,
|
|
413
|
+
op_tag: str,
|
|
414
|
+
op_resource: str,
|
|
415
|
+
stream: TextIO,
|
|
416
|
+
total_records: int,
|
|
417
|
+
is_delete: bool,
|
|
418
|
+
) -> None:
|
|
419
|
+
"""Run the sequential loop and emit summary envelope on any failure."""
|
|
420
|
+
|
|
421
|
+
def _send_one_loop(op: Operation, request: ResolvedRequest) -> dict[str, Any]:
|
|
422
|
+
return _send_one(op, request, ctx)
|
|
423
|
+
|
|
424
|
+
def _audit_one(
|
|
425
|
+
_request: ResolvedRequest,
|
|
426
|
+
_response: dict[str, Any] | None,
|
|
427
|
+
_err: Exception | None,
|
|
428
|
+
) -> None:
|
|
429
|
+
# NetBoxClient.{post,patch,put,delete} already writes one audit entry
|
|
430
|
+
# per HTTP request. Writing here would double-count. The callback
|
|
431
|
+
# exists so unit tests can verify per-attempt ordering; production
|
|
432
|
+
# wiring leaves it as a no-op.
|
|
433
|
+
return
|
|
434
|
+
|
|
435
|
+
def _to_envelope(exc: Exception) -> ErrorEnvelope:
|
|
436
|
+
if isinstance(exc, NetBoxAPIError | NetBoxClientError):
|
|
437
|
+
return map_error(exc, operation_id=operation.operation_id)
|
|
438
|
+
return ErrorEnvelope(
|
|
439
|
+
error=str(exc),
|
|
440
|
+
type=ErrorType.INTERNAL,
|
|
441
|
+
operation_id=operation.operation_id,
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
result = run_loop(
|
|
445
|
+
requests,
|
|
446
|
+
operation=operation,
|
|
447
|
+
on_error=ctx.on_error,
|
|
448
|
+
send_one=_send_one_loop,
|
|
449
|
+
audit_attempt=_audit_one,
|
|
450
|
+
to_envelope=_to_envelope,
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
failures: list[ErrorEnvelope] = []
|
|
454
|
+
for attempt in result.attempts:
|
|
455
|
+
if attempt.failure is None:
|
|
456
|
+
continue
|
|
457
|
+
idx = attempt.request.record_indices[0]
|
|
458
|
+
failures.append(attempt.failure.model_copy(update={"record_index": idx}))
|
|
459
|
+
|
|
460
|
+
if not failures:
|
|
461
|
+
last_response = result.attempts[-1].response or {}
|
|
462
|
+
_render_response(
|
|
463
|
+
operation,
|
|
464
|
+
last_response,
|
|
465
|
+
ctx,
|
|
466
|
+
op_tag=op_tag,
|
|
467
|
+
op_resource=op_resource,
|
|
468
|
+
stream=stream,
|
|
469
|
+
is_delete=is_delete,
|
|
470
|
+
)
|
|
471
|
+
return
|
|
472
|
+
|
|
473
|
+
env = summary_envelope(
|
|
474
|
+
attempted=result.attempted,
|
|
475
|
+
failures=failures,
|
|
476
|
+
on_error=ctx.on_error,
|
|
477
|
+
operation_id=operation.operation_id,
|
|
478
|
+
total_records=total_records,
|
|
479
|
+
)
|
|
480
|
+
code = emit_envelope(env, output_format=ctx.output_format)
|
|
481
|
+
raise typer.Exit(code)
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _extract_path_vars(operation: Operation, kwargs: dict[str, Any]) -> dict[str, str]:
|
|
485
|
+
path_names = {p.name for p in operation.parameters if p.location is ParameterLocation.PATH}
|
|
486
|
+
return {k: str(v) for k, v in kwargs.items() if k in path_names and v is not None}
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def _emit_dry_run_audit(
|
|
490
|
+
operation: Operation,
|
|
491
|
+
resolved: list[ResolvedRequest],
|
|
492
|
+
preflight: PreflightResult,
|
|
493
|
+
ctx: RuntimeContext,
|
|
494
|
+
*,
|
|
495
|
+
preflight_blocked: bool = False,
|
|
496
|
+
) -> None:
|
|
497
|
+
log_dir = default_paths().logs_dir
|
|
498
|
+
sensitive_paths = (
|
|
499
|
+
operation.request_body.sensitive_paths if operation.request_body is not None else ()
|
|
500
|
+
)
|
|
501
|
+
for r in resolved:
|
|
502
|
+
entry = AuditEntry(
|
|
503
|
+
timestamp=_now_iso(),
|
|
504
|
+
operation_id=operation.operation_id,
|
|
505
|
+
method=operation.http_method,
|
|
506
|
+
url=r.url,
|
|
507
|
+
request_headers={
|
|
508
|
+
"Authorization": f"Token {ctx.resolved_profile.token}",
|
|
509
|
+
"Accept": "application/json",
|
|
510
|
+
},
|
|
511
|
+
request_query=dict(r.query or {}),
|
|
512
|
+
request_body=r.body,
|
|
513
|
+
sensitive_paths=sensitive_paths,
|
|
514
|
+
response_status_code=None,
|
|
515
|
+
response_headers={},
|
|
516
|
+
response_body=None,
|
|
517
|
+
duration_ms=None,
|
|
518
|
+
attempt_n=1,
|
|
519
|
+
final_attempt=True,
|
|
520
|
+
error_kind="preflight" if preflight_blocked else None,
|
|
521
|
+
dry_run=True,
|
|
522
|
+
preflight_blocked=preflight_blocked,
|
|
523
|
+
record_indices=list(r.record_indices),
|
|
524
|
+
applied=False,
|
|
525
|
+
explain=ctx.explain,
|
|
526
|
+
)
|
|
527
|
+
append_audit_jsonl(entry, path=log_dir / "audit.jsonl")
|
|
528
|
+
_ = preflight # currently consumed only via preflight_blocked
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def _now_iso() -> str:
|
|
532
|
+
return datetime.now(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def _render_explain_or_dry_run(trace: ExplainTrace, ctx: RuntimeContext, *, stream: TextIO) -> None:
|
|
536
|
+
if ctx.output_format is OutputFormat.JSON:
|
|
537
|
+
print(render_explain_json(trace), file=stream)
|
|
538
|
+
elif ctx.output_format is OutputFormat.TABLE:
|
|
539
|
+
render_explain_rich(trace, stream=stream)
|
|
540
|
+
else:
|
|
541
|
+
# CSV/YAML/JSONL on dry-run → JSON to stdout (the formatters expect rows,
|
|
542
|
+
# not a structured trace). Consistent with spec §4.2.3 fallback rules.
|
|
543
|
+
print(render_explain_json(trace), file=stream)
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _send_one(
|
|
547
|
+
operation: Operation, request: ResolvedRequest, ctx: RuntimeContext
|
|
548
|
+
) -> dict[str, Any]:
|
|
549
|
+
relative = operation.path.format(**request.path_vars)
|
|
550
|
+
indices = list(request.record_indices)
|
|
551
|
+
sensitive_paths = (
|
|
552
|
+
operation.request_body.sensitive_paths if operation.request_body is not None else ()
|
|
553
|
+
)
|
|
554
|
+
if operation.http_method is HttpMethod.POST:
|
|
555
|
+
return ctx.client.post(
|
|
556
|
+
relative,
|
|
557
|
+
json=request.body,
|
|
558
|
+
operation_id=operation.operation_id,
|
|
559
|
+
record_indices=indices,
|
|
560
|
+
sensitive_paths=sensitive_paths,
|
|
561
|
+
)
|
|
562
|
+
if operation.http_method is HttpMethod.PATCH:
|
|
563
|
+
return ctx.client.patch(
|
|
564
|
+
relative,
|
|
565
|
+
json=request.body,
|
|
566
|
+
operation_id=operation.operation_id,
|
|
567
|
+
record_indices=indices,
|
|
568
|
+
sensitive_paths=sensitive_paths,
|
|
569
|
+
)
|
|
570
|
+
if operation.http_method is HttpMethod.PUT:
|
|
571
|
+
return ctx.client.put(
|
|
572
|
+
relative,
|
|
573
|
+
json=request.body,
|
|
574
|
+
operation_id=operation.operation_id,
|
|
575
|
+
record_indices=indices,
|
|
576
|
+
sensitive_paths=sensitive_paths,
|
|
577
|
+
)
|
|
578
|
+
if operation.http_method is HttpMethod.DELETE:
|
|
579
|
+
return ctx.client.delete(
|
|
580
|
+
relative,
|
|
581
|
+
operation_id=operation.operation_id,
|
|
582
|
+
record_indices=indices,
|
|
583
|
+
sensitive_paths=sensitive_paths,
|
|
584
|
+
)
|
|
585
|
+
raise RuntimeError(f"unsupported write method: {operation.http_method}")
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def _render_response(
|
|
589
|
+
operation: Operation,
|
|
590
|
+
response: dict[str, Any],
|
|
591
|
+
ctx: RuntimeContext,
|
|
592
|
+
*,
|
|
593
|
+
op_tag: str,
|
|
594
|
+
op_resource: str,
|
|
595
|
+
stream: TextIO,
|
|
596
|
+
is_delete: bool,
|
|
597
|
+
) -> None:
|
|
598
|
+
if is_delete:
|
|
599
|
+
_render_delete_ok(ctx, stream=stream)
|
|
600
|
+
return
|
|
601
|
+
render(
|
|
602
|
+
response,
|
|
603
|
+
format=ctx.output_format,
|
|
604
|
+
columns=ctx.resolve_columns(op_tag, op_resource, operation),
|
|
605
|
+
stream=stream,
|
|
606
|
+
compact=ctx.compact,
|
|
607
|
+
)
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def _render_delete_ok(ctx: RuntimeContext, *, stream: TextIO) -> None:
|
|
611
|
+
payload = {"deleted": True}
|
|
612
|
+
if ctx.output_format is OutputFormat.JSON:
|
|
613
|
+
print(_json.dumps(payload), file=stream)
|
|
614
|
+
else:
|
|
615
|
+
print("deleted", file=stream)
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def _render_delete_already_absent(ctx: RuntimeContext, *, stream: TextIO) -> None:
|
|
619
|
+
payload = {"deleted": False, "reason": "already_absent"}
|
|
620
|
+
if ctx.output_format is OutputFormat.JSON:
|
|
621
|
+
print(_json.dumps(payload), file=stream)
|
|
622
|
+
else:
|
|
623
|
+
print("already absent (no change)", file=stream)
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def _preflight_envelope(
|
|
627
|
+
operation: Operation, preflight: PreflightResult, *, applied: bool
|
|
628
|
+
) -> ErrorEnvelope:
|
|
629
|
+
issues = [
|
|
630
|
+
{
|
|
631
|
+
"record_index": i.record_index,
|
|
632
|
+
"field_path": i.field_path,
|
|
633
|
+
"kind": i.kind,
|
|
634
|
+
"message": i.message,
|
|
635
|
+
"expected": i.expected,
|
|
636
|
+
}
|
|
637
|
+
for i in preflight.issues
|
|
638
|
+
]
|
|
639
|
+
return ErrorEnvelope(
|
|
640
|
+
error="preflight validation failed",
|
|
641
|
+
type=ErrorType.VALIDATION,
|
|
642
|
+
operation_id=operation.operation_id,
|
|
643
|
+
details={"source": "preflight", "issues": issues, "applied": applied},
|
|
644
|
+
)
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def _split_params(
|
|
648
|
+
operation: Operation, kwargs: dict[str, Any]
|
|
649
|
+
) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
650
|
+
path_names = {p.name for p in operation.parameters if p.location is ParameterLocation.PATH}
|
|
651
|
+
query: dict[str, Any] = {}
|
|
652
|
+
path: dict[str, Any] = {}
|
|
653
|
+
for k, v in kwargs.items():
|
|
654
|
+
if v is None:
|
|
655
|
+
continue
|
|
656
|
+
if k in path_names:
|
|
657
|
+
path[k] = v
|
|
658
|
+
else:
|
|
659
|
+
query[k] = v
|
|
660
|
+
return query, path
|