libre-devops-helpers 0.4.1__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.
- libre_devops_helpers/__init__.py +23 -0
- libre_devops_helpers/__main__.py +5 -0
- libre_devops_helpers/cli/__init__.py +5 -0
- libre_devops_helpers/cli/app.py +147 -0
- libre_devops_helpers/cli/commands/__init__.py +1 -0
- libre_devops_helpers/cli/commands/automation.py +321 -0
- libre_devops_helpers/cli/commands/az.py +93 -0
- libre_devops_helpers/cli/commands/azure.py +258 -0
- libre_devops_helpers/cli/commands/config.py +54 -0
- libre_devops_helpers/cli/commands/devices.py +548 -0
- libre_devops_helpers/cli/commands/entra.py +554 -0
- libre_devops_helpers/cli/commands/graph.py +358 -0
- libre_devops_helpers/cli/commands/incidents.py +420 -0
- libre_devops_helpers/cli/commands/intune.py +79 -0
- libre_devops_helpers/cli/commands/keyvault.py +142 -0
- libre_devops_helpers/cli/commands/logicapp.py +489 -0
- libre_devops_helpers/cli/commands/logs.py +69 -0
- libre_devops_helpers/cli/commands/pim.py +381 -0
- libre_devops_helpers/cli/commands/pretty.py +141 -0
- libre_devops_helpers/cli/commands/profiles.py +153 -0
- libre_devops_helpers/cli/commands/snow.py +268 -0
- libre_devops_helpers/cli/commands/token.py +222 -0
- libre_devops_helpers/cli/commands/welcome.py +43 -0
- libre_devops_helpers/cli/commands/xdr.py +353 -0
- libre_devops_helpers/cli/exits.py +12 -0
- libre_devops_helpers/cli/options.py +146 -0
- libre_devops_helpers/cli/render.py +360 -0
- libre_devops_helpers/cli/runtime.py +348 -0
- libre_devops_helpers/cli/servicenow_runtime.py +170 -0
- libre_devops_helpers/core/__init__.py +94 -0
- libre_devops_helpers/core/auth.py +103 -0
- libre_devops_helpers/core/brand.py +67 -0
- libre_devops_helpers/core/browser.py +21 -0
- libre_devops_helpers/core/config.py +159 -0
- libre_devops_helpers/core/dpapi.py +60 -0
- libre_devops_helpers/core/errors.py +90 -0
- libre_devops_helpers/core/http.py +412 -0
- libre_devops_helpers/core/inputs.py +193 -0
- libre_devops_helpers/core/log.py +246 -0
- libre_devops_helpers/core/poll.py +88 -0
- libre_devops_helpers/core/process.py +106 -0
- libre_devops_helpers/core/sheets.py +330 -0
- libre_devops_helpers/core/tables.py +49 -0
- libre_devops_helpers/core/timewindow.py +127 -0
- libre_devops_helpers/core/token_store.py +266 -0
- libre_devops_helpers/core/util.py +120 -0
- libre_devops_helpers/core/yaml_text.py +142 -0
- libre_devops_helpers/microsoft/__init__.py +94 -0
- libre_devops_helpers/microsoft/auth/__init__.py +43 -0
- libre_devops_helpers/microsoft/auth/azure_cli.py +91 -0
- libre_devops_helpers/microsoft/auth/delegated.py +391 -0
- libre_devops_helpers/microsoft/auth/entra.py +241 -0
- libre_devops_helpers/microsoft/auth/factory.py +114 -0
- libre_devops_helpers/microsoft/auth/lapse.py +42 -0
- libre_devops_helpers/microsoft/auth/managed_identity.py +84 -0
- libre_devops_helpers/microsoft/automation/__init__.py +24 -0
- libre_devops_helpers/microsoft/automation/client.py +241 -0
- libre_devops_helpers/microsoft/automation/models.py +131 -0
- libre_devops_helpers/microsoft/azcli/__init__.py +27 -0
- libre_devops_helpers/microsoft/azcli/client.py +81 -0
- libre_devops_helpers/microsoft/azcli/context.py +95 -0
- libre_devops_helpers/microsoft/azure/__init__.py +34 -0
- libre_devops_helpers/microsoft/azure/client.py +260 -0
- libre_devops_helpers/microsoft/azure/models.py +198 -0
- libre_devops_helpers/microsoft/clouds.py +83 -0
- libre_devops_helpers/microsoft/config.py +244 -0
- libre_devops_helpers/microsoft/devices/__init__.py +45 -0
- libre_devops_helpers/microsoft/devices/antivirus.py +149 -0
- libre_devops_helpers/microsoft/devices/check.py +286 -0
- libre_devops_helpers/microsoft/devices/inspect.py +146 -0
- libre_devops_helpers/microsoft/devices/models.py +148 -0
- libre_devops_helpers/microsoft/entra/__init__.py +41 -0
- libre_devops_helpers/microsoft/entra/client.py +422 -0
- libre_devops_helpers/microsoft/entra/models.py +334 -0
- libre_devops_helpers/microsoft/entra/permissions.py +51 -0
- libre_devops_helpers/microsoft/graph/__init__.py +38 -0
- libre_devops_helpers/microsoft/graph/client.py +292 -0
- libre_devops_helpers/microsoft/incidents/__init__.py +51 -0
- libre_devops_helpers/microsoft/incidents/client.py +217 -0
- libre_devops_helpers/microsoft/incidents/models.py +165 -0
- libre_devops_helpers/microsoft/incidents/permissions.py +15 -0
- libre_devops_helpers/microsoft/intune/__init__.py +18 -0
- libre_devops_helpers/microsoft/intune/client.py +94 -0
- libre_devops_helpers/microsoft/intune/models.py +63 -0
- libre_devops_helpers/microsoft/intune/permissions.py +16 -0
- libre_devops_helpers/microsoft/keyvault/__init__.py +34 -0
- libre_devops_helpers/microsoft/keyvault/client.py +185 -0
- libre_devops_helpers/microsoft/loganalytics/__init__.py +17 -0
- libre_devops_helpers/microsoft/loganalytics/client.py +117 -0
- libre_devops_helpers/microsoft/logicapps/__init__.py +79 -0
- libre_devops_helpers/microsoft/logicapps/checks.py +432 -0
- libre_devops_helpers/microsoft/logicapps/client.py +162 -0
- libre_devops_helpers/microsoft/logicapps/document.py +202 -0
- libre_devops_helpers/microsoft/pim/__init__.py +39 -0
- libre_devops_helpers/microsoft/pim/azure.py +238 -0
- libre_devops_helpers/microsoft/pim/entra.py +294 -0
- libre_devops_helpers/microsoft/pim/models.py +93 -0
- libre_devops_helpers/microsoft/pim/permissions.py +98 -0
- libre_devops_helpers/microsoft/pim/rules.py +70 -0
- libre_devops_helpers/microsoft/process.py +70 -0
- libre_devops_helpers/microsoft/resources.py +137 -0
- libre_devops_helpers/microsoft/tokens.py +269 -0
- libre_devops_helpers/microsoft/xdr/__init__.py +33 -0
- libre_devops_helpers/microsoft/xdr/client.py +246 -0
- libre_devops_helpers/microsoft/xdr/models.py +181 -0
- libre_devops_helpers/microsoft/xdr/permissions.py +24 -0
- libre_devops_helpers/py.typed +0 -0
- libre_devops_helpers/servicenow/__init__.py +50 -0
- libre_devops_helpers/servicenow/auth.py +409 -0
- libre_devops_helpers/servicenow/config.py +261 -0
- libre_devops_helpers/servicenow/instance/__init__.py +32 -0
- libre_devops_helpers/servicenow/instance/client.py +91 -0
- libre_devops_helpers/servicenow/instance/models.py +131 -0
- libre_devops_helpers/servicenow/roles.py +23 -0
- libre_devops_helpers/servicenow/tables.py +133 -0
- libre_devops_helpers-0.4.1.dist-info/METADATA +153 -0
- libre_devops_helpers-0.4.1.dist-info/RECORD +120 -0
- libre_devops_helpers-0.4.1.dist-info/WHEEL +4 -0
- libre_devops_helpers-0.4.1.dist-info/entry_points.txt +2 -0
- libre_devops_helpers-0.4.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""Logging setup for the CLI: human text, flat JSON, or the OpenTelemetry wire format.
|
|
2
|
+
|
|
3
|
+
Library modules only call ``logging.getLogger(__name__)``; they never configure
|
|
4
|
+
handlers. The CLI calls ``configure_logging`` once. Logs go to stderr so they never mix
|
|
5
|
+
with data written to stdout.
|
|
6
|
+
|
|
7
|
+
``otlp`` writes the OpenTelemetry file format: JSON Lines, each line a complete OTLP/JSON
|
|
8
|
+
``LogsData`` (the same object an ``ExportLogsServiceRequest`` is) holding one record, as
|
|
9
|
+
``Write-LdoLog`` does in LibreDevOpsHelpers. An OpenTelemetry Collector reads it with the
|
|
10
|
+
``otlp_json_file`` receiver, or, from a container's logs, with the ``file_log`` receiver
|
|
11
|
+
and the ``otlp_json`` connector, with no parsing rules either way. The two tools read the
|
|
12
|
+
same variables (``LDO_LOG_FORMAT``, ``LDO_LOG_LEVEL``, ``LDO_SERVICE_NAME`` and the trace
|
|
13
|
+
context), so one pipeline setting controls both. One difference is deliberate: with
|
|
14
|
+
nothing set, this CLI writes readable text.
|
|
15
|
+
|
|
16
|
+
https://opentelemetry.io/docs/specs/otel/protocol/file-exporter/
|
|
17
|
+
https://opentelemetry.io/docs/specs/otlp/#json-protobuf-encoding
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import logging
|
|
24
|
+
import os
|
|
25
|
+
import sys
|
|
26
|
+
import traceback
|
|
27
|
+
from collections.abc import Mapping
|
|
28
|
+
from typing import Any
|
|
29
|
+
from urllib.parse import unquote
|
|
30
|
+
|
|
31
|
+
from libre_devops_helpers import __version__
|
|
32
|
+
from libre_devops_helpers.core import brand
|
|
33
|
+
|
|
34
|
+
LOG_FORMATS = ("text", "json", "otlp")
|
|
35
|
+
|
|
36
|
+
# LibreDevOpsHelpers' names for formats and levels, so its settings mean the same here.
|
|
37
|
+
_FORMAT_ALIASES = {"jsonindented": "json", "otlpindented": "otlp"}
|
|
38
|
+
_LEVELS = {
|
|
39
|
+
"trace": logging.DEBUG,
|
|
40
|
+
"debug": logging.DEBUG,
|
|
41
|
+
"info": logging.INFO,
|
|
42
|
+
"success": logging.INFO,
|
|
43
|
+
"warn": logging.WARNING,
|
|
44
|
+
"warning": logging.WARNING,
|
|
45
|
+
"error": logging.ERROR,
|
|
46
|
+
"fatal": logging.CRITICAL,
|
|
47
|
+
}
|
|
48
|
+
SERVICE_NAME = brand.COMMAND
|
|
49
|
+
|
|
50
|
+
# https://opentelemetry.io/docs/specs/otel/logs/data-model/#field-severitynumber
|
|
51
|
+
_SEVERITY = {
|
|
52
|
+
logging.DEBUG: 5,
|
|
53
|
+
logging.INFO: 9,
|
|
54
|
+
logging.WARNING: 13,
|
|
55
|
+
logging.ERROR: 17,
|
|
56
|
+
logging.CRITICAL: 21,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class JsonFormatter(logging.Formatter):
|
|
61
|
+
"""One flat JSON object per record."""
|
|
62
|
+
|
|
63
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
64
|
+
data: dict[str, Any] = {
|
|
65
|
+
"time": self.formatTime(record, "%Y-%m-%dT%H:%M:%S") + f".{int(record.msecs):03d}",
|
|
66
|
+
"level": record.levelname.lower(),
|
|
67
|
+
"logger": record.name,
|
|
68
|
+
"message": record.getMessage(),
|
|
69
|
+
}
|
|
70
|
+
if getattr(record, "hint", None):
|
|
71
|
+
data["hint"] = record.hint
|
|
72
|
+
if record.exc_info:
|
|
73
|
+
data["exception"] = self.formatException(record.exc_info)
|
|
74
|
+
return json.dumps(data)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class OtlpFormatter(logging.Formatter):
|
|
78
|
+
"""One OTLP/JSON ``LogsData`` per record, on one line.
|
|
79
|
+
|
|
80
|
+
OTLP's JSON rules differ from plain proto3 JSON: 64-bit integers such as timestamps are
|
|
81
|
+
strings, ``severityNumber`` is the integer, ``traceId`` and ``spanId`` are lowercase
|
|
82
|
+
hex, and attribute values are typed. Attribute names follow the semantic conventions.
|
|
83
|
+
|
|
84
|
+
The resource describes what is logging: ``service.name`` (``LDO_SERVICE_NAME``, else
|
|
85
|
+
``OTEL_SERVICE_NAME``, else the command), ``service.version`` and, with
|
|
86
|
+
``LDO_DEPLOYMENT_ENVIRONMENT``, ``deployment.environment.name``; anything else comes
|
|
87
|
+
from ``OTEL_RESOURCE_ATTRIBUTES``. ``LDO_TRACE_ID``, ``LDO_SPAN_ID`` and
|
|
88
|
+
``LDO_CORRELATION_ID`` put every record in a trace, so a CI run's logs join up; an id
|
|
89
|
+
that is not valid hex of the right width is left out rather than sent and rejected.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
def __init__(
|
|
93
|
+
self,
|
|
94
|
+
service_name: str | None = None,
|
|
95
|
+
service_version: str | None = None,
|
|
96
|
+
*,
|
|
97
|
+
environ: Mapping[str, str] | None = None,
|
|
98
|
+
):
|
|
99
|
+
super().__init__()
|
|
100
|
+
env = os.environ if environ is None else environ
|
|
101
|
+
self._resource = {"attributes": _resource_attributes(env, service_name, service_version)}
|
|
102
|
+
correlation = (env.get(brand.env_var("CORRELATION_ID")) or "").strip()
|
|
103
|
+
self._correlation_id = correlation
|
|
104
|
+
self._trace_id = otlp_hex_id(env.get(brand.env_var("TRACE_ID")), 32) or otlp_hex_id(
|
|
105
|
+
correlation, 32
|
|
106
|
+
)
|
|
107
|
+
self._span_id = otlp_hex_id(env.get(brand.env_var("SPAN_ID")), 16)
|
|
108
|
+
|
|
109
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
110
|
+
nanos = str(int(record.created * 1_000_000_000))
|
|
111
|
+
attributes = [
|
|
112
|
+
_attribute("code.function.name", f"{record.name}.{record.funcName}"),
|
|
113
|
+
_attribute("code.line.number", record.lineno),
|
|
114
|
+
]
|
|
115
|
+
if self._correlation_id:
|
|
116
|
+
attributes.append(_attribute("correlation_id", self._correlation_id))
|
|
117
|
+
if getattr(record, "hint", None):
|
|
118
|
+
attributes.append(_attribute("hint", str(record.hint)))
|
|
119
|
+
if record.exc_info and record.exc_info[0] is not None:
|
|
120
|
+
error_type, error, trace = record.exc_info
|
|
121
|
+
attributes += [
|
|
122
|
+
_attribute("exception.type", error_type.__name__),
|
|
123
|
+
_attribute("exception.message", str(error)),
|
|
124
|
+
_attribute(
|
|
125
|
+
"exception.stacktrace",
|
|
126
|
+
"".join(traceback.format_exception(error_type, error, trace)),
|
|
127
|
+
),
|
|
128
|
+
]
|
|
129
|
+
log_record: dict[str, Any] = {
|
|
130
|
+
"timeUnixNano": nanos,
|
|
131
|
+
"observedTimeUnixNano": nanos,
|
|
132
|
+
"severityNumber": _SEVERITY.get(record.levelno, 9),
|
|
133
|
+
"severityText": record.levelname,
|
|
134
|
+
"body": {"stringValue": record.getMessage()},
|
|
135
|
+
"attributes": attributes,
|
|
136
|
+
}
|
|
137
|
+
if self._trace_id:
|
|
138
|
+
log_record["traceId"] = self._trace_id
|
|
139
|
+
if self._span_id:
|
|
140
|
+
log_record["spanId"] = self._span_id
|
|
141
|
+
request = {
|
|
142
|
+
"resourceLogs": [
|
|
143
|
+
{
|
|
144
|
+
"resource": self._resource,
|
|
145
|
+
"scopeLogs": [{"scope": {"name": record.name}, "logRecords": [log_record]}],
|
|
146
|
+
}
|
|
147
|
+
]
|
|
148
|
+
}
|
|
149
|
+
return json.dumps(request, separators=(",", ":"))
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def otlp_hex_id(value: str | None, length: int) -> str:
|
|
153
|
+
"""A trace (32) or span (16) id in OTLP's lowercase hex, or "" when ``value`` is not one.
|
|
154
|
+
|
|
155
|
+
Dashes are dropped, so a GUID, which is 16 bytes, becomes a trace id: a CI run's id
|
|
156
|
+
can join its logs up without reformatting. An all-zero id is invalid in W3C trace
|
|
157
|
+
context, so it is refused too. Refusing means leaving the field out: a collector
|
|
158
|
+
rejects a whole payload with a bad id, and logging must never lose the record.
|
|
159
|
+
"""
|
|
160
|
+
candidate = (value or "").strip().replace("-", "").lower()
|
|
161
|
+
if len(candidate) != length or candidate.strip("0123456789abcdef") or not candidate.strip("0"):
|
|
162
|
+
return ""
|
|
163
|
+
return candidate
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _resource_attributes(
|
|
167
|
+
environ: Mapping[str, str], service_name: str | None, service_version: str | None
|
|
168
|
+
) -> list[dict[str, Any]]:
|
|
169
|
+
extra = _parse_resource_attributes(environ.get("OTEL_RESOURCE_ATTRIBUTES", ""))
|
|
170
|
+
named = extra.pop("service.name", None)
|
|
171
|
+
versioned = extra.pop("service.version", None)
|
|
172
|
+
placed = extra.pop("deployment.environment.name", None)
|
|
173
|
+
name = (
|
|
174
|
+
service_name
|
|
175
|
+
or environ.get(brand.env_var("SERVICE_NAME"))
|
|
176
|
+
or environ.get("OTEL_SERVICE_NAME")
|
|
177
|
+
or named
|
|
178
|
+
or SERVICE_NAME
|
|
179
|
+
)
|
|
180
|
+
version = service_version or environ.get(brand.env_var("SERVICE_VERSION")) or versioned
|
|
181
|
+
environment = environ.get(brand.env_var("DEPLOYMENT_ENVIRONMENT")) or placed
|
|
182
|
+
attributes = [
|
|
183
|
+
_attribute("service.name", name),
|
|
184
|
+
_attribute("service.version", version or __version__),
|
|
185
|
+
]
|
|
186
|
+
if environment:
|
|
187
|
+
attributes.append(_attribute("deployment.environment.name", environment))
|
|
188
|
+
attributes.extend(_attribute(key, value) for key, value in extra.items())
|
|
189
|
+
return attributes
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _parse_resource_attributes(text: str) -> dict[str, str]:
|
|
193
|
+
"""``OTEL_RESOURCE_ATTRIBUTES``: ``key=value`` pairs, comma separated, values
|
|
194
|
+
percent-encoded. A malformed pair is skipped, as the specification allows."""
|
|
195
|
+
found: dict[str, str] = {}
|
|
196
|
+
for pair in text.split(","):
|
|
197
|
+
key, sep, value = pair.partition("=")
|
|
198
|
+
if sep and key.strip():
|
|
199
|
+
found[unquote(key.strip())] = unquote(value.strip())
|
|
200
|
+
return found
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _attribute(key: str, value: str | int) -> dict[str, Any]:
|
|
204
|
+
if isinstance(value, int):
|
|
205
|
+
return {"key": key, "value": {"intValue": str(value)}}
|
|
206
|
+
return {"key": key, "value": {"stringValue": value}}
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def normalise_format(fmt: str | None) -> str:
|
|
210
|
+
"""``text``, ``json`` or ``otlp`` for any spelling LibreDevOpsHelpers accepts."""
|
|
211
|
+
value = (fmt or "text").strip().lower()
|
|
212
|
+
value = _FORMAT_ALIASES.get(value, value)
|
|
213
|
+
return value if value in LOG_FORMATS else "text"
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def resolve_level(verbosity: int = 0, level_name: str | None = None) -> int:
|
|
217
|
+
"""-v (info) and -vv (debug) win; otherwise ``level_name`` if valid; otherwise warnings."""
|
|
218
|
+
if verbosity == 1:
|
|
219
|
+
return logging.INFO
|
|
220
|
+
if verbosity >= 2:
|
|
221
|
+
return logging.DEBUG
|
|
222
|
+
return _LEVELS.get((level_name or "").strip().lower(), logging.WARNING)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def configure_logging(
|
|
226
|
+
verbosity: int = 0, fmt: str | None = "text", level_name: str | None = None
|
|
227
|
+
) -> None:
|
|
228
|
+
"""Send logs to stderr at the resolved level, in the chosen format.
|
|
229
|
+
|
|
230
|
+
An unknown format or level falls back to the default, because a logging preference
|
|
231
|
+
must never stop the command it is logging about.
|
|
232
|
+
"""
|
|
233
|
+
level = resolve_level(verbosity, level_name)
|
|
234
|
+
fmt = normalise_format(fmt)
|
|
235
|
+
handler = logging.StreamHandler(sys.stderr)
|
|
236
|
+
if fmt == "json":
|
|
237
|
+
handler.setFormatter(JsonFormatter())
|
|
238
|
+
elif fmt == "otlp":
|
|
239
|
+
handler.setFormatter(OtlpFormatter())
|
|
240
|
+
else:
|
|
241
|
+
handler.setFormatter(
|
|
242
|
+
logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s", "%H:%M:%S")
|
|
243
|
+
)
|
|
244
|
+
logging.basicConfig(level=level, handlers=[handler], force=True)
|
|
245
|
+
# urllib3 logs each connection at DEBUG; that is noise unless asked for twice over.
|
|
246
|
+
logging.getLogger("urllib3").setLevel(logging.DEBUG if verbosity >= 3 else logging.WARNING)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Run a check repeatedly until it is complete or a limit is reached.
|
|
2
|
+
|
|
3
|
+
The clock and the sleep are injected, so a poll of hours runs in microseconds in tests.
|
|
4
|
+
The poller never sleeps past the timeout: the last wait is cut short so one final pass
|
|
5
|
+
runs at the deadline.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import time
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Generic, Literal, TypeVar
|
|
14
|
+
|
|
15
|
+
from libre_devops_helpers.core.errors import InputError
|
|
16
|
+
|
|
17
|
+
T = TypeVar("T")
|
|
18
|
+
|
|
19
|
+
Reason = Literal["complete", "timeout", "max-passes"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class PollLimits:
|
|
24
|
+
"""How often to check, and when to give up. ``None`` means no limit of that kind."""
|
|
25
|
+
|
|
26
|
+
interval: float
|
|
27
|
+
timeout: float | None = None
|
|
28
|
+
max_passes: int | None = None
|
|
29
|
+
|
|
30
|
+
def __post_init__(self) -> None:
|
|
31
|
+
if self.interval <= 0:
|
|
32
|
+
raise InputError("the poll interval must be more than zero seconds")
|
|
33
|
+
if self.timeout is not None and self.timeout <= 0:
|
|
34
|
+
raise InputError("the poll timeout must be more than zero seconds")
|
|
35
|
+
if self.max_passes is not None and self.max_passes < 1:
|
|
36
|
+
raise InputError("the poll needs at least one pass")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class PollOutcome(Generic[T]):
|
|
41
|
+
"""What the poll ended with: the last pass's result and why it stopped."""
|
|
42
|
+
|
|
43
|
+
result: T
|
|
44
|
+
passes: int
|
|
45
|
+
elapsed: float
|
|
46
|
+
reason: Reason
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def complete(self) -> bool:
|
|
50
|
+
return self.reason == "complete"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def poll(
|
|
54
|
+
run_pass: Callable[[int], T],
|
|
55
|
+
is_complete: Callable[[T], bool],
|
|
56
|
+
limits: PollLimits,
|
|
57
|
+
*,
|
|
58
|
+
clock: Callable[[], float] = time.monotonic,
|
|
59
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
60
|
+
on_pass: Callable[[int, T], None] | None = None,
|
|
61
|
+
on_wait: Callable[[float], None] | None = None,
|
|
62
|
+
) -> PollOutcome[T]:
|
|
63
|
+
"""Call ``run_pass(n)`` for n = 1, 2, ... until ``is_complete`` or a limit stops it.
|
|
64
|
+
|
|
65
|
+
``on_pass`` sees each result; ``on_wait`` is told how long the next wait is.
|
|
66
|
+
Exceptions from ``run_pass`` propagate: the caller decides which ones a pass may
|
|
67
|
+
absorb (a throttled request) and which end the poll (a rejected token).
|
|
68
|
+
"""
|
|
69
|
+
started = clock()
|
|
70
|
+
passes = 0
|
|
71
|
+
while True:
|
|
72
|
+
passes += 1
|
|
73
|
+
result = run_pass(passes)
|
|
74
|
+
if on_pass is not None:
|
|
75
|
+
on_pass(passes, result)
|
|
76
|
+
elapsed = clock() - started
|
|
77
|
+
if is_complete(result):
|
|
78
|
+
return PollOutcome(result, passes, elapsed, "complete")
|
|
79
|
+
if limits.max_passes is not None and passes >= limits.max_passes:
|
|
80
|
+
return PollOutcome(result, passes, elapsed, "max-passes")
|
|
81
|
+
if limits.timeout is not None and elapsed >= limits.timeout:
|
|
82
|
+
return PollOutcome(result, passes, elapsed, "timeout")
|
|
83
|
+
wait = limits.interval
|
|
84
|
+
if limits.timeout is not None:
|
|
85
|
+
wait = min(wait, limits.timeout - elapsed)
|
|
86
|
+
if on_wait is not None:
|
|
87
|
+
on_wait(wait)
|
|
88
|
+
sleep(wait)
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Run an external command line tool: checked exit codes, a timeout, readable errors.
|
|
2
|
+
|
|
3
|
+
Vendor layers build on ``CommandRunner`` for their own tools (the Azure CLI today),
|
|
4
|
+
supplying how that tool's errors read and what to suggest. Output is returned to the
|
|
5
|
+
caller but never logged, because it can hold a token.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import itertools
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import shutil
|
|
15
|
+
import subprocess
|
|
16
|
+
from collections.abc import Callable, Mapping
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from libre_devops_helpers.core.errors import CommandError
|
|
20
|
+
|
|
21
|
+
log = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
Runner = Callable[..., "subprocess.CompletedProcess[str]"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class CommandRunner:
|
|
27
|
+
"""Runs one executable, found on ``PATH`` by ``name`` unless ``executable`` is given.
|
|
28
|
+
|
|
29
|
+
``runner`` defaults to ``subprocess.run``; tests pass a fake so nothing real runs.
|
|
30
|
+
Subclasses override ``clean_stderr`` and ``hint_for`` for their tool, and
|
|
31
|
+
``error_type`` for a more specific error.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
error_type: type[CommandError] = CommandError
|
|
35
|
+
# Non-interactive commands are bounded. Interactive ones wait on a person, so are not.
|
|
36
|
+
timeout_seconds = 120
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
name: str,
|
|
41
|
+
executable: str | None = None,
|
|
42
|
+
*,
|
|
43
|
+
install_hint: str | None = None,
|
|
44
|
+
runner: Runner = subprocess.run,
|
|
45
|
+
) -> None:
|
|
46
|
+
self.name = name
|
|
47
|
+
self._executable = executable
|
|
48
|
+
self._install_hint = install_hint
|
|
49
|
+
self._runner = runner
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def executable(self) -> str:
|
|
53
|
+
"""Path to the tool, resolved on first use so importing never requires it."""
|
|
54
|
+
if self._executable is None:
|
|
55
|
+
found = shutil.which(self.name)
|
|
56
|
+
if found is None:
|
|
57
|
+
raise self.error_type(f"'{self.name}' is not on PATH", hint=self._install_hint)
|
|
58
|
+
self._executable = found
|
|
59
|
+
return self._executable
|
|
60
|
+
|
|
61
|
+
def run(
|
|
62
|
+
self, *args: str, interactive: bool = False, env: Mapping[str, str] | None = None
|
|
63
|
+
) -> str:
|
|
64
|
+
"""Run the tool with ``args`` and return stdout. Raises on a non-zero exit.
|
|
65
|
+
|
|
66
|
+
``interactive`` connects the terminal instead of capturing output.
|
|
67
|
+
"""
|
|
68
|
+
label = " ".join(
|
|
69
|
+
[self.name, *itertools.takewhile(lambda arg: not arg.startswith("-"), args)]
|
|
70
|
+
)
|
|
71
|
+
log.debug("running %s", " ".join([self.name, *args]))
|
|
72
|
+
try:
|
|
73
|
+
result = self._runner(
|
|
74
|
+
[self.executable, *args],
|
|
75
|
+
capture_output=not interactive,
|
|
76
|
+
text=True,
|
|
77
|
+
env={**os.environ, **env} if env else None,
|
|
78
|
+
timeout=None if interactive else self.timeout_seconds,
|
|
79
|
+
check=False,
|
|
80
|
+
)
|
|
81
|
+
except subprocess.TimeoutExpired:
|
|
82
|
+
raise self.error_type(f"{label} timed out after {self.timeout_seconds}s") from None
|
|
83
|
+
except OSError as exc:
|
|
84
|
+
raise self.error_type(f"cannot run {label}: {exc}") from None
|
|
85
|
+
if result.returncode != 0:
|
|
86
|
+
detail = self.clean_stderr(result.stderr) or f"exit code {result.returncode}"
|
|
87
|
+
raise self.error_type(f"{label} failed: {detail}", hint=self.hint_for(detail))
|
|
88
|
+
return result.stdout or ""
|
|
89
|
+
|
|
90
|
+
def run_json(self, *args: str) -> Any:
|
|
91
|
+
"""Run the tool and parse its stdout as JSON (None for no output)."""
|
|
92
|
+
out = self.run(*args).strip()
|
|
93
|
+
if not out:
|
|
94
|
+
return None
|
|
95
|
+
try:
|
|
96
|
+
return json.loads(out)
|
|
97
|
+
except json.JSONDecodeError:
|
|
98
|
+
raise self.error_type(f"{self.name} {' '.join(args[:2])} did not return JSON") from None
|
|
99
|
+
|
|
100
|
+
def clean_stderr(self, stderr: str | None) -> str:
|
|
101
|
+
"""The error message from stderr, on one line."""
|
|
102
|
+
return " ".join(line.strip() for line in (stderr or "").splitlines() if line.strip())[:1000]
|
|
103
|
+
|
|
104
|
+
def hint_for(self, detail: str) -> str | None:
|
|
105
|
+
"""A next step for a failed command, when the message points to one."""
|
|
106
|
+
return None
|