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.
Files changed (120) hide show
  1. libre_devops_helpers/__init__.py +23 -0
  2. libre_devops_helpers/__main__.py +5 -0
  3. libre_devops_helpers/cli/__init__.py +5 -0
  4. libre_devops_helpers/cli/app.py +147 -0
  5. libre_devops_helpers/cli/commands/__init__.py +1 -0
  6. libre_devops_helpers/cli/commands/automation.py +321 -0
  7. libre_devops_helpers/cli/commands/az.py +93 -0
  8. libre_devops_helpers/cli/commands/azure.py +258 -0
  9. libre_devops_helpers/cli/commands/config.py +54 -0
  10. libre_devops_helpers/cli/commands/devices.py +548 -0
  11. libre_devops_helpers/cli/commands/entra.py +554 -0
  12. libre_devops_helpers/cli/commands/graph.py +358 -0
  13. libre_devops_helpers/cli/commands/incidents.py +420 -0
  14. libre_devops_helpers/cli/commands/intune.py +79 -0
  15. libre_devops_helpers/cli/commands/keyvault.py +142 -0
  16. libre_devops_helpers/cli/commands/logicapp.py +489 -0
  17. libre_devops_helpers/cli/commands/logs.py +69 -0
  18. libre_devops_helpers/cli/commands/pim.py +381 -0
  19. libre_devops_helpers/cli/commands/pretty.py +141 -0
  20. libre_devops_helpers/cli/commands/profiles.py +153 -0
  21. libre_devops_helpers/cli/commands/snow.py +268 -0
  22. libre_devops_helpers/cli/commands/token.py +222 -0
  23. libre_devops_helpers/cli/commands/welcome.py +43 -0
  24. libre_devops_helpers/cli/commands/xdr.py +353 -0
  25. libre_devops_helpers/cli/exits.py +12 -0
  26. libre_devops_helpers/cli/options.py +146 -0
  27. libre_devops_helpers/cli/render.py +360 -0
  28. libre_devops_helpers/cli/runtime.py +348 -0
  29. libre_devops_helpers/cli/servicenow_runtime.py +170 -0
  30. libre_devops_helpers/core/__init__.py +94 -0
  31. libre_devops_helpers/core/auth.py +103 -0
  32. libre_devops_helpers/core/brand.py +67 -0
  33. libre_devops_helpers/core/browser.py +21 -0
  34. libre_devops_helpers/core/config.py +159 -0
  35. libre_devops_helpers/core/dpapi.py +60 -0
  36. libre_devops_helpers/core/errors.py +90 -0
  37. libre_devops_helpers/core/http.py +412 -0
  38. libre_devops_helpers/core/inputs.py +193 -0
  39. libre_devops_helpers/core/log.py +246 -0
  40. libre_devops_helpers/core/poll.py +88 -0
  41. libre_devops_helpers/core/process.py +106 -0
  42. libre_devops_helpers/core/sheets.py +330 -0
  43. libre_devops_helpers/core/tables.py +49 -0
  44. libre_devops_helpers/core/timewindow.py +127 -0
  45. libre_devops_helpers/core/token_store.py +266 -0
  46. libre_devops_helpers/core/util.py +120 -0
  47. libre_devops_helpers/core/yaml_text.py +142 -0
  48. libre_devops_helpers/microsoft/__init__.py +94 -0
  49. libre_devops_helpers/microsoft/auth/__init__.py +43 -0
  50. libre_devops_helpers/microsoft/auth/azure_cli.py +91 -0
  51. libre_devops_helpers/microsoft/auth/delegated.py +391 -0
  52. libre_devops_helpers/microsoft/auth/entra.py +241 -0
  53. libre_devops_helpers/microsoft/auth/factory.py +114 -0
  54. libre_devops_helpers/microsoft/auth/lapse.py +42 -0
  55. libre_devops_helpers/microsoft/auth/managed_identity.py +84 -0
  56. libre_devops_helpers/microsoft/automation/__init__.py +24 -0
  57. libre_devops_helpers/microsoft/automation/client.py +241 -0
  58. libre_devops_helpers/microsoft/automation/models.py +131 -0
  59. libre_devops_helpers/microsoft/azcli/__init__.py +27 -0
  60. libre_devops_helpers/microsoft/azcli/client.py +81 -0
  61. libre_devops_helpers/microsoft/azcli/context.py +95 -0
  62. libre_devops_helpers/microsoft/azure/__init__.py +34 -0
  63. libre_devops_helpers/microsoft/azure/client.py +260 -0
  64. libre_devops_helpers/microsoft/azure/models.py +198 -0
  65. libre_devops_helpers/microsoft/clouds.py +83 -0
  66. libre_devops_helpers/microsoft/config.py +244 -0
  67. libre_devops_helpers/microsoft/devices/__init__.py +45 -0
  68. libre_devops_helpers/microsoft/devices/antivirus.py +149 -0
  69. libre_devops_helpers/microsoft/devices/check.py +286 -0
  70. libre_devops_helpers/microsoft/devices/inspect.py +146 -0
  71. libre_devops_helpers/microsoft/devices/models.py +148 -0
  72. libre_devops_helpers/microsoft/entra/__init__.py +41 -0
  73. libre_devops_helpers/microsoft/entra/client.py +422 -0
  74. libre_devops_helpers/microsoft/entra/models.py +334 -0
  75. libre_devops_helpers/microsoft/entra/permissions.py +51 -0
  76. libre_devops_helpers/microsoft/graph/__init__.py +38 -0
  77. libre_devops_helpers/microsoft/graph/client.py +292 -0
  78. libre_devops_helpers/microsoft/incidents/__init__.py +51 -0
  79. libre_devops_helpers/microsoft/incidents/client.py +217 -0
  80. libre_devops_helpers/microsoft/incidents/models.py +165 -0
  81. libre_devops_helpers/microsoft/incidents/permissions.py +15 -0
  82. libre_devops_helpers/microsoft/intune/__init__.py +18 -0
  83. libre_devops_helpers/microsoft/intune/client.py +94 -0
  84. libre_devops_helpers/microsoft/intune/models.py +63 -0
  85. libre_devops_helpers/microsoft/intune/permissions.py +16 -0
  86. libre_devops_helpers/microsoft/keyvault/__init__.py +34 -0
  87. libre_devops_helpers/microsoft/keyvault/client.py +185 -0
  88. libre_devops_helpers/microsoft/loganalytics/__init__.py +17 -0
  89. libre_devops_helpers/microsoft/loganalytics/client.py +117 -0
  90. libre_devops_helpers/microsoft/logicapps/__init__.py +79 -0
  91. libre_devops_helpers/microsoft/logicapps/checks.py +432 -0
  92. libre_devops_helpers/microsoft/logicapps/client.py +162 -0
  93. libre_devops_helpers/microsoft/logicapps/document.py +202 -0
  94. libre_devops_helpers/microsoft/pim/__init__.py +39 -0
  95. libre_devops_helpers/microsoft/pim/azure.py +238 -0
  96. libre_devops_helpers/microsoft/pim/entra.py +294 -0
  97. libre_devops_helpers/microsoft/pim/models.py +93 -0
  98. libre_devops_helpers/microsoft/pim/permissions.py +98 -0
  99. libre_devops_helpers/microsoft/pim/rules.py +70 -0
  100. libre_devops_helpers/microsoft/process.py +70 -0
  101. libre_devops_helpers/microsoft/resources.py +137 -0
  102. libre_devops_helpers/microsoft/tokens.py +269 -0
  103. libre_devops_helpers/microsoft/xdr/__init__.py +33 -0
  104. libre_devops_helpers/microsoft/xdr/client.py +246 -0
  105. libre_devops_helpers/microsoft/xdr/models.py +181 -0
  106. libre_devops_helpers/microsoft/xdr/permissions.py +24 -0
  107. libre_devops_helpers/py.typed +0 -0
  108. libre_devops_helpers/servicenow/__init__.py +50 -0
  109. libre_devops_helpers/servicenow/auth.py +409 -0
  110. libre_devops_helpers/servicenow/config.py +261 -0
  111. libre_devops_helpers/servicenow/instance/__init__.py +32 -0
  112. libre_devops_helpers/servicenow/instance/client.py +91 -0
  113. libre_devops_helpers/servicenow/instance/models.py +131 -0
  114. libre_devops_helpers/servicenow/roles.py +23 -0
  115. libre_devops_helpers/servicenow/tables.py +133 -0
  116. libre_devops_helpers-0.4.1.dist-info/METADATA +153 -0
  117. libre_devops_helpers-0.4.1.dist-info/RECORD +120 -0
  118. libre_devops_helpers-0.4.1.dist-info/WHEEL +4 -0
  119. libre_devops_helpers-0.4.1.dist-info/entry_points.txt +2 -0
  120. 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
+ ]