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/http/audit.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Audit log writers (Phase 3a).
|
|
2
|
+
|
|
3
|
+
Two files under `~/.nsc/logs/`:
|
|
4
|
+
- `last-request.json` — single-exchange ephemeral, overwritten on every call.
|
|
5
|
+
- `audit.jsonl` — append-only, write attempts only (POST/PATCH/PUT/DELETE).
|
|
6
|
+
|
|
7
|
+
Header redaction is centralized; body redaction is opt-in via `sensitive_paths`
|
|
8
|
+
threaded from the schema's `RequestBodyShape.sensitive_paths` field.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import copy
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import sys
|
|
17
|
+
import tempfile
|
|
18
|
+
from collections.abc import Sequence
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
23
|
+
|
|
24
|
+
from nsc.model.command_model import HttpMethod
|
|
25
|
+
from nsc.output.headers import SENSITIVE_HEADERS
|
|
26
|
+
|
|
27
|
+
DEFAULT_BODY_CAP_BYTES = 256 * 1024
|
|
28
|
+
DEFAULT_ROTATE_BYTES = 10 * 1024 * 1024
|
|
29
|
+
SCHEMA_VERSION = 1
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class _Frozen(BaseModel):
|
|
33
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class AuditEntry(_Frozen):
|
|
37
|
+
timestamp: str
|
|
38
|
+
operation_id: str | None = None
|
|
39
|
+
method: HttpMethod
|
|
40
|
+
url: str
|
|
41
|
+
request_headers: dict[str, str] = Field(default_factory=dict)
|
|
42
|
+
request_query: dict[str, Any] = Field(default_factory=dict)
|
|
43
|
+
request_body: Any = None
|
|
44
|
+
sensitive_paths: tuple[str, ...] = ()
|
|
45
|
+
response_status_code: int | None = None
|
|
46
|
+
response_headers: dict[str, str] = Field(default_factory=dict)
|
|
47
|
+
response_body: Any = None
|
|
48
|
+
duration_ms: int | None = None
|
|
49
|
+
attempt_n: int = 1
|
|
50
|
+
final_attempt: bool = True
|
|
51
|
+
error_kind: str | None = None
|
|
52
|
+
dry_run: bool = False
|
|
53
|
+
preflight_blocked: bool = False
|
|
54
|
+
record_indices: list[int] = Field(default_factory=list)
|
|
55
|
+
applied: bool = False
|
|
56
|
+
explain: bool = False
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def redact_headers(headers: dict[str, str]) -> dict[str, str]:
|
|
60
|
+
return {k: ("<redacted>" if k.lower() in SENSITIVE_HEADERS else v) for k, v in headers.items()}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def redact_body(body: Any, sensitive_paths: Sequence[str]) -> Any:
|
|
64
|
+
"""Return a deep-copy of `body` with all `sensitive_paths` rewritten to `"<redacted>"`.
|
|
65
|
+
|
|
66
|
+
Path semantics:
|
|
67
|
+
- Each path is a dotted string (e.g., "auth.password"). Empty paths are ignored.
|
|
68
|
+
- The walker descends into dicts by key.
|
|
69
|
+
- When it encounters a list, it applies the remainder of the path to every element.
|
|
70
|
+
- A missing key on the walk is a no-op (the schema may declare optional fields).
|
|
71
|
+
- A non-mapping/non-list intermediate aborts that path safely.
|
|
72
|
+
"""
|
|
73
|
+
if body is None or not sensitive_paths:
|
|
74
|
+
return body
|
|
75
|
+
out = copy.deepcopy(body)
|
|
76
|
+
for path in sensitive_paths:
|
|
77
|
+
if not path:
|
|
78
|
+
continue
|
|
79
|
+
_apply_redaction(out, path.split("."))
|
|
80
|
+
return out
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _apply_redaction(node: Any, parts: list[str]) -> None:
|
|
84
|
+
if not parts:
|
|
85
|
+
return
|
|
86
|
+
head, *tail = parts
|
|
87
|
+
if isinstance(node, list):
|
|
88
|
+
for item in node:
|
|
89
|
+
_apply_redaction(item, parts)
|
|
90
|
+
return
|
|
91
|
+
if not isinstance(node, dict):
|
|
92
|
+
return
|
|
93
|
+
if head not in node:
|
|
94
|
+
return
|
|
95
|
+
if not tail:
|
|
96
|
+
node[head] = "<redacted>"
|
|
97
|
+
return
|
|
98
|
+
_apply_redaction(node[head], tail)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def truncate_body(body: Any, *, cap_bytes: int = DEFAULT_BODY_CAP_BYTES) -> tuple[Any, bool]:
|
|
102
|
+
"""Return (possibly-truncated body, was_truncated)."""
|
|
103
|
+
if body is None:
|
|
104
|
+
return None, False
|
|
105
|
+
serialized = json.dumps(body, default=str).encode("utf-8")
|
|
106
|
+
if len(serialized) <= cap_bytes:
|
|
107
|
+
return body, False
|
|
108
|
+
return {"_truncated": True, "_size_bytes": len(serialized)}, True
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _to_dict(entry: AuditEntry) -> dict[str, Any]:
|
|
112
|
+
"""Serialize to the wire shape documented in spec §4.3."""
|
|
113
|
+
req_body, req_trunc = truncate_body(redact_body(entry.request_body, entry.sensitive_paths))
|
|
114
|
+
resp_body, resp_trunc = truncate_body(entry.response_body)
|
|
115
|
+
return {
|
|
116
|
+
"schema_version": SCHEMA_VERSION,
|
|
117
|
+
"timestamp": entry.timestamp,
|
|
118
|
+
"operation_id": entry.operation_id,
|
|
119
|
+
"method": entry.method.value,
|
|
120
|
+
"url": entry.url,
|
|
121
|
+
"request": {
|
|
122
|
+
"headers": redact_headers(entry.request_headers),
|
|
123
|
+
"query": entry.request_query,
|
|
124
|
+
"body": req_body,
|
|
125
|
+
"body_truncated": req_trunc,
|
|
126
|
+
},
|
|
127
|
+
"response": (
|
|
128
|
+
None
|
|
129
|
+
if entry.response_status_code is None
|
|
130
|
+
else {
|
|
131
|
+
"status_code": entry.response_status_code,
|
|
132
|
+
"headers": dict(entry.response_headers),
|
|
133
|
+
"body": resp_body,
|
|
134
|
+
"body_truncated": resp_trunc,
|
|
135
|
+
}
|
|
136
|
+
),
|
|
137
|
+
"duration_ms": entry.duration_ms,
|
|
138
|
+
"attempt_n": entry.attempt_n,
|
|
139
|
+
"final_attempt": entry.final_attempt,
|
|
140
|
+
"error_kind": entry.error_kind,
|
|
141
|
+
"dry_run": entry.dry_run,
|
|
142
|
+
"preflight_blocked": entry.preflight_blocked,
|
|
143
|
+
"record_indices": entry.record_indices,
|
|
144
|
+
"applied": entry.applied,
|
|
145
|
+
"explain": entry.explain,
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _warn(msg: str) -> None:
|
|
150
|
+
print(f"warning: {msg}", file=sys.stderr)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def write_last_request(entry: AuditEntry, *, path: Path) -> None:
|
|
154
|
+
"""Atomically overwrite `path` with the entry. Failures emit a stderr warning."""
|
|
155
|
+
try:
|
|
156
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
157
|
+
with tempfile.NamedTemporaryFile(
|
|
158
|
+
"w", encoding="utf-8", dir=path.parent, delete=False
|
|
159
|
+
) as tmp:
|
|
160
|
+
json.dump(_to_dict(entry), tmp)
|
|
161
|
+
tmp_path = Path(tmp.name)
|
|
162
|
+
os.replace(tmp_path, path)
|
|
163
|
+
except OSError as exc:
|
|
164
|
+
_warn(f"could not write audit log {path}: {exc}")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def append_audit_jsonl(
|
|
168
|
+
entry: AuditEntry,
|
|
169
|
+
*,
|
|
170
|
+
path: Path,
|
|
171
|
+
rotate_bytes: int = DEFAULT_ROTATE_BYTES,
|
|
172
|
+
) -> None:
|
|
173
|
+
"""Append the entry as a single line. Rotate to `path.1` when over the threshold."""
|
|
174
|
+
try:
|
|
175
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
176
|
+
if path.exists() and path.stat().st_size > rotate_bytes:
|
|
177
|
+
rolled = path.with_suffix(path.suffix + ".1")
|
|
178
|
+
os.replace(path, rolled)
|
|
179
|
+
with path.open("a", encoding="utf-8") as fh:
|
|
180
|
+
fh.write(json.dumps(_to_dict(entry)))
|
|
181
|
+
fh.write("\n")
|
|
182
|
+
except OSError as exc:
|
|
183
|
+
_warn(f"could not append audit log {path}: {exc}")
|
nsc/http/client.py
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
"""Sync httpx-based NetBox client (Phase 3a: reads + retry + audit)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Iterator
|
|
8
|
+
from datetime import UTC, datetime
|
|
9
|
+
from types import TracebackType
|
|
10
|
+
from typing import Any, Protocol, cast
|
|
11
|
+
from urllib.parse import parse_qs, urlsplit
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from nsc.config.settings import default_paths
|
|
16
|
+
from nsc.http.audit import (
|
|
17
|
+
AuditEntry,
|
|
18
|
+
append_audit_jsonl,
|
|
19
|
+
write_last_request,
|
|
20
|
+
)
|
|
21
|
+
from nsc.http.errors import NetBoxAPIError, NetBoxClientError
|
|
22
|
+
from nsc.http.retry import (
|
|
23
|
+
ErrorClass,
|
|
24
|
+
backoff_delay,
|
|
25
|
+
classify_error,
|
|
26
|
+
policy_for_method,
|
|
27
|
+
should_retry,
|
|
28
|
+
)
|
|
29
|
+
from nsc.model.command_model import HttpMethod
|
|
30
|
+
|
|
31
|
+
_BODY_SNIPPET_BYTES = 2048
|
|
32
|
+
_HTTP_5XX_MIN = 500
|
|
33
|
+
_HTTP_4XX_MIN = 400
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class _ProfileLike(Protocol):
|
|
37
|
+
url: Any
|
|
38
|
+
token: str | None
|
|
39
|
+
verify_ssl: bool
|
|
40
|
+
timeout: float
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class NetBoxClient:
|
|
44
|
+
def __init__(self, profile: _ProfileLike, *, debug: bool = False) -> None:
|
|
45
|
+
if profile.token is None:
|
|
46
|
+
raise ValueError("NetBoxClient requires a non-None token on the profile")
|
|
47
|
+
self._url = str(profile.url).rstrip("/")
|
|
48
|
+
self._debug = debug
|
|
49
|
+
self._client = httpx.Client(
|
|
50
|
+
base_url=self._url,
|
|
51
|
+
headers={
|
|
52
|
+
"Authorization": f"Token {profile.token}",
|
|
53
|
+
"Accept": "application/json",
|
|
54
|
+
},
|
|
55
|
+
verify=profile.verify_ssl,
|
|
56
|
+
timeout=profile.timeout,
|
|
57
|
+
event_hooks=self._event_hooks() if debug else {},
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
def __enter__(self) -> NetBoxClient:
|
|
61
|
+
return self
|
|
62
|
+
|
|
63
|
+
def __exit__(
|
|
64
|
+
self,
|
|
65
|
+
exc_type: type[BaseException] | None,
|
|
66
|
+
exc: BaseException | None,
|
|
67
|
+
tb: TracebackType | None,
|
|
68
|
+
) -> None:
|
|
69
|
+
self.close()
|
|
70
|
+
|
|
71
|
+
def close(self) -> None:
|
|
72
|
+
self._client.close()
|
|
73
|
+
|
|
74
|
+
def get(self, path: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
75
|
+
response = self._send_with_retry(HttpMethod.GET, path, params=params)
|
|
76
|
+
return _parse_json(response)
|
|
77
|
+
|
|
78
|
+
def post(
|
|
79
|
+
self,
|
|
80
|
+
path: str,
|
|
81
|
+
*,
|
|
82
|
+
json: Any | None = None,
|
|
83
|
+
operation_id: str | None = None,
|
|
84
|
+
record_indices: list[int] | None = None,
|
|
85
|
+
sensitive_paths: tuple[str, ...] = (),
|
|
86
|
+
) -> dict[str, Any]:
|
|
87
|
+
response = self._send_with_retry(
|
|
88
|
+
HttpMethod.POST,
|
|
89
|
+
path,
|
|
90
|
+
json_body=json,
|
|
91
|
+
operation_id=operation_id,
|
|
92
|
+
record_indices=record_indices,
|
|
93
|
+
sensitive_paths=sensitive_paths,
|
|
94
|
+
)
|
|
95
|
+
return _parse_json(response)
|
|
96
|
+
|
|
97
|
+
def patch(
|
|
98
|
+
self,
|
|
99
|
+
path: str,
|
|
100
|
+
*,
|
|
101
|
+
json: Any | None = None,
|
|
102
|
+
operation_id: str | None = None,
|
|
103
|
+
record_indices: list[int] | None = None,
|
|
104
|
+
sensitive_paths: tuple[str, ...] = (),
|
|
105
|
+
) -> dict[str, Any]:
|
|
106
|
+
response = self._send_with_retry(
|
|
107
|
+
HttpMethod.PATCH,
|
|
108
|
+
path,
|
|
109
|
+
json_body=json,
|
|
110
|
+
operation_id=operation_id,
|
|
111
|
+
record_indices=record_indices,
|
|
112
|
+
sensitive_paths=sensitive_paths,
|
|
113
|
+
)
|
|
114
|
+
return _parse_json(response)
|
|
115
|
+
|
|
116
|
+
def put(
|
|
117
|
+
self,
|
|
118
|
+
path: str,
|
|
119
|
+
*,
|
|
120
|
+
json: Any | None = None,
|
|
121
|
+
operation_id: str | None = None,
|
|
122
|
+
record_indices: list[int] | None = None,
|
|
123
|
+
sensitive_paths: tuple[str, ...] = (),
|
|
124
|
+
) -> dict[str, Any]:
|
|
125
|
+
response = self._send_with_retry(
|
|
126
|
+
HttpMethod.PUT,
|
|
127
|
+
path,
|
|
128
|
+
json_body=json,
|
|
129
|
+
operation_id=operation_id,
|
|
130
|
+
record_indices=record_indices,
|
|
131
|
+
sensitive_paths=sensitive_paths,
|
|
132
|
+
)
|
|
133
|
+
return _parse_json(response)
|
|
134
|
+
|
|
135
|
+
def delete(
|
|
136
|
+
self,
|
|
137
|
+
path: str,
|
|
138
|
+
*,
|
|
139
|
+
operation_id: str | None = None,
|
|
140
|
+
record_indices: list[int] | None = None,
|
|
141
|
+
sensitive_paths: tuple[str, ...] = (),
|
|
142
|
+
) -> dict[str, Any]:
|
|
143
|
+
response = self._send_with_retry(
|
|
144
|
+
HttpMethod.DELETE,
|
|
145
|
+
path,
|
|
146
|
+
operation_id=operation_id,
|
|
147
|
+
record_indices=record_indices,
|
|
148
|
+
sensitive_paths=sensitive_paths,
|
|
149
|
+
)
|
|
150
|
+
return _parse_json(response)
|
|
151
|
+
|
|
152
|
+
def paginate(
|
|
153
|
+
self,
|
|
154
|
+
path: str,
|
|
155
|
+
params: dict[str, Any] | None = None,
|
|
156
|
+
*,
|
|
157
|
+
limit: int | None = None,
|
|
158
|
+
) -> Iterator[dict[str, Any]]:
|
|
159
|
+
url: str | None = path
|
|
160
|
+
first = True
|
|
161
|
+
emitted = 0
|
|
162
|
+
while url is not None:
|
|
163
|
+
if first:
|
|
164
|
+
req_path, req_params = url, params
|
|
165
|
+
else:
|
|
166
|
+
# next URLs are absolute; split them so respx (and httpx) can
|
|
167
|
+
# match on path + params independently rather than a raw string.
|
|
168
|
+
parsed = urlsplit(url)
|
|
169
|
+
req_path = parsed.path
|
|
170
|
+
qs = parse_qs(parsed.query, keep_blank_values=True)
|
|
171
|
+
req_params = {k: v[0] for k, v in qs.items()} if qs else None
|
|
172
|
+
response = self._send_with_retry(HttpMethod.GET, req_path, params=req_params)
|
|
173
|
+
payload = _parse_json(response)
|
|
174
|
+
for record in payload.get("results", []):
|
|
175
|
+
yield record
|
|
176
|
+
emitted += 1
|
|
177
|
+
if limit is not None and emitted >= limit:
|
|
178
|
+
return
|
|
179
|
+
url = payload.get("next")
|
|
180
|
+
first = False
|
|
181
|
+
|
|
182
|
+
def _send_with_retry(
|
|
183
|
+
self,
|
|
184
|
+
method: HttpMethod,
|
|
185
|
+
path: str,
|
|
186
|
+
*,
|
|
187
|
+
params: dict[str, Any] | None = None,
|
|
188
|
+
json_body: Any | None = None,
|
|
189
|
+
operation_id: str | None = None,
|
|
190
|
+
record_indices: list[int] | None = None,
|
|
191
|
+
sensitive_paths: tuple[str, ...] = (),
|
|
192
|
+
) -> httpx.Response:
|
|
193
|
+
policy = policy_for_method(method)
|
|
194
|
+
attempt = 0
|
|
195
|
+
is_write = method not in {HttpMethod.GET, HttpMethod.HEAD, HttpMethod.OPTIONS}
|
|
196
|
+
indices: list[int] = list(record_indices) if record_indices is not None else []
|
|
197
|
+
while True:
|
|
198
|
+
attempt += 1
|
|
199
|
+
started = time.monotonic()
|
|
200
|
+
try:
|
|
201
|
+
response = self._client.request(method.value, path, params=params, json=json_body)
|
|
202
|
+
except httpx.RequestError as exc:
|
|
203
|
+
error_class: ErrorClass | None = classify_error(exc)
|
|
204
|
+
duration_ms = int((time.monotonic() - started) * 1000)
|
|
205
|
+
retry = should_retry(
|
|
206
|
+
policy, attempt=attempt, status_code=None, error_class=error_class
|
|
207
|
+
)
|
|
208
|
+
self._record_attempt(
|
|
209
|
+
method=method,
|
|
210
|
+
path=path,
|
|
211
|
+
params=params,
|
|
212
|
+
request_body=json_body,
|
|
213
|
+
response=None,
|
|
214
|
+
duration_ms=duration_ms,
|
|
215
|
+
attempt=attempt,
|
|
216
|
+
final=not retry,
|
|
217
|
+
operation_id=operation_id,
|
|
218
|
+
is_write=is_write,
|
|
219
|
+
record_indices=indices,
|
|
220
|
+
sensitive_paths=sensitive_paths,
|
|
221
|
+
)
|
|
222
|
+
if not retry:
|
|
223
|
+
raise NetBoxClientError(url=self._absolute(path, params), cause=exc) from exc
|
|
224
|
+
time.sleep(backoff_delay(policy, attempt=attempt))
|
|
225
|
+
continue
|
|
226
|
+
duration_ms = int((time.monotonic() - started) * 1000)
|
|
227
|
+
retry = should_retry(
|
|
228
|
+
policy,
|
|
229
|
+
attempt=attempt,
|
|
230
|
+
status_code=response.status_code,
|
|
231
|
+
error_class=None,
|
|
232
|
+
)
|
|
233
|
+
self._record_attempt(
|
|
234
|
+
method=method,
|
|
235
|
+
path=path,
|
|
236
|
+
params=params,
|
|
237
|
+
request_body=json_body,
|
|
238
|
+
response=response,
|
|
239
|
+
duration_ms=duration_ms,
|
|
240
|
+
attempt=attempt,
|
|
241
|
+
final=not retry,
|
|
242
|
+
operation_id=operation_id,
|
|
243
|
+
is_write=is_write,
|
|
244
|
+
record_indices=indices,
|
|
245
|
+
sensitive_paths=sensitive_paths,
|
|
246
|
+
)
|
|
247
|
+
if not retry:
|
|
248
|
+
if response.is_success:
|
|
249
|
+
return response
|
|
250
|
+
self._raise_for_status(response)
|
|
251
|
+
time.sleep(backoff_delay(policy, attempt=attempt))
|
|
252
|
+
|
|
253
|
+
def _absolute(self, path: str, params: dict[str, Any] | None) -> str:
|
|
254
|
+
request = self._client.build_request("GET", path, params=params)
|
|
255
|
+
return str(request.url)
|
|
256
|
+
|
|
257
|
+
def _raise_for_status(self, response: httpx.Response) -> None:
|
|
258
|
+
if response.is_success:
|
|
259
|
+
return
|
|
260
|
+
try:
|
|
261
|
+
body = response.text[:_BODY_SNIPPET_BYTES]
|
|
262
|
+
except Exception: # pragma: no cover
|
|
263
|
+
body = ""
|
|
264
|
+
raise NetBoxAPIError(
|
|
265
|
+
status_code=response.status_code,
|
|
266
|
+
url=str(response.request.url),
|
|
267
|
+
body_snippet=body,
|
|
268
|
+
headers=dict(response.headers),
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
def _record_attempt(
|
|
272
|
+
self,
|
|
273
|
+
*,
|
|
274
|
+
method: HttpMethod,
|
|
275
|
+
path: str,
|
|
276
|
+
params: dict[str, Any] | None,
|
|
277
|
+
request_body: Any | None,
|
|
278
|
+
response: httpx.Response | None,
|
|
279
|
+
duration_ms: int,
|
|
280
|
+
attempt: int,
|
|
281
|
+
final: bool,
|
|
282
|
+
operation_id: str | None,
|
|
283
|
+
is_write: bool,
|
|
284
|
+
record_indices: list[int],
|
|
285
|
+
sensitive_paths: tuple[str, ...] = (),
|
|
286
|
+
) -> None:
|
|
287
|
+
url = self._absolute(path, params)
|
|
288
|
+
# httpx lowercases all header names; title-case them so the audit log
|
|
289
|
+
# uses the conventional HTTP capitalisation (e.g. "Authorization").
|
|
290
|
+
request_headers = {k.title(): v for k, v in self._client.headers.items()}
|
|
291
|
+
if response is not None:
|
|
292
|
+
status_code: int = response.status_code
|
|
293
|
+
response_headers = dict(response.headers)
|
|
294
|
+
try:
|
|
295
|
+
response_body: Any = response.json() if response.content else None
|
|
296
|
+
except ValueError:
|
|
297
|
+
response_body = response.text[:_BODY_SNIPPET_BYTES]
|
|
298
|
+
if status_code >= _HTTP_5XX_MIN:
|
|
299
|
+
error_kind: str | None = "5xx"
|
|
300
|
+
elif status_code >= _HTTP_4XX_MIN:
|
|
301
|
+
error_kind = "4xx"
|
|
302
|
+
else:
|
|
303
|
+
error_kind = None
|
|
304
|
+
audit_status: int | None = status_code
|
|
305
|
+
else:
|
|
306
|
+
audit_status = None
|
|
307
|
+
response_headers = {}
|
|
308
|
+
response_body = None
|
|
309
|
+
error_kind = "transport"
|
|
310
|
+
|
|
311
|
+
entry = AuditEntry(
|
|
312
|
+
timestamp=_now_iso(),
|
|
313
|
+
operation_id=operation_id,
|
|
314
|
+
method=method,
|
|
315
|
+
url=url,
|
|
316
|
+
request_headers=request_headers,
|
|
317
|
+
request_query=dict(params or {}),
|
|
318
|
+
request_body=request_body,
|
|
319
|
+
sensitive_paths=sensitive_paths,
|
|
320
|
+
response_status_code=audit_status,
|
|
321
|
+
response_headers=response_headers,
|
|
322
|
+
response_body=response_body,
|
|
323
|
+
duration_ms=duration_ms,
|
|
324
|
+
attempt_n=attempt,
|
|
325
|
+
final_attempt=final,
|
|
326
|
+
error_kind=error_kind,
|
|
327
|
+
dry_run=False,
|
|
328
|
+
preflight_blocked=False,
|
|
329
|
+
record_indices=list(record_indices),
|
|
330
|
+
applied=is_write,
|
|
331
|
+
explain=False,
|
|
332
|
+
)
|
|
333
|
+
log_dir = default_paths().logs_dir
|
|
334
|
+
write_last_request(entry, path=log_dir / "last-request.json")
|
|
335
|
+
if is_write or self._debug:
|
|
336
|
+
append_audit_jsonl(entry, path=log_dir / "audit.jsonl")
|
|
337
|
+
|
|
338
|
+
def _event_hooks(self) -> dict[str, list[Any]]:
|
|
339
|
+
# Phase 2 stderr streaming — keep alongside Phase 3a audit.jsonl writes.
|
|
340
|
+
def on_request(request: httpx.Request) -> None:
|
|
341
|
+
print(f">>> {request.method} {request.url}", file=sys.stderr)
|
|
342
|
+
for k, v in request.headers.items():
|
|
343
|
+
masked = "<redacted>" if k.lower() == "authorization" else v
|
|
344
|
+
print(f">>> {k}: {masked}", file=sys.stderr)
|
|
345
|
+
|
|
346
|
+
def on_response(response: httpx.Response) -> None:
|
|
347
|
+
response.read()
|
|
348
|
+
print(f"<<< {response.status_code} {response.reason_phrase}", file=sys.stderr)
|
|
349
|
+
for k, v in response.headers.items():
|
|
350
|
+
print(f"<<< {k}: {v}", file=sys.stderr)
|
|
351
|
+
body = response.text[:_BODY_SNIPPET_BYTES]
|
|
352
|
+
if body:
|
|
353
|
+
print(f"<<< {body}", file=sys.stderr)
|
|
354
|
+
|
|
355
|
+
return {"request": [on_request], "response": [on_response]}
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _now_iso() -> str:
|
|
359
|
+
return datetime.now(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _parse_json(response: httpx.Response) -> dict[str, Any]:
|
|
363
|
+
if not response.content:
|
|
364
|
+
return {}
|
|
365
|
+
return cast(dict[str, Any], response.json())
|
nsc/http/errors.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""HTTP error types raised by NetBoxClient."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class NetBoxAPIError(Exception):
|
|
7
|
+
"""A non-2xx response from NetBox."""
|
|
8
|
+
|
|
9
|
+
def __init__(
|
|
10
|
+
self,
|
|
11
|
+
status_code: int,
|
|
12
|
+
url: str,
|
|
13
|
+
body_snippet: str,
|
|
14
|
+
headers: dict[str, str],
|
|
15
|
+
) -> None:
|
|
16
|
+
super().__init__(f"NetBox API {status_code} on {url}")
|
|
17
|
+
self.status_code = status_code
|
|
18
|
+
self.url = url
|
|
19
|
+
self.body_snippet = body_snippet
|
|
20
|
+
self.headers = headers
|
|
21
|
+
|
|
22
|
+
def render_for_cli(self) -> str:
|
|
23
|
+
return f"NetBox API {self.status_code} on {self.url}: {self.body_snippet}"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class NetBoxClientError(Exception):
|
|
27
|
+
"""A transport-level failure reaching NetBox (DNS, TCP, TLS, timeout)."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, url: str, cause: BaseException) -> None:
|
|
30
|
+
super().__init__(f"NetBox transport error on {url}: {cause}")
|
|
31
|
+
self.url = url
|
|
32
|
+
self.cause = cause
|
|
33
|
+
|
|
34
|
+
def render_for_cli(self) -> str:
|
|
35
|
+
return f"NetBox transport error on {self.url}: {self.cause}"
|
nsc/http/retry.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Per-method retry policy + error classification (Phase 3a).
|
|
2
|
+
|
|
3
|
+
Pure functions only. The retry *loop* lives in `NetBoxClient`; this module
|
|
4
|
+
decides whether to keep going.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import random
|
|
10
|
+
from enum import Enum
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
from pydantic import BaseModel, ConfigDict
|
|
14
|
+
|
|
15
|
+
from nsc.model.command_model import HttpMethod
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class _Frozen(BaseModel):
|
|
19
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class RetryPolicy(_Frozen):
|
|
23
|
+
max_attempts: int = 3
|
|
24
|
+
base_delay: float = 0.5
|
|
25
|
+
jitter: float = 0.25
|
|
26
|
+
retry_on_5xx: bool
|
|
27
|
+
retry_on_connect: bool
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ErrorClass(Enum):
|
|
31
|
+
"""Classification of a transport-layer exception."""
|
|
32
|
+
|
|
33
|
+
CONNECT = "connect" # provably no-op (request never sent)
|
|
34
|
+
READ_TIMEOUT = "read_timeout" # bytes sent, server may have processed
|
|
35
|
+
TRANSPORT_AMBIGUOUS = "transport_ambig" # could be either; treat as may-have-sent
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
_READ_METHODS = {HttpMethod.GET, HttpMethod.HEAD, HttpMethod.OPTIONS}
|
|
39
|
+
|
|
40
|
+
_5XX_MIN = 500
|
|
41
|
+
_5XX_MAX = 600
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def policy_for_method(method: HttpMethod) -> RetryPolicy:
|
|
45
|
+
if method in _READ_METHODS:
|
|
46
|
+
return RetryPolicy(retry_on_5xx=True, retry_on_connect=True)
|
|
47
|
+
return RetryPolicy(retry_on_5xx=False, retry_on_connect=True)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def classify_error(exc: BaseException) -> ErrorClass:
|
|
51
|
+
"""Map an httpx transport exception to a retry-relevant class."""
|
|
52
|
+
if isinstance(exc, (httpx.ConnectError, httpx.ConnectTimeout)):
|
|
53
|
+
return ErrorClass.CONNECT
|
|
54
|
+
if isinstance(exc, httpx.ReadTimeout):
|
|
55
|
+
return ErrorClass.READ_TIMEOUT
|
|
56
|
+
return ErrorClass.TRANSPORT_AMBIGUOUS
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def should_retry(
|
|
60
|
+
policy: RetryPolicy,
|
|
61
|
+
*,
|
|
62
|
+
attempt: int,
|
|
63
|
+
status_code: int | None,
|
|
64
|
+
error_class: ErrorClass | None,
|
|
65
|
+
) -> bool:
|
|
66
|
+
"""Should we attempt request `attempt + 1`?
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
attempt: The 1-indexed attempt that just finished.
|
|
70
|
+
status_code: HTTP status from the response, or None if no response.
|
|
71
|
+
error_class: The transport classification, or None if a response arrived.
|
|
72
|
+
"""
|
|
73
|
+
if attempt >= policy.max_attempts:
|
|
74
|
+
return False
|
|
75
|
+
if error_class is ErrorClass.CONNECT:
|
|
76
|
+
return policy.retry_on_connect
|
|
77
|
+
if error_class in (ErrorClass.READ_TIMEOUT, ErrorClass.TRANSPORT_AMBIGUOUS):
|
|
78
|
+
return False # never retry possibly-already-sent writes; reads don't either (safety)
|
|
79
|
+
if status_code is not None and _5XX_MIN <= status_code < _5XX_MAX:
|
|
80
|
+
return policy.retry_on_5xx
|
|
81
|
+
return False
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def backoff_delay(policy: RetryPolicy, *, attempt: int) -> float:
|
|
85
|
+
"""Exponential backoff with ± jitter%, in seconds."""
|
|
86
|
+
base: float = policy.base_delay * (2 ** (attempt - 1))
|
|
87
|
+
if policy.jitter == 0.0:
|
|
88
|
+
return base
|
|
89
|
+
span: float = base * policy.jitter
|
|
90
|
+
return float(base + random.uniform(-span, span))
|
nsc/model/__init__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Framework-free command-model: the normalized command tree."""
|
|
2
|
+
|
|
3
|
+
from nsc.model.command_model import (
|
|
4
|
+
CommandModel,
|
|
5
|
+
HttpMethod,
|
|
6
|
+
Operation,
|
|
7
|
+
Parameter,
|
|
8
|
+
ParameterLocation,
|
|
9
|
+
PrimitiveType,
|
|
10
|
+
Resource,
|
|
11
|
+
Tag,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"CommandModel",
|
|
16
|
+
"HttpMethod",
|
|
17
|
+
"Operation",
|
|
18
|
+
"Parameter",
|
|
19
|
+
"ParameterLocation",
|
|
20
|
+
"PrimitiveType",
|
|
21
|
+
"Resource",
|
|
22
|
+
"Tag",
|
|
23
|
+
]
|