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,266 @@
|
|
|
1
|
+
"""Where a sign-in's refresh token is kept between commands: a profile's token_cache.
|
|
2
|
+
|
|
3
|
+
Three choices, from safest to least guarded (profiles use ``file`` unless they say
|
|
4
|
+
otherwise, since it works on a headless machine):
|
|
5
|
+
|
|
6
|
+
- ``memory``: nowhere. The sign-in lasts one command and is gone after.
|
|
7
|
+
- ``keychain``: the operating system's own store, locked to your account. Keychain on
|
|
8
|
+
macOS and the Secret Service (GNOME Keyring, KWallet) on Linux, through the optional
|
|
9
|
+
``keyring`` package; on Windows, a file encrypted with DPAPI for your Windows account,
|
|
10
|
+
since Credential Manager's size limit is too small for a refresh token.
|
|
11
|
+
- ``file`` (the default for profiles): a plaintext JSON file that only your account may
|
|
12
|
+
read (mode 0600), as the Azure CLI keeps its own tokens on Linux. Anyone who can act as
|
|
13
|
+
you, or as root, or who gets a copy of the file (a backup, say), can use what is in it.
|
|
14
|
+
|
|
15
|
+
A refresh token is as good as a sign-in until it expires or is revoked, so a file that
|
|
16
|
+
other accounts can read is refused rather than used, as ssh refuses a readable key.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import logging
|
|
23
|
+
import os
|
|
24
|
+
import sys
|
|
25
|
+
import threading
|
|
26
|
+
from collections.abc import Callable, Mapping
|
|
27
|
+
from datetime import UTC, datetime
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from types import ModuleType
|
|
30
|
+
from typing import Any, Protocol
|
|
31
|
+
|
|
32
|
+
from libre_devops_helpers.core import brand
|
|
33
|
+
from libre_devops_helpers.core.errors import AuthError, ConfigError
|
|
34
|
+
|
|
35
|
+
log = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
TOKEN_CACHES = ("memory", "keychain", "file")
|
|
38
|
+
# Where a profile keeps its sign-in unless it says otherwise: a private file, which works
|
|
39
|
+
# on a headless machine as well as a desktop.
|
|
40
|
+
DEFAULT_TOKEN_CACHE = "file"
|
|
41
|
+
FILE_ENV = brand.env_var("TOKEN_CACHE")
|
|
42
|
+
KEYCHAIN_SERVICE = f"{brand.COMMAND} sign-in"
|
|
43
|
+
_KEYCHAIN_HINT = f"install the keychain extra: uv tool install '{brand.DISTRIBUTION}[keychain]'"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class TokenStore(Protocol):
|
|
47
|
+
"""Named secrets that outlast one command (or, for MemoryStore, do not)."""
|
|
48
|
+
|
|
49
|
+
def load(self, key: str) -> str | None:
|
|
50
|
+
"""The value kept under ``key``, or None when there is none."""
|
|
51
|
+
|
|
52
|
+
def save(self, key: str, value: str) -> None:
|
|
53
|
+
"""Keep ``value`` under ``key``, replacing what was there."""
|
|
54
|
+
|
|
55
|
+
def delete(self, key: str) -> bool:
|
|
56
|
+
"""Forget ``key``. True when there was something to forget."""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class MemoryStore:
|
|
60
|
+
"""Kept in this process only: the default, and what every other store falls back to."""
|
|
61
|
+
|
|
62
|
+
def __init__(self) -> None:
|
|
63
|
+
self._values: dict[str, str] = {}
|
|
64
|
+
self._lock = threading.Lock()
|
|
65
|
+
|
|
66
|
+
def load(self, key: str) -> str | None:
|
|
67
|
+
with self._lock:
|
|
68
|
+
return self._values.get(key)
|
|
69
|
+
|
|
70
|
+
def save(self, key: str, value: str) -> None:
|
|
71
|
+
with self._lock:
|
|
72
|
+
self._values[key] = value
|
|
73
|
+
|
|
74
|
+
def delete(self, key: str) -> bool:
|
|
75
|
+
with self._lock:
|
|
76
|
+
return self._values.pop(key, None) is not None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class FileStore:
|
|
80
|
+
"""Values in one JSON file, private to your account, replaced whole on every write.
|
|
81
|
+
|
|
82
|
+
The directory is created 0700 and the file 0600, through a temporary file renamed
|
|
83
|
+
into place, so a crash never leaves half a file. With ``protect`` and ``unprotect``
|
|
84
|
+
(DPAPI on Windows) the file holds their encrypted bytes rather than plain JSON.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
def __init__(
|
|
88
|
+
self,
|
|
89
|
+
path: Path,
|
|
90
|
+
*,
|
|
91
|
+
protect: Callable[[bytes], bytes] | None = None,
|
|
92
|
+
unprotect: Callable[[bytes], bytes] | None = None,
|
|
93
|
+
) -> None:
|
|
94
|
+
self.path = path
|
|
95
|
+
self._protect = protect
|
|
96
|
+
self._unprotect = unprotect
|
|
97
|
+
self._lock = threading.Lock()
|
|
98
|
+
|
|
99
|
+
def load(self, key: str) -> str | None:
|
|
100
|
+
with self._lock:
|
|
101
|
+
entry = self._read().get(key)
|
|
102
|
+
value = entry.get("value") if isinstance(entry, dict) else None
|
|
103
|
+
return value if isinstance(value, str) and value else None
|
|
104
|
+
|
|
105
|
+
def save(self, key: str, value: str) -> None:
|
|
106
|
+
with self._lock:
|
|
107
|
+
data = self._read(refuse_exposed=False)
|
|
108
|
+
data[key] = {"value": value, "saved": datetime.now(UTC).isoformat(timespec="seconds")}
|
|
109
|
+
self._write(data)
|
|
110
|
+
|
|
111
|
+
def delete(self, key: str) -> bool:
|
|
112
|
+
with self._lock:
|
|
113
|
+
data = self._read(refuse_exposed=False)
|
|
114
|
+
if key not in data:
|
|
115
|
+
return False
|
|
116
|
+
del data[key]
|
|
117
|
+
if data:
|
|
118
|
+
self._write(data)
|
|
119
|
+
else:
|
|
120
|
+
self.path.unlink(missing_ok=True)
|
|
121
|
+
return True
|
|
122
|
+
|
|
123
|
+
def _read(self, *, refuse_exposed: bool = True) -> dict[str, Any]:
|
|
124
|
+
try:
|
|
125
|
+
raw = self.path.read_bytes()
|
|
126
|
+
except FileNotFoundError:
|
|
127
|
+
return {}
|
|
128
|
+
except OSError as exc:
|
|
129
|
+
raise AuthError(f"cannot read the token cache {self.path}: {exc}") from None
|
|
130
|
+
if refuse_exposed and _exposed(self.path):
|
|
131
|
+
raise AuthError(
|
|
132
|
+
f"the token cache {self.path} can be read by other accounts, so it is not used",
|
|
133
|
+
hint=(
|
|
134
|
+
f"delete it (a new one is private): what it held may have been seen, so "
|
|
135
|
+
f"also run {brand.command('entra sign-out')} and sign out everywhere if "
|
|
136
|
+
"that matters"
|
|
137
|
+
),
|
|
138
|
+
)
|
|
139
|
+
try:
|
|
140
|
+
if self._unprotect is not None:
|
|
141
|
+
raw = self._unprotect(raw)
|
|
142
|
+
data = json.loads(raw.decode("utf-8"))
|
|
143
|
+
except (OSError, ValueError) as exc:
|
|
144
|
+
# A damaged cache, or one encrypted for another account: start again.
|
|
145
|
+
log.warning("ignoring the token cache %s, which cannot be read: %s", self.path, exc)
|
|
146
|
+
return {}
|
|
147
|
+
return data if isinstance(data, dict) else {}
|
|
148
|
+
|
|
149
|
+
def _write(self, data: Mapping[str, Any]) -> None:
|
|
150
|
+
payload = json.dumps(data, indent=2, sort_keys=True).encode("utf-8")
|
|
151
|
+
if self._protect is not None:
|
|
152
|
+
payload = self._protect(payload)
|
|
153
|
+
folder = self.path.parent
|
|
154
|
+
try:
|
|
155
|
+
if not folder.exists():
|
|
156
|
+
folder.mkdir(parents=True, mode=0o700)
|
|
157
|
+
os.chmod(folder, 0o700) # mkdir's mode is narrowed by the umask, not widened
|
|
158
|
+
temporary = folder / f".{self.path.name}.{os.getpid()}.tmp"
|
|
159
|
+
descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
160
|
+
try:
|
|
161
|
+
with os.fdopen(descriptor, "wb") as handle:
|
|
162
|
+
handle.write(payload)
|
|
163
|
+
handle.flush()
|
|
164
|
+
os.fsync(handle.fileno())
|
|
165
|
+
os.replace(temporary, self.path)
|
|
166
|
+
finally:
|
|
167
|
+
temporary.unlink(missing_ok=True)
|
|
168
|
+
except OSError as exc:
|
|
169
|
+
raise AuthError(f"cannot write the token cache {self.path}: {exc}") from None
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class KeyringStore:
|
|
173
|
+
"""Values in the operating system's keychain, through the ``keyring`` package."""
|
|
174
|
+
|
|
175
|
+
def __init__(self, service: str = KEYCHAIN_SERVICE, *, backend: ModuleType | None = None):
|
|
176
|
+
if backend is None:
|
|
177
|
+
try:
|
|
178
|
+
# Optional (the keychain extra), so imported only when asked for.
|
|
179
|
+
import keyring as backend
|
|
180
|
+
except ImportError:
|
|
181
|
+
raise ConfigError(
|
|
182
|
+
'token_cache = "keychain" needs the keyring package', hint=_KEYCHAIN_HINT
|
|
183
|
+
) from None
|
|
184
|
+
self.service = service
|
|
185
|
+
self._backend = backend
|
|
186
|
+
errors = getattr(backend, "errors", None)
|
|
187
|
+
self._error: type[Exception] = getattr(errors, "KeyringError", Exception)
|
|
188
|
+
|
|
189
|
+
def load(self, key: str) -> str | None:
|
|
190
|
+
try:
|
|
191
|
+
return self._backend.get_password(self.service, key) or None
|
|
192
|
+
except self._error as exc:
|
|
193
|
+
raise self._failure("read", exc) from None
|
|
194
|
+
|
|
195
|
+
def save(self, key: str, value: str) -> None:
|
|
196
|
+
try:
|
|
197
|
+
self._backend.set_password(self.service, key, value)
|
|
198
|
+
except self._error as exc:
|
|
199
|
+
raise self._failure("write to", exc) from None
|
|
200
|
+
|
|
201
|
+
def delete(self, key: str) -> bool:
|
|
202
|
+
try:
|
|
203
|
+
if self._backend.get_password(self.service, key) is None:
|
|
204
|
+
return False
|
|
205
|
+
self._backend.delete_password(self.service, key)
|
|
206
|
+
except self._error as exc:
|
|
207
|
+
raise self._failure("write to", exc) from None
|
|
208
|
+
return True
|
|
209
|
+
|
|
210
|
+
def _failure(self, action: str, exc: Exception) -> AuthError:
|
|
211
|
+
return AuthError(
|
|
212
|
+
f"cannot {action} the keychain: {exc}",
|
|
213
|
+
hint=(
|
|
214
|
+
"on Linux this needs a Secret Service (GNOME Keyring or KWallet) running; "
|
|
215
|
+
'without one, use token_cache = "file" or "memory"'
|
|
216
|
+
),
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def default_path(
|
|
221
|
+
*, encrypted: bool = False, environ: Mapping[str, str] = os.environ, platform: str = ""
|
|
222
|
+
) -> Path:
|
|
223
|
+
"""Where the file stores live: per user, never beside a config file in a repository.
|
|
224
|
+
|
|
225
|
+
The brand's ``TOKEN_CACHE`` variable names a file instead. Otherwise it is
|
|
226
|
+
``%LOCALAPPDATA%\\<tool>`` on Windows and ``$XDG_STATE_HOME/<tool>`` (by default
|
|
227
|
+
``~/.local/state/<tool>``) elsewhere.
|
|
228
|
+
"""
|
|
229
|
+
name = "refresh-tokens.dpapi" if encrypted else "refresh-tokens.json"
|
|
230
|
+
override = environ.get(FILE_ENV)
|
|
231
|
+
if override:
|
|
232
|
+
path = Path(override).expanduser()
|
|
233
|
+
return path.with_suffix(".dpapi") if encrypted else path
|
|
234
|
+
if (platform or sys.platform) == "win32":
|
|
235
|
+
base = Path(environ.get("LOCALAPPDATA") or Path.home() / "AppData" / "Local")
|
|
236
|
+
else:
|
|
237
|
+
base = Path(environ.get("XDG_STATE_HOME") or Path.home() / ".local" / "state")
|
|
238
|
+
return base / brand.CONFIG_DIR / name
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def open_store(
|
|
242
|
+
kind: str, *, environ: Mapping[str, str] = os.environ, platform: str = ""
|
|
243
|
+
) -> TokenStore:
|
|
244
|
+
"""The store a profile's ``token_cache`` names."""
|
|
245
|
+
if kind == "memory":
|
|
246
|
+
return MemoryStore()
|
|
247
|
+
if kind == "file":
|
|
248
|
+
return FileStore(default_path(environ=environ, platform=platform))
|
|
249
|
+
if kind == "keychain":
|
|
250
|
+
if (platform or sys.platform) == "win32":
|
|
251
|
+
from libre_devops_helpers.core import dpapi # Windows only
|
|
252
|
+
|
|
253
|
+
return FileStore(
|
|
254
|
+
default_path(encrypted=True, environ=environ, platform=platform),
|
|
255
|
+
protect=dpapi.protect,
|
|
256
|
+
unprotect=dpapi.unprotect,
|
|
257
|
+
)
|
|
258
|
+
return KeyringStore()
|
|
259
|
+
raise ConfigError(f"token_cache must be one of {', '.join(TOKEN_CACHES)}, not {kind!r}")
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _exposed(path: Path) -> bool:
|
|
263
|
+
"""True when accounts other than the owner may read ``path`` (POSIX permissions)."""
|
|
264
|
+
if os.name != "posix":
|
|
265
|
+
return False # Windows: the per-user profile folder's ACL keeps others out
|
|
266
|
+
return bool(path.stat().st_mode & 0o077)
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Small helpers shared across the subpackages: names, OData literals, timestamps."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Iterable
|
|
7
|
+
from datetime import UTC, datetime, timedelta
|
|
8
|
+
|
|
9
|
+
from libre_devops_helpers.core.errors import InputError
|
|
10
|
+
|
|
11
|
+
_GUID = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.IGNORECASE)
|
|
12
|
+
# Some Microsoft APIs return seven fractional digits; datetime accepts six.
|
|
13
|
+
_LONG_FRACTION = re.compile(r"\.(\d{6})\d+")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def is_guid(value: str) -> bool:
|
|
17
|
+
"""True when ``value`` is a GUID in the 8-4-4-4-12 form."""
|
|
18
|
+
return bool(_GUID.match(value.strip()))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def short_name(name: str) -> str:
|
|
22
|
+
"""The host part of a name: ``web01.corp.example.com`` -> ``web01``."""
|
|
23
|
+
return name.strip().rstrip(".").split(".", 1)[0]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def candidate_names(name: str) -> list[str]:
|
|
27
|
+
"""Names to try for one device, most specific first: the FQDN, then the short hostname.
|
|
28
|
+
|
|
29
|
+
Entra and Defender do not record Linux host names consistently, so a lookup
|
|
30
|
+
that misses on the FQDN retries on the short name.
|
|
31
|
+
"""
|
|
32
|
+
full = name.strip().rstrip(".")
|
|
33
|
+
short = short_name(full)
|
|
34
|
+
return [full] if short.casefold() == full.casefold() else [full, short]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def split_names(values: Iterable[str]) -> list[str]:
|
|
38
|
+
"""Split comma- or whitespace-separated names, dropping blanks and repeats.
|
|
39
|
+
|
|
40
|
+
``["a,b", "c d", "A"]`` -> ``["a", "b", "c", "d"]``: a command can take
|
|
41
|
+
``"X,Y,Z"`` as one argument, as several arguments, or as lines from a file.
|
|
42
|
+
Repeats are matched case-insensitively and the first spelling is kept.
|
|
43
|
+
"""
|
|
44
|
+
seen: set[str] = set()
|
|
45
|
+
names: list[str] = []
|
|
46
|
+
for value in values:
|
|
47
|
+
for name in re.split(r"[,\s]+", value):
|
|
48
|
+
if name and name.casefold() not in seen:
|
|
49
|
+
seen.add(name.casefold())
|
|
50
|
+
names.append(name)
|
|
51
|
+
return names
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def odata_string(value: str) -> str:
|
|
55
|
+
"""Quote ``value`` as an OData string literal, doubling embedded single quotes."""
|
|
56
|
+
return "'" + value.replace("'", "''") + "'"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def odata_datetime(value: datetime) -> str:
|
|
60
|
+
"""A UTC OData datetime literal, ``2026-09-24T10:11:12Z``, which is never quoted."""
|
|
61
|
+
return value.astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def parse_datetime(value: object) -> datetime | None:
|
|
65
|
+
"""Parse an ISO 8601 timestamp from an API into an aware UTC datetime.
|
|
66
|
+
|
|
67
|
+
Returns None for empty or unparseable values rather than raising, because one
|
|
68
|
+
odd record should not fail a whole listing.
|
|
69
|
+
"""
|
|
70
|
+
if not isinstance(value, str) or not value.strip():
|
|
71
|
+
return None
|
|
72
|
+
text = _LONG_FRACTION.sub(r".\1", value.strip())
|
|
73
|
+
if text[-1] in "Zz":
|
|
74
|
+
text = text[:-1] + "+00:00"
|
|
75
|
+
try:
|
|
76
|
+
parsed = datetime.fromisoformat(text)
|
|
77
|
+
except ValueError:
|
|
78
|
+
return None
|
|
79
|
+
if parsed.tzinfo is None:
|
|
80
|
+
parsed = parsed.replace(tzinfo=UTC)
|
|
81
|
+
return parsed.astimezone(UTC)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
_DURATION = re.compile(r"(\d+)\s*([dhms])", re.IGNORECASE)
|
|
85
|
+
_UNIT_SECONDS = {"d": 86400, "h": 3600, "m": 60, "s": 1}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def parse_duration(text: str) -> timedelta:
|
|
89
|
+
"""Read ``90``, ``90s``, ``15m``, ``2h``, ``1h30m`` or ``7d`` as a duration.
|
|
90
|
+
|
|
91
|
+
A bare number is seconds. Raises InputError for anything else, including zero.
|
|
92
|
+
"""
|
|
93
|
+
value = text.strip().lower().replace(" ", "")
|
|
94
|
+
if value.isdigit():
|
|
95
|
+
seconds = int(value)
|
|
96
|
+
else:
|
|
97
|
+
parts = _DURATION.findall(value)
|
|
98
|
+
if not parts or "".join(number + unit for number, unit in parts) != value:
|
|
99
|
+
raise InputError(
|
|
100
|
+
f"cannot read the duration {text!r}", hint="use e.g. 90, 90s, 15m, 2h, 1h30m or 7d"
|
|
101
|
+
)
|
|
102
|
+
seconds = sum(int(number) * _UNIT_SECONDS[unit] for number, unit in parts)
|
|
103
|
+
if seconds <= 0:
|
|
104
|
+
raise InputError(f"the duration {text!r} must be more than zero")
|
|
105
|
+
return timedelta(seconds=seconds)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def format_duration(delta: timedelta) -> str:
|
|
109
|
+
"""Render a duration compactly: ``45s``, ``12m 05s``, ``3h 07m``, ``2d 04h``."""
|
|
110
|
+
seconds = int(abs(delta.total_seconds()))
|
|
111
|
+
days, seconds = divmod(seconds, 86400)
|
|
112
|
+
hours, seconds = divmod(seconds, 3600)
|
|
113
|
+
minutes, seconds = divmod(seconds, 60)
|
|
114
|
+
if days:
|
|
115
|
+
return f"{days}d {hours:02d}h"
|
|
116
|
+
if hours:
|
|
117
|
+
return f"{hours}h {minutes:02d}m"
|
|
118
|
+
if minutes:
|
|
119
|
+
return f"{minutes}m {seconds:02d}s"
|
|
120
|
+
return f"{seconds}s"
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""JSON-shaped data as YAML, with the standard library alone.
|
|
2
|
+
|
|
3
|
+
Only what JSON can hold is written (objects, arrays, strings, numbers, booleans, null),
|
|
4
|
+
in block style. A string is left plain only when no YAML reader could take it for
|
|
5
|
+
anything else: ``yes``, ``no``, ``null``, ``1.0``, ``2026-09-24``, ``@odata.context`` and
|
|
6
|
+
``a: b`` are all quoted, as JSON strings, which YAML reads the same way. A multi-line
|
|
7
|
+
string becomes a ``|`` block. There is no parser: reading YAML is not needed here.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import re
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
# kind ("key", "string", "number", "bool", "null", "punct") and text -> text to write.
|
|
18
|
+
Paint = Callable[[str, str], str]
|
|
19
|
+
|
|
20
|
+
# Plain only when it starts with a letter or underscore and holds nothing YAML reads
|
|
21
|
+
# specially; single spaces between words are fine.
|
|
22
|
+
_PLAIN = re.compile(r"[A-Za-z_][\w.\-/+@]*(?: [\w.\-/+@]+)*")
|
|
23
|
+
# Words YAML 1.1 or 1.2 readers take for booleans or null, in any case.
|
|
24
|
+
_WORDS = frozenset(
|
|
25
|
+
{"true", "false", "yes", "no", "on", "off", "y", "n", "null", "none", "nan", "inf"}
|
|
26
|
+
)
|
|
27
|
+
# Characters YAML will not take as they are inside a quoted string: C1 controls (U+0085
|
|
28
|
+
# is a line break to YAML), the Unicode line and paragraph separators, and non-characters.
|
|
29
|
+
_UNPRINTABLE = re.compile(r"[\x7f-\x9f\u2028\u2029\ufeff\ufffe\uffff\ud800-\udfff]")
|
|
30
|
+
# Characters that make a string unsafe for a literal block: controls other than tab.
|
|
31
|
+
_CONTROL = re.compile(r"[\x00-\x08\x0b-\x1f\x7f\u0085\u2028\u2029\ufeff]")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def dumps(data: Any, *, indent: int = 2, paint: Paint | None = None) -> str:
|
|
35
|
+
"""``data`` (as ``json.loads`` gives it) as a YAML document, ending in a newline.
|
|
36
|
+
|
|
37
|
+
The newline matters: a ``|`` block last in the document keeps its own final line
|
|
38
|
+
break only when the document ends with one.
|
|
39
|
+
"""
|
|
40
|
+
painter = paint or (lambda _kind, text: text)
|
|
41
|
+
return "\n".join(_node(data, 0, max(indent, 1), painter)) + "\n"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _node(value: Any, level: int, indent: int, paint: Paint) -> list[str]:
|
|
45
|
+
"""The lines for ``value`` standing alone, at ``level`` spaces."""
|
|
46
|
+
pad = " " * level
|
|
47
|
+
if isinstance(value, dict) and value:
|
|
48
|
+
lines = []
|
|
49
|
+
for key, item in value.items():
|
|
50
|
+
head = f"{pad}{paint('key', _string(str(key)))}{paint('punct', ':')}"
|
|
51
|
+
lines.extend(_entry(head, item, level, indent, paint))
|
|
52
|
+
return lines
|
|
53
|
+
if isinstance(value, list) and value:
|
|
54
|
+
lines = []
|
|
55
|
+
for item in value:
|
|
56
|
+
lines.extend(_item(item, level, indent, paint))
|
|
57
|
+
return lines
|
|
58
|
+
first, *rest = _scalar(value, level, indent, paint)
|
|
59
|
+
return [pad + first, *_indented(rest, level + indent)]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _entry(head: str, value: Any, level: int, indent: int, paint: Paint) -> list[str]:
|
|
63
|
+
"""``key:`` followed by its value: on the same line, or nested below it."""
|
|
64
|
+
if _nests(value):
|
|
65
|
+
return [head, *_node(value, level + indent, indent, paint)]
|
|
66
|
+
first, *rest = _scalar(value, level + indent, indent, paint)
|
|
67
|
+
return [f"{head} {first}", *_indented(rest, level + indent)]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _item(value: Any, level: int, indent: int, paint: Paint) -> list[str]:
|
|
71
|
+
"""``- `` followed by an array item; a nested object or array starts on the same line."""
|
|
72
|
+
dash = " " * level + paint("punct", "-")
|
|
73
|
+
if _nests(value):
|
|
74
|
+
inner = _node(value, level + 2, indent, paint)
|
|
75
|
+
return [dash + " " + inner[0].lstrip(), *inner[1:]]
|
|
76
|
+
first, *rest = _scalar(value, level + 2, indent, paint)
|
|
77
|
+
return [f"{dash} {first}", *_indented(rest, level + 2)]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _indented(lines: list[str], level: int) -> list[str]:
|
|
81
|
+
"""A block's lines at ``level``; blank ones stay empty, with no trailing spaces."""
|
|
82
|
+
return [" " * level + line if line else "" for line in lines]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _nests(value: Any) -> bool:
|
|
86
|
+
return isinstance(value, dict | list) and bool(value)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _scalar(value: Any, level: int, indent: int, paint: Paint) -> list[str]:
|
|
90
|
+
"""A scalar's lines: one, or a ``|`` header and a block, each without the indent."""
|
|
91
|
+
if value is None:
|
|
92
|
+
return [paint("null", "null")]
|
|
93
|
+
if isinstance(value, bool):
|
|
94
|
+
return [paint("bool", "true" if value else "false")]
|
|
95
|
+
if isinstance(value, float):
|
|
96
|
+
return [paint("number", _float(value))]
|
|
97
|
+
if isinstance(value, int):
|
|
98
|
+
return [paint("number", json.dumps(value))]
|
|
99
|
+
if isinstance(value, dict):
|
|
100
|
+
return [paint("punct", "{}")]
|
|
101
|
+
if isinstance(value, list):
|
|
102
|
+
return [paint("punct", "[]")]
|
|
103
|
+
text = str(value)
|
|
104
|
+
block = _block(text)
|
|
105
|
+
if block is not None:
|
|
106
|
+
return [paint("punct", block[0]), *(paint("string", line) for line in block[1:])]
|
|
107
|
+
return [paint("string", _string(text))]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _float(value: float) -> str:
|
|
111
|
+
"""A float every YAML reader takes as one: YAML 1.1 wants a point, so 1e-07 is 1.0e-07."""
|
|
112
|
+
text = json.dumps(value)
|
|
113
|
+
mantissa, marker, exponent = text.partition("e")
|
|
114
|
+
if marker and "." not in mantissa:
|
|
115
|
+
return f"{mantissa}.0e{exponent}"
|
|
116
|
+
return text
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _string(text: str) -> str:
|
|
120
|
+
"""``text`` plain when that reads back as the same string, else JSON-quoted."""
|
|
121
|
+
# fullmatch, not match with $: in Python, $ also matches before a final newline.
|
|
122
|
+
if _PLAIN.fullmatch(text) and text.casefold() not in _WORDS:
|
|
123
|
+
return text
|
|
124
|
+
quoted = json.dumps(text, ensure_ascii=False)
|
|
125
|
+
return _UNPRINTABLE.sub(lambda found: f"\\u{ord(found.group()):04x}", quoted)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _block(text: str) -> list[str] | None:
|
|
129
|
+
"""A multi-line string as a literal block (``|``), or None when it cannot be one.
|
|
130
|
+
|
|
131
|
+
The lines come back unindented; the caller indents them. A block must not start with
|
|
132
|
+
a space (the reader would take that as the indent), and holds no control characters.
|
|
133
|
+
"""
|
|
134
|
+
if "\n" not in text or _CONTROL.search(text) or text.startswith((" ", "\n")):
|
|
135
|
+
return None
|
|
136
|
+
body = text.rstrip("\n")
|
|
137
|
+
trailing = len(text) - len(body)
|
|
138
|
+
chomp = {0: "|-", 1: "|"}.get(trailing, "|+")
|
|
139
|
+
lines = body.split("\n") + [""] * (trailing - 1 if trailing > 1 else 0)
|
|
140
|
+
if any(line.endswith((" ", "\t")) for line in lines):
|
|
141
|
+
return None # trailing spaces would be invisible, so quote it instead
|
|
142
|
+
return [chomp, *lines]
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Microsoft: Azure, Entra ID, Defender, Intune, Key Vault and Log Analytics.
|
|
2
|
+
|
|
3
|
+
This package is the Microsoft vendor layer. Its top-level modules are shared by every
|
|
4
|
+
Microsoft feature: clouds and their endpoints, the APIs as tokens see them, token checks,
|
|
5
|
+
credentials, the Azure CLI runner and the ``[microsoft]`` profiles. The feature modules
|
|
6
|
+
(``azcli``, ``entra``, ``xdr``, ``intune``, ``azure``, ``keyvault``, ``loganalytics``)
|
|
7
|
+
depend on those and on ``core`` only; ``devices`` combines ``entra``, ``xdr`` and
|
|
8
|
+
``intune``.
|
|
9
|
+
|
|
10
|
+
Public API::
|
|
11
|
+
|
|
12
|
+
from libre_devops_helpers.core import CachingTokenProvider
|
|
13
|
+
from libre_devops_helpers.microsoft import credential_for, load_config
|
|
14
|
+
from libre_devops_helpers.microsoft.entra import EntraClient
|
|
15
|
+
|
|
16
|
+
profile = load_config().get("prod-tenant")
|
|
17
|
+
tokens = CachingTokenProvider(credential_for(profile))
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from libre_devops_helpers.microsoft.auth import (
|
|
21
|
+
AzureCliCredential,
|
|
22
|
+
ClientSecretCredential,
|
|
23
|
+
ManagedIdentityCredential,
|
|
24
|
+
WorkloadIdentityCredential,
|
|
25
|
+
credential_for,
|
|
26
|
+
)
|
|
27
|
+
from libre_devops_helpers.microsoft.clouds import CHINA, CLOUDS, PUBLIC, USGOV, Cloud, get_cloud
|
|
28
|
+
from libre_devops_helpers.microsoft.config import (
|
|
29
|
+
AUTH_METHODS,
|
|
30
|
+
CONFIG_TEMPLATE,
|
|
31
|
+
PLACEHOLDER_ID,
|
|
32
|
+
MicrosoftConfig,
|
|
33
|
+
Profile,
|
|
34
|
+
load_config,
|
|
35
|
+
parse_config,
|
|
36
|
+
)
|
|
37
|
+
from libre_devops_helpers.microsoft.process import AzCliError, AzureCliRunner
|
|
38
|
+
from libre_devops_helpers.microsoft.resources import (
|
|
39
|
+
ARM,
|
|
40
|
+
GRAPH,
|
|
41
|
+
KEY_VAULT,
|
|
42
|
+
LOG_ANALYTICS,
|
|
43
|
+
MDE,
|
|
44
|
+
RESOURCES,
|
|
45
|
+
Requirement,
|
|
46
|
+
Resource,
|
|
47
|
+
resolve_resource,
|
|
48
|
+
resources_for,
|
|
49
|
+
)
|
|
50
|
+
from libre_devops_helpers.microsoft.tokens import (
|
|
51
|
+
Check,
|
|
52
|
+
DecodedToken,
|
|
53
|
+
decode_token,
|
|
54
|
+
passed,
|
|
55
|
+
validate_token,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
__all__ = [
|
|
59
|
+
"ARM",
|
|
60
|
+
"AUTH_METHODS",
|
|
61
|
+
"CHINA",
|
|
62
|
+
"CLOUDS",
|
|
63
|
+
"CONFIG_TEMPLATE",
|
|
64
|
+
"GRAPH",
|
|
65
|
+
"KEY_VAULT",
|
|
66
|
+
"LOG_ANALYTICS",
|
|
67
|
+
"MDE",
|
|
68
|
+
"PLACEHOLDER_ID",
|
|
69
|
+
"PUBLIC",
|
|
70
|
+
"RESOURCES",
|
|
71
|
+
"USGOV",
|
|
72
|
+
"AzCliError",
|
|
73
|
+
"AzureCliCredential",
|
|
74
|
+
"AzureCliRunner",
|
|
75
|
+
"Check",
|
|
76
|
+
"ClientSecretCredential",
|
|
77
|
+
"Cloud",
|
|
78
|
+
"DecodedToken",
|
|
79
|
+
"ManagedIdentityCredential",
|
|
80
|
+
"MicrosoftConfig",
|
|
81
|
+
"Profile",
|
|
82
|
+
"Requirement",
|
|
83
|
+
"Resource",
|
|
84
|
+
"WorkloadIdentityCredential",
|
|
85
|
+
"credential_for",
|
|
86
|
+
"decode_token",
|
|
87
|
+
"get_cloud",
|
|
88
|
+
"load_config",
|
|
89
|
+
"parse_config",
|
|
90
|
+
"passed",
|
|
91
|
+
"resolve_resource",
|
|
92
|
+
"resources_for",
|
|
93
|
+
"validate_token",
|
|
94
|
+
]
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Microsoft credentials: the Azure CLI, app registrations and managed identities.
|
|
2
|
+
|
|
3
|
+
Every credential satisfies ``core.auth.TokenProvider``; the service modules take any
|
|
4
|
+
provider, and ``credential_for`` builds the one a profile's ``auth`` setting names.
|
|
5
|
+
|
|
6
|
+
Public API::
|
|
7
|
+
|
|
8
|
+
from libre_devops_helpers.core.auth import CachingTokenProvider
|
|
9
|
+
from libre_devops_helpers.microsoft.auth import credential_for
|
|
10
|
+
|
|
11
|
+
tokens = CachingTokenProvider(credential_for(profile))
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from libre_devops_helpers.microsoft.auth.azure_cli import AzureCliCredential, Reauthenticate
|
|
15
|
+
from libre_devops_helpers.microsoft.auth.delegated import (
|
|
16
|
+
DeviceCodeCredential,
|
|
17
|
+
InteractiveCredential,
|
|
18
|
+
LoopbackReceiver,
|
|
19
|
+
)
|
|
20
|
+
from libre_devops_helpers.microsoft.auth.entra import (
|
|
21
|
+
ClientSecretCredential,
|
|
22
|
+
WorkloadIdentityCredential,
|
|
23
|
+
federated_token_file,
|
|
24
|
+
github_actions_assertion,
|
|
25
|
+
)
|
|
26
|
+
from libre_devops_helpers.microsoft.auth.factory import credential_for
|
|
27
|
+
from libre_devops_helpers.microsoft.auth.lapse import lapse_reason
|
|
28
|
+
from libre_devops_helpers.microsoft.auth.managed_identity import ManagedIdentityCredential
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"AzureCliCredential",
|
|
32
|
+
"ClientSecretCredential",
|
|
33
|
+
"DeviceCodeCredential",
|
|
34
|
+
"InteractiveCredential",
|
|
35
|
+
"LoopbackReceiver",
|
|
36
|
+
"ManagedIdentityCredential",
|
|
37
|
+
"Reauthenticate",
|
|
38
|
+
"WorkloadIdentityCredential",
|
|
39
|
+
"credential_for",
|
|
40
|
+
"federated_token_file",
|
|
41
|
+
"github_actions_assertion",
|
|
42
|
+
"lapse_reason",
|
|
43
|
+
]
|