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,348 @@
|
|
|
1
|
+
"""Per-invocation state for the CLI.
|
|
2
|
+
|
|
3
|
+
``Runtime`` holds what every vendor shares: the config file, TLS settings, the HTTP
|
|
4
|
+
session, the environment, the clock, and the clients to close at exit. Each vendor has
|
|
5
|
+
its own runtime hanging off it (``runtime.microsoft``), holding that vendor's profiles,
|
|
6
|
+
credentials and clients. Everything is created lazily, so ``--help`` never touches a
|
|
7
|
+
CLI tool or the network.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
import threading
|
|
15
|
+
import time
|
|
16
|
+
import webbrowser
|
|
17
|
+
from collections.abc import Callable, Mapping
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Protocol, TypeVar
|
|
21
|
+
|
|
22
|
+
import requests
|
|
23
|
+
import typer
|
|
24
|
+
|
|
25
|
+
from libre_devops_helpers.cli import render
|
|
26
|
+
from libre_devops_helpers.cli.servicenow_runtime import ServiceNowRuntime
|
|
27
|
+
from libre_devops_helpers.core import brand
|
|
28
|
+
from libre_devops_helpers.core.auth import CachingTokenProvider
|
|
29
|
+
from libre_devops_helpers.core.browser import can_launch_browser
|
|
30
|
+
from libre_devops_helpers.core.config import ConfigFile, load_config_file
|
|
31
|
+
from libre_devops_helpers.core.errors import ConfigError, ConfigNotFoundError
|
|
32
|
+
from libre_devops_helpers.core.token_store import TokenStore
|
|
33
|
+
from libre_devops_helpers.microsoft.auth import credential_for
|
|
34
|
+
from libre_devops_helpers.microsoft.automation import AutomationClient
|
|
35
|
+
from libre_devops_helpers.microsoft.azcli import AzCli
|
|
36
|
+
from libre_devops_helpers.microsoft.azure import AzureClient
|
|
37
|
+
from libre_devops_helpers.microsoft.config import SECTION as MICROSOFT
|
|
38
|
+
from libre_devops_helpers.microsoft.config import MicrosoftConfig, Profile
|
|
39
|
+
from libre_devops_helpers.microsoft.config import from_file as microsoft_from_file
|
|
40
|
+
from libre_devops_helpers.microsoft.entra import EntraClient
|
|
41
|
+
from libre_devops_helpers.microsoft.graph import GraphClient
|
|
42
|
+
from libre_devops_helpers.microsoft.incidents import IncidentsClient
|
|
43
|
+
from libre_devops_helpers.microsoft.intune import IntuneClient
|
|
44
|
+
from libre_devops_helpers.microsoft.keyvault import KeyVaultClient
|
|
45
|
+
from libre_devops_helpers.microsoft.loganalytics import LogAnalyticsClient
|
|
46
|
+
from libre_devops_helpers.microsoft.logicapps import LogicAppsClient
|
|
47
|
+
from libre_devops_helpers.microsoft.pim import AzurePimClient, GraphPimClient
|
|
48
|
+
from libre_devops_helpers.microsoft.process import AzCliError, AzureCliRunner
|
|
49
|
+
from libre_devops_helpers.microsoft.xdr import XdrClient
|
|
50
|
+
from libre_devops_helpers.servicenow.config import SECTION as SERVICENOW
|
|
51
|
+
|
|
52
|
+
# Every vendor section the config file may hold. Anything else at the top level is a typo.
|
|
53
|
+
SECTIONS = (MICROSOFT, SERVICENOW)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _prompt(message: str) -> None:
|
|
57
|
+
render.notify(message)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _on_a_terminal() -> bool:
|
|
61
|
+
return sys.stdin.isatty() and sys.stderr.isatty()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _confirm(question: str) -> bool:
|
|
65
|
+
return typer.confirm(typer.style(question, fg="cyan"), default=True, err=True)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _ask(question: str, secret: bool) -> str:
|
|
69
|
+
return str(typer.prompt(typer.style(question, fg="cyan"), hide_input=secret, err=True))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# LDO_REAUTH: unset (or "prompt") asks on a terminal, "device-code" asks and then signs
|
|
73
|
+
# in with a device code, and "off" never asks, so a lapsed sign-in is an error at once.
|
|
74
|
+
_REAUTH_OFF = {"off", "never", "no", "false", "0"}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class _Closeable(Protocol):
|
|
78
|
+
def close(self) -> None:
|
|
79
|
+
"""Release the connection pool."""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
C = TypeVar("C", bound=_Closeable)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass
|
|
86
|
+
class Runtime:
|
|
87
|
+
"""What every command shares.
|
|
88
|
+
|
|
89
|
+
Tests pass an ``az_runner`` wrapping a fake subprocess, a ``session`` routing HTTP
|
|
90
|
+
through a fake adapter, an ``environ`` for credentials that read the environment, and
|
|
91
|
+
a ``clock`` and ``sleep`` so a watch runs without waiting.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
config_path: Path | None = None
|
|
95
|
+
az_runner: AzureCliRunner = field(default_factory=AzureCliRunner)
|
|
96
|
+
session: requests.Session | None = None
|
|
97
|
+
environ: Mapping[str, str] = field(default_factory=lambda: os.environ)
|
|
98
|
+
clock: Callable[[], float] = time.monotonic
|
|
99
|
+
sleep: Callable[[float], None] = time.sleep
|
|
100
|
+
# Where a credential shows a sign-in prompt (a URL, or a device code): stderr.
|
|
101
|
+
notify: Callable[[str], None] = _prompt
|
|
102
|
+
# Whether someone is there to answer a question, and how to ask it.
|
|
103
|
+
interactive: Callable[[], bool] = _on_a_terminal
|
|
104
|
+
confirm: Callable[[str], bool] = _confirm
|
|
105
|
+
# Asks a question and returns the answer; the flag hides what is typed (a secret).
|
|
106
|
+
ask: Callable[[str, bool], str] = _ask
|
|
107
|
+
# Whether a browser can be opened here, and how to open one on a sign-in link.
|
|
108
|
+
has_browser: Callable[[], bool] = can_launch_browser
|
|
109
|
+
open_browser: Callable[[str], object] = webbrowser.open
|
|
110
|
+
# Tests replace the store a profile's token_cache would open.
|
|
111
|
+
token_store: TokenStore | None = None
|
|
112
|
+
_file: ConfigFile | None = field(default=None, init=False)
|
|
113
|
+
_microsoft: MicrosoftRuntime | None = field(default=None, init=False)
|
|
114
|
+
_servicenow: ServiceNowRuntime | None = field(default=None, init=False)
|
|
115
|
+
_closers: list[Callable[[], None]] = field(default_factory=list, init=False)
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def microsoft(self) -> MicrosoftRuntime:
|
|
119
|
+
if self._microsoft is None:
|
|
120
|
+
self._microsoft = MicrosoftRuntime(self)
|
|
121
|
+
return self._microsoft
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def servicenow(self) -> ServiceNowRuntime:
|
|
125
|
+
if self._servicenow is None:
|
|
126
|
+
self._servicenow = ServiceNowRuntime(self)
|
|
127
|
+
return self._servicenow
|
|
128
|
+
|
|
129
|
+
def config_file(self) -> ConfigFile:
|
|
130
|
+
"""The config file, loaded once. Raises ConfigNotFoundError when it is absent."""
|
|
131
|
+
if self._file is None:
|
|
132
|
+
self._file = load_config_file(self.config_path, sections=SECTIONS)
|
|
133
|
+
return self._file
|
|
134
|
+
|
|
135
|
+
def optional_config_file(self) -> ConfigFile | None:
|
|
136
|
+
"""The config file, or None when it does not exist (other errors still raise)."""
|
|
137
|
+
try:
|
|
138
|
+
return self.config_file()
|
|
139
|
+
except ConfigNotFoundError:
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
def verify(self) -> bool | str:
|
|
143
|
+
"""TLS verification for requests: True, or the config's CA bundle path."""
|
|
144
|
+
file = self.optional_config_file()
|
|
145
|
+
if file is None or file.ca_bundle is None:
|
|
146
|
+
return True
|
|
147
|
+
if not file.ca_bundle.is_file():
|
|
148
|
+
raise ConfigError(f"ca_bundle not found: {file.ca_bundle}")
|
|
149
|
+
return str(file.ca_bundle)
|
|
150
|
+
|
|
151
|
+
def track(self, client: C) -> C:
|
|
152
|
+
"""Close ``client`` when the command finishes."""
|
|
153
|
+
self._closers.append(client.close)
|
|
154
|
+
return client
|
|
155
|
+
|
|
156
|
+
def close(self) -> None:
|
|
157
|
+
while self._closers:
|
|
158
|
+
self._closers.pop()()
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class MicrosoftRuntime:
|
|
162
|
+
"""Microsoft profiles, credentials and clients for one invocation."""
|
|
163
|
+
|
|
164
|
+
def __init__(self, runtime: Runtime) -> None:
|
|
165
|
+
self.runtime = runtime
|
|
166
|
+
self._config: MicrosoftConfig | None = None
|
|
167
|
+
self._tokens: dict[str, CachingTokenProvider] = {}
|
|
168
|
+
|
|
169
|
+
@property
|
|
170
|
+
def az(self) -> AzCli:
|
|
171
|
+
"""The Azure CLI's accounts, for profile switching and the active account."""
|
|
172
|
+
return AzCli(self.runtime.az_runner)
|
|
173
|
+
|
|
174
|
+
def config(self) -> MicrosoftConfig:
|
|
175
|
+
"""The ``[microsoft]`` section, parsed once."""
|
|
176
|
+
if self._config is None:
|
|
177
|
+
self._config = microsoft_from_file(self.runtime.config_file())
|
|
178
|
+
return self._config
|
|
179
|
+
|
|
180
|
+
def optional_config(self) -> MicrosoftConfig | None:
|
|
181
|
+
"""The ``[microsoft]`` section, or None when there is no config file or section."""
|
|
182
|
+
file = self.runtime.optional_config_file()
|
|
183
|
+
if file is None or file.section(MICROSOFT) is None:
|
|
184
|
+
return None
|
|
185
|
+
return self.config()
|
|
186
|
+
|
|
187
|
+
def profile(self, name: str | None) -> Profile:
|
|
188
|
+
"""The profile to act on.
|
|
189
|
+
|
|
190
|
+
``name`` (from --profile or LDO_PROFILE) wins, then the section's
|
|
191
|
+
default_profile, then the Azure CLI's active account as an unnamed profile.
|
|
192
|
+
"""
|
|
193
|
+
if name:
|
|
194
|
+
profile = self.config().get(name)
|
|
195
|
+
else:
|
|
196
|
+
config = self.optional_config()
|
|
197
|
+
if config is not None and config.default_profile:
|
|
198
|
+
profile = config.get(config.default_profile)
|
|
199
|
+
else:
|
|
200
|
+
return self._active_account_profile()
|
|
201
|
+
profile.require_real_ids()
|
|
202
|
+
return profile
|
|
203
|
+
|
|
204
|
+
def tokens(self, profile: Profile) -> CachingTokenProvider:
|
|
205
|
+
"""The profile's credential, wrapped in a cache shared by every client."""
|
|
206
|
+
if profile.name not in self._tokens:
|
|
207
|
+
credential = credential_for(
|
|
208
|
+
profile,
|
|
209
|
+
runner=self.runtime.az_runner,
|
|
210
|
+
session=self.runtime.session,
|
|
211
|
+
verify=self.runtime.verify(),
|
|
212
|
+
environ=self.runtime.environ,
|
|
213
|
+
notify=self.runtime.notify,
|
|
214
|
+
reauthenticate=self._reauthenticate,
|
|
215
|
+
token_store=self.runtime.token_store,
|
|
216
|
+
)
|
|
217
|
+
self._tokens[profile.name] = CachingTokenProvider(credential)
|
|
218
|
+
return self._tokens[profile.name]
|
|
219
|
+
|
|
220
|
+
def sign_out(self, profile: Profile) -> bool:
|
|
221
|
+
"""Forget the sign-in ``profile`` keeps between commands. True when it had one."""
|
|
222
|
+
credential = credential_for(
|
|
223
|
+
profile,
|
|
224
|
+
runner=self.runtime.az_runner,
|
|
225
|
+
session=self.runtime.session,
|
|
226
|
+
verify=self.runtime.verify(),
|
|
227
|
+
environ=self.runtime.environ,
|
|
228
|
+
notify=self.runtime.notify,
|
|
229
|
+
token_store=self.runtime.token_store,
|
|
230
|
+
)
|
|
231
|
+
forget = getattr(credential, "sign_out", None)
|
|
232
|
+
return bool(forget(profile.tenant_id)) if callable(forget) else False
|
|
233
|
+
|
|
234
|
+
def _reauthenticate(self, tenant_id: str, reason: str) -> bool:
|
|
235
|
+
"""Offer to sign the Azure CLI in to ``tenant_id`` again, then carry on.
|
|
236
|
+
|
|
237
|
+
Only on a terminal, and only from the main thread (a worker must not block on a
|
|
238
|
+
question). The CLI's active account is put back afterwards, since ``az login``
|
|
239
|
+
changes it and other shells rely on it.
|
|
240
|
+
"""
|
|
241
|
+
mode = self.runtime.environ.get(brand.env_var("REAUTH"), "").strip().lower()
|
|
242
|
+
if mode in _REAUTH_OFF or not self.runtime.interactive():
|
|
243
|
+
return False
|
|
244
|
+
if threading.current_thread() is not threading.main_thread():
|
|
245
|
+
return False
|
|
246
|
+
if not self.runtime.confirm(
|
|
247
|
+
f"The Azure CLI's sign-in to tenant {tenant_id} has lapsed: {reason}. "
|
|
248
|
+
"Sign in again now?"
|
|
249
|
+
):
|
|
250
|
+
return False
|
|
251
|
+
try:
|
|
252
|
+
previous = self.az.current_account()
|
|
253
|
+
except AzCliError:
|
|
254
|
+
previous = None
|
|
255
|
+
self.az.login(tenant_id, device_code=mode == "device-code", allow_no_subscriptions=True)
|
|
256
|
+
if previous is not None:
|
|
257
|
+
try:
|
|
258
|
+
self.az.set_account(previous.id)
|
|
259
|
+
except AzCliError as exc:
|
|
260
|
+
render.warn(f"signed in, but could not restore the active account: {exc}")
|
|
261
|
+
return True
|
|
262
|
+
|
|
263
|
+
def _options(self) -> dict[str, object]:
|
|
264
|
+
return {"verify": self.runtime.verify(), "session": self.runtime.session}
|
|
265
|
+
|
|
266
|
+
def entra(self, profile: Profile) -> EntraClient:
|
|
267
|
+
return self.runtime.track(
|
|
268
|
+
EntraClient.for_profile(profile, self.tokens(profile), **self._options())
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
def graph(self, profile: Profile) -> GraphClient:
|
|
272
|
+
return self.runtime.track(
|
|
273
|
+
GraphClient.for_profile(profile, self.tokens(profile), **self._options())
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
def automation(self, profile: Profile) -> AutomationClient:
|
|
277
|
+
return self.runtime.track(
|
|
278
|
+
AutomationClient.for_profile(profile, self.tokens(profile), **self._options())
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
def logicapps(self, profile: Profile) -> LogicAppsClient:
|
|
282
|
+
return self.runtime.track(
|
|
283
|
+
LogicAppsClient.for_profile(profile, self.tokens(profile), **self._options())
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
def incidents(self, profile: Profile) -> IncidentsClient:
|
|
287
|
+
return self.runtime.track(
|
|
288
|
+
IncidentsClient.for_profile(profile, self.tokens(profile), **self._options())
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
def xdr(self, profile: Profile) -> XdrClient:
|
|
292
|
+
return self.runtime.track(
|
|
293
|
+
XdrClient.for_profile(profile, self.tokens(profile), **self._options())
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
def intune(self, profile: Profile) -> IntuneClient:
|
|
297
|
+
return self.runtime.track(
|
|
298
|
+
IntuneClient.for_profile(profile, self.tokens(profile), **self._options())
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
def azure(self, profile: Profile) -> AzureClient:
|
|
302
|
+
return self.runtime.track(
|
|
303
|
+
AzureClient.for_profile(profile, self.tokens(profile), **self._options())
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
def keyvault(self, profile: Profile, vault: str) -> KeyVaultClient:
|
|
307
|
+
return self.runtime.track(
|
|
308
|
+
KeyVaultClient.for_profile(profile, self.tokens(profile), vault, **self._options())
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
def logs(self, profile: Profile) -> LogAnalyticsClient:
|
|
312
|
+
return self.runtime.track(
|
|
313
|
+
LogAnalyticsClient.for_profile(profile, self.tokens(profile), **self._options())
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
def azure_pim(self, profile: Profile) -> AzurePimClient:
|
|
317
|
+
return self.runtime.track(
|
|
318
|
+
AzurePimClient.for_profile(profile, self.tokens(profile), **self._options())
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
def graph_pim(self, profile: Profile) -> GraphPimClient:
|
|
322
|
+
return self.runtime.track(
|
|
323
|
+
GraphPimClient.for_profile(profile, self.tokens(profile), **self._options())
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
def subscription_ids(self, profile: Profile, chosen: list[str] | None = None) -> list[str]:
|
|
327
|
+
"""The subscriptions an Azure command covers: ``chosen``, the profile's pinned one,
|
|
328
|
+
or every subscription the credential can see in the profile's tenant."""
|
|
329
|
+
if chosen:
|
|
330
|
+
return chosen
|
|
331
|
+
if profile.subscription_id:
|
|
332
|
+
return [profile.subscription_id]
|
|
333
|
+
return [item.id for item in self.azure(profile).subscriptions(profile.tenant_id)]
|
|
334
|
+
|
|
335
|
+
def _active_account_profile(self) -> Profile:
|
|
336
|
+
account = self.az.current_account()
|
|
337
|
+
if account is None:
|
|
338
|
+
raise AzCliError(
|
|
339
|
+
"no profile selected and the Azure CLI is not signed in",
|
|
340
|
+
hint="pass --profile, set default_profile, or run "
|
|
341
|
+
f"{brand.command('az use <profile>')}",
|
|
342
|
+
)
|
|
343
|
+
return Profile(
|
|
344
|
+
name="az-active",
|
|
345
|
+
tenant_id=account.tenant_id,
|
|
346
|
+
subscription_id=None if account.tenant_level else account.id,
|
|
347
|
+
description="the Azure CLI's active account",
|
|
348
|
+
)
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""The CLI's ServiceNow state: profiles, sign-ins and clients, for one invocation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Mapping
|
|
6
|
+
from typing import Protocol, TypeVar
|
|
7
|
+
|
|
8
|
+
import requests
|
|
9
|
+
|
|
10
|
+
from libre_devops_helpers.core import brand
|
|
11
|
+
from libre_devops_helpers.core.auth import CachingTokenProvider, token_source
|
|
12
|
+
from libre_devops_helpers.core.config import ConfigFile
|
|
13
|
+
from libre_devops_helpers.core.errors import ConfigError
|
|
14
|
+
from libre_devops_helpers.core.token_store import TokenStore
|
|
15
|
+
from libre_devops_helpers.servicenow import (
|
|
16
|
+
BasicCredential,
|
|
17
|
+
Credential,
|
|
18
|
+
OAuthCredential,
|
|
19
|
+
Profile,
|
|
20
|
+
ServiceNowConfig,
|
|
21
|
+
TableClient,
|
|
22
|
+
credential_for,
|
|
23
|
+
from_file,
|
|
24
|
+
profile_from_env,
|
|
25
|
+
)
|
|
26
|
+
from libre_devops_helpers.servicenow.config import (
|
|
27
|
+
CLIENT_ID_ENV,
|
|
28
|
+
CLIENT_SECRET_ENV,
|
|
29
|
+
ENV_PROFILE,
|
|
30
|
+
URL_ENV,
|
|
31
|
+
)
|
|
32
|
+
from libre_devops_helpers.servicenow.instance import InstanceClient
|
|
33
|
+
|
|
34
|
+
_C = TypeVar("_C")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Host(Protocol):
|
|
38
|
+
"""What this needs from the CLI's Runtime.
|
|
39
|
+
|
|
40
|
+
A Protocol, so this module does not import runtime.py, which imports this one.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
environ: Mapping[str, str]
|
|
44
|
+
session: requests.Session | None
|
|
45
|
+
token_store: TokenStore | None
|
|
46
|
+
notify: Callable[[str], None]
|
|
47
|
+
interactive: Callable[[], bool]
|
|
48
|
+
ask: Callable[[str, bool], str]
|
|
49
|
+
has_browser: Callable[[], bool]
|
|
50
|
+
open_browser: Callable[[str], object]
|
|
51
|
+
|
|
52
|
+
def optional_config_file(self) -> ConfigFile | None:
|
|
53
|
+
"""The config file, or None when there is none."""
|
|
54
|
+
|
|
55
|
+
def verify(self) -> bool | str:
|
|
56
|
+
"""TLS verification for requests: True, or a CA bundle's path."""
|
|
57
|
+
|
|
58
|
+
def track(self, client: _C) -> _C:
|
|
59
|
+
"""Close ``client`` when the command ends, and return it."""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class ServiceNowRuntime:
|
|
63
|
+
"""Profiles, credentials and clients for ServiceNow, created as they are needed."""
|
|
64
|
+
|
|
65
|
+
def __init__(self, runtime: Host) -> None:
|
|
66
|
+
self.runtime = runtime
|
|
67
|
+
self._credentials: dict[str, Credential] = {}
|
|
68
|
+
self._tokens: dict[str, CachingTokenProvider] = {}
|
|
69
|
+
|
|
70
|
+
def config(self) -> ServiceNowConfig | None:
|
|
71
|
+
"""The ``[servicenow]`` section, or None without a config file or section."""
|
|
72
|
+
file = self.runtime.optional_config_file()
|
|
73
|
+
return from_file(file) if file is not None else None
|
|
74
|
+
|
|
75
|
+
def profiles(self) -> list[Profile]:
|
|
76
|
+
"""Every profile: the configured ones, and ``env`` when SNOW_INSTANCE_URL is set."""
|
|
77
|
+
config = self.config()
|
|
78
|
+
found = list(config.profiles.values()) if config else []
|
|
79
|
+
env = profile_from_env(self.runtime.environ)
|
|
80
|
+
if env is not None and all(profile.name != ENV_PROFILE for profile in found):
|
|
81
|
+
found.append(env)
|
|
82
|
+
return found
|
|
83
|
+
|
|
84
|
+
def profile(self, name: str | None) -> Profile:
|
|
85
|
+
"""The named profile, else default_profile, else the ``env`` one."""
|
|
86
|
+
config = self.config()
|
|
87
|
+
env = profile_from_env(self.runtime.environ)
|
|
88
|
+
if name:
|
|
89
|
+
if config is not None and name in config.profiles:
|
|
90
|
+
selected = config.get(name)
|
|
91
|
+
elif name == ENV_PROFILE and env is not None:
|
|
92
|
+
selected = env
|
|
93
|
+
elif config is not None:
|
|
94
|
+
selected = config.get(name) # raises, listing the known profiles
|
|
95
|
+
else:
|
|
96
|
+
raise ConfigError(f"unknown ServiceNow profile {name!r}", hint=self._setup_hint())
|
|
97
|
+
elif config is not None and config.default_profile:
|
|
98
|
+
selected = config.get(config.default_profile)
|
|
99
|
+
elif env is not None:
|
|
100
|
+
selected = env
|
|
101
|
+
elif config is not None and len(config.profiles) == 1:
|
|
102
|
+
selected = next(iter(config.profiles.values()))
|
|
103
|
+
else:
|
|
104
|
+
raise ConfigError("no ServiceNow profile selected", hint=self._setup_hint())
|
|
105
|
+
selected.require_real_instance()
|
|
106
|
+
return selected
|
|
107
|
+
|
|
108
|
+
def credential(self, profile: Profile) -> Credential:
|
|
109
|
+
if profile.name not in self._credentials:
|
|
110
|
+
runtime = self.runtime
|
|
111
|
+
self._credentials[profile.name] = credential_for(
|
|
112
|
+
profile,
|
|
113
|
+
environ=runtime.environ,
|
|
114
|
+
store=runtime.token_store,
|
|
115
|
+
ask=runtime.ask if runtime.interactive() else None,
|
|
116
|
+
session=runtime.session,
|
|
117
|
+
verify=runtime.verify(),
|
|
118
|
+
notify=runtime.notify,
|
|
119
|
+
sign_in_hint=f"run {brand.command(f'snow sign-in -p {profile.name}')}",
|
|
120
|
+
has_browser=runtime.has_browser,
|
|
121
|
+
open_browser=runtime.open_browser,
|
|
122
|
+
)
|
|
123
|
+
return self._credentials[profile.name]
|
|
124
|
+
|
|
125
|
+
def tables(self, profile: Profile) -> TableClient:
|
|
126
|
+
credential = self.credential(profile)
|
|
127
|
+
options = {"session": self.runtime.session, "verify": self.runtime.verify()}
|
|
128
|
+
if isinstance(credential, BasicCredential):
|
|
129
|
+
client = TableClient.create(
|
|
130
|
+
profile.instance, credential.authorization, scheme="Basic", **options
|
|
131
|
+
)
|
|
132
|
+
else:
|
|
133
|
+
client = TableClient.create(
|
|
134
|
+
profile.instance,
|
|
135
|
+
token_source(self.tokens(profile), profile.instance, ""),
|
|
136
|
+
**options,
|
|
137
|
+
)
|
|
138
|
+
return self.runtime.track(client)
|
|
139
|
+
|
|
140
|
+
def tokens(self, profile: Profile) -> CachingTokenProvider:
|
|
141
|
+
"""The OAuth profile's tokens, cached for the command."""
|
|
142
|
+
credential = self.credential(profile)
|
|
143
|
+
if not isinstance(credential, OAuthCredential):
|
|
144
|
+
raise ConfigError(
|
|
145
|
+
f"ServiceNow profile {profile.name!r} uses basic sign-in, which has no token",
|
|
146
|
+
hint=self.oauth_hint(profile),
|
|
147
|
+
)
|
|
148
|
+
if profile.name not in self._tokens:
|
|
149
|
+
self._tokens[profile.name] = CachingTokenProvider(credential)
|
|
150
|
+
return self._tokens[profile.name]
|
|
151
|
+
|
|
152
|
+
def instance(self, profile: Profile) -> InstanceClient:
|
|
153
|
+
return InstanceClient(self.tables(profile))
|
|
154
|
+
|
|
155
|
+
@staticmethod
|
|
156
|
+
def oauth_hint(profile: Profile) -> str:
|
|
157
|
+
"""How to move ``profile`` from basic sign-in to OAuth."""
|
|
158
|
+
if profile.name == ENV_PROFILE:
|
|
159
|
+
return (
|
|
160
|
+
f"set {CLIENT_ID_ENV} to the application registry entry's client id "
|
|
161
|
+
f"(and {CLIENT_SECRET_ENV}, or let sign-in ask for it)"
|
|
162
|
+
)
|
|
163
|
+
return f'set auth = "oauth" and a client_id on the profile (see {brand.docs("servicenow")})'
|
|
164
|
+
|
|
165
|
+
@staticmethod
|
|
166
|
+
def _setup_hint() -> str:
|
|
167
|
+
return (
|
|
168
|
+
f"set {URL_ENV}, or add a [servicenow] profile ({brand.command('config init')} "
|
|
169
|
+
"writes a template)"
|
|
170
|
+
)
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Vendor-neutral building blocks. Depends on nothing else in the package.
|
|
2
|
+
|
|
3
|
+
Everything more than one vendor or feature needs lives here: errors, the config file,
|
|
4
|
+
the token cache, the HTTP client, the command runner, polling, input parsing, tabular
|
|
5
|
+
results and logging. Vendor layers (``microsoft``, ...) build on core; nothing here knows
|
|
6
|
+
any vendor.
|
|
7
|
+
|
|
8
|
+
Public API::
|
|
9
|
+
|
|
10
|
+
from libre_devops_helpers.core import ApiClient, PollLimits, poll, read_names
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from libre_devops_helpers.core.auth import (
|
|
14
|
+
AccessToken,
|
|
15
|
+
CachingTokenProvider,
|
|
16
|
+
TokenProvider,
|
|
17
|
+
token_source,
|
|
18
|
+
)
|
|
19
|
+
from libre_devops_helpers.core.config import (
|
|
20
|
+
CONFIG_HEADER,
|
|
21
|
+
ConfigFile,
|
|
22
|
+
default_config_path,
|
|
23
|
+
load_config_file,
|
|
24
|
+
parse_config_file,
|
|
25
|
+
)
|
|
26
|
+
from libre_devops_helpers.core.errors import (
|
|
27
|
+
AmbiguousError,
|
|
28
|
+
ApiError,
|
|
29
|
+
AuthError,
|
|
30
|
+
CommandError,
|
|
31
|
+
ConfigError,
|
|
32
|
+
ConfigNotFoundError,
|
|
33
|
+
InputError,
|
|
34
|
+
LdoError,
|
|
35
|
+
NotFoundError,
|
|
36
|
+
TokenError,
|
|
37
|
+
)
|
|
38
|
+
from libre_devops_helpers.core.http import ApiClient
|
|
39
|
+
from libre_devops_helpers.core.inputs import read_names
|
|
40
|
+
from libre_devops_helpers.core.log import LOG_FORMATS, configure_logging
|
|
41
|
+
from libre_devops_helpers.core.poll import PollLimits, PollOutcome, poll
|
|
42
|
+
from libre_devops_helpers.core.process import CommandRunner
|
|
43
|
+
from libre_devops_helpers.core.tables import QueryResult
|
|
44
|
+
from libre_devops_helpers.core.util import (
|
|
45
|
+
candidate_names,
|
|
46
|
+
format_duration,
|
|
47
|
+
is_guid,
|
|
48
|
+
odata_datetime,
|
|
49
|
+
odata_string,
|
|
50
|
+
parse_datetime,
|
|
51
|
+
parse_duration,
|
|
52
|
+
short_name,
|
|
53
|
+
split_names,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
__all__ = [
|
|
57
|
+
"CONFIG_HEADER",
|
|
58
|
+
"LOG_FORMATS",
|
|
59
|
+
"AccessToken",
|
|
60
|
+
"AmbiguousError",
|
|
61
|
+
"ApiClient",
|
|
62
|
+
"ApiError",
|
|
63
|
+
"AuthError",
|
|
64
|
+
"CachingTokenProvider",
|
|
65
|
+
"CommandError",
|
|
66
|
+
"CommandRunner",
|
|
67
|
+
"ConfigError",
|
|
68
|
+
"ConfigFile",
|
|
69
|
+
"ConfigNotFoundError",
|
|
70
|
+
"InputError",
|
|
71
|
+
"LdoError",
|
|
72
|
+
"NotFoundError",
|
|
73
|
+
"PollLimits",
|
|
74
|
+
"PollOutcome",
|
|
75
|
+
"QueryResult",
|
|
76
|
+
"TokenError",
|
|
77
|
+
"TokenProvider",
|
|
78
|
+
"candidate_names",
|
|
79
|
+
"configure_logging",
|
|
80
|
+
"default_config_path",
|
|
81
|
+
"format_duration",
|
|
82
|
+
"is_guid",
|
|
83
|
+
"load_config_file",
|
|
84
|
+
"odata_datetime",
|
|
85
|
+
"odata_string",
|
|
86
|
+
"parse_config_file",
|
|
87
|
+
"parse_datetime",
|
|
88
|
+
"parse_duration",
|
|
89
|
+
"poll",
|
|
90
|
+
"read_names",
|
|
91
|
+
"short_name",
|
|
92
|
+
"split_names",
|
|
93
|
+
"token_source",
|
|
94
|
+
]
|