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,381 @@
1
+ """PIM commands: eligible and active roles, requests, approvals and role settings.
2
+
3
+ Each command covers three areas: Azure resource roles (ARM), Entra roles and PIM for
4
+ Groups (Graph). An area that cannot be read (no P2 licence, a token without the scope) is
5
+ reported as a warning and the others still show; the command fails only when every area
6
+ it was asked for fails.
7
+ """
8
+
9
+ import re
10
+ from collections.abc import Callable
11
+ from typing import Annotated, TypeVar
12
+
13
+ import typer
14
+
15
+ from libre_devops_helpers.cli import render
16
+ from libre_devops_helpers.cli.exits import ERROR
17
+ from libre_devops_helpers.cli.options import OutputOption, ProfileOption, get_runtime
18
+ from libre_devops_helpers.cli.render import Output
19
+ from libre_devops_helpers.cli.runtime import MicrosoftRuntime
20
+ from libre_devops_helpers.core import brand
21
+ from libre_devops_helpers.core.errors import LdoError
22
+ from libre_devops_helpers.core.util import is_guid
23
+ from libre_devops_helpers.microsoft.config import Profile
24
+ from libre_devops_helpers.microsoft.pim import AREAS, Area, PimAssignment, PimRequest
25
+
26
+ T = TypeVar("T")
27
+
28
+ pim_app = typer.Typer(
29
+ rich_markup_mode="markdown",
30
+ help="Privileged Identity Management: eligible and active roles, requests, approvals.",
31
+ no_args_is_help=True,
32
+ )
33
+
34
+ AzureOption = Annotated[bool, typer.Option("--azure", help="Azure resource roles.")]
35
+ EntraOption = Annotated[bool, typer.Option("--entra", help="Entra (directory) roles.")]
36
+ GroupsOption = Annotated[bool, typer.Option("--groups", help="PIM for Groups.")]
37
+ UserOption = Annotated[
38
+ str | None,
39
+ typer.Option(
40
+ "--user",
41
+ "-u",
42
+ help="Someone else's (UPN, name or object id). Default: the signed-in user.",
43
+ show_default=False,
44
+ ),
45
+ ]
46
+ SubscriptionOption = Annotated[
47
+ list[str] | None,
48
+ typer.Option(
49
+ "--subscription",
50
+ "-s",
51
+ help="With --user: subscriptions to search for Azure roles. Default: all in the tenant.",
52
+ show_default=False,
53
+ ),
54
+ ]
55
+
56
+
57
+ def register(app: typer.Typer) -> None:
58
+ app.add_typer(pim_app, name="pim")
59
+
60
+
61
+ def _areas(azure: bool, entra: bool, groups: bool) -> list[Area]:
62
+ chosen = [area for area, wanted in zip(AREAS, (azure, entra, groups), strict=True) if wanted]
63
+ return chosen or list(AREAS)
64
+
65
+
66
+ def _gather(areas: list[Area], calls: dict[Area, Callable[[], list[T]]]) -> list[T]:
67
+ """Run each area's call; warn about the ones that fail, and fail only if all do."""
68
+ found: list[T] = []
69
+ failed = 0
70
+ for area in areas:
71
+ try:
72
+ found.extend(calls[area]())
73
+ except LdoError as exc:
74
+ failed += 1
75
+ render.warn(f"{area}: {exc}")
76
+ hint = _hint(area, str(exc)) or exc.hint
77
+ if hint:
78
+ render.note(f"hint: {hint}")
79
+ if failed and failed == len(areas):
80
+ raise typer.Exit(ERROR)
81
+ return found
82
+
83
+
84
+ def _hint(area: Area, text: str) -> str | None:
85
+ if "AadPremiumLicenseRequired" in text or "TenantNotOnboarded" in text:
86
+ return "PIM needs Microsoft Entra ID P2 or ID Governance in the tenant"
87
+ if area != "azure" and ("PermissionScopeNotGranted" in text or "HTTP 403" in text):
88
+ return (
89
+ "these views need a token with the PIM scopes; the Azure CLI's never has them. Use a "
90
+ 'profile with auth = "interactive" or "device-code" '
91
+ f"(see {brand.docs('authentication')})"
92
+ )
93
+ return None
94
+
95
+
96
+ def _principal(runtime: MicrosoftRuntime, profile: Profile, user: str | None) -> str | None:
97
+ if user is None:
98
+ return None
99
+ if is_guid(user):
100
+ return user.strip().lower()
101
+ return runtime.entra(profile).find_principal(user).id
102
+
103
+
104
+ def _scopes(runtime: MicrosoftRuntime, profile: Profile, chosen: list[str] | None) -> list[str]:
105
+ return [f"/subscriptions/{item}" for item in runtime.subscription_ids(profile, chosen)]
106
+
107
+
108
+ def _duration(value: str) -> str:
109
+ """ISO 8601 durations as people say them: PT8H -> 8h, P1DT2H -> 1d 2h."""
110
+ match = re.fullmatch(r"P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?", value.strip())
111
+ if not value or not match or not any(match.groups()):
112
+ return value or "-"
113
+ return " ".join(f"{n}{unit}" for n, unit in zip(match.groups(), "dhms", strict=True) if n)
114
+
115
+
116
+ def _assignment_rows(items: list[PimAssignment], active: bool) -> list[list[render.Cell]]:
117
+ rows: list[list[render.Cell]] = []
118
+ for item in items:
119
+ ends: render.Cell = (
120
+ ("permanent", "yellow") if item.permanent and active else render.when(item.ends)
121
+ )
122
+ row: list[render.Cell] = [item.area, item.role, item.scope, item.member_type or "-"]
123
+ if active:
124
+ row.append(("activated", "green") if item.activated else item.assignment_type or "-")
125
+ rows.extend([[*row, render.when(item.starts), ends]])
126
+ return rows
127
+
128
+
129
+ def _assignment_records(items: list[PimAssignment]) -> list[dict[str, object]]:
130
+ return [
131
+ {
132
+ "area": item.area,
133
+ "role": item.role,
134
+ "scope": item.scope,
135
+ "member_type": item.member_type,
136
+ "assignment_type": item.assignment_type,
137
+ "permanent": item.permanent,
138
+ "raw": dict(item.raw),
139
+ }
140
+ for item in items
141
+ ]
142
+
143
+
144
+ @pim_app.command("eligible")
145
+ def eligible(
146
+ ctx: typer.Context,
147
+ azure: AzureOption = False,
148
+ entra: EntraOption = False,
149
+ groups: GroupsOption = False,
150
+ user: UserOption = None,
151
+ subscription: SubscriptionOption = None,
152
+ profile: ProfileOption = None,
153
+ output: OutputOption = Output.TABLE,
154
+ ) -> None:
155
+ """List the roles you (or --user) can activate through PIM."""
156
+ runtime = get_runtime(ctx).microsoft
157
+ selected = runtime.profile(profile)
158
+ principal = _principal(runtime, selected, user)
159
+ areas = _areas(azure, entra, groups)
160
+ items = _gather(
161
+ areas,
162
+ {
163
+ "azure": lambda: runtime.azure_pim(selected).eligible(
164
+ principal_id=principal,
165
+ scopes=_scopes(runtime, selected, subscription) if principal else (),
166
+ ),
167
+ "entra": lambda: runtime.graph_pim(selected).role_eligible(principal_id=principal),
168
+ "groups": lambda: runtime.graph_pim(selected).group_eligible(principal_id=principal),
169
+ },
170
+ )
171
+ render.emit(
172
+ output,
173
+ ["AREA", "ROLE", "SCOPE", "MEMBERSHIP", "FROM", "UNTIL"],
174
+ _assignment_rows(items, active=False),
175
+ _assignment_records(items),
176
+ )
177
+ render.note(f"{len(items)} eligible role(s)")
178
+
179
+
180
+ @pim_app.command("active")
181
+ def active(
182
+ ctx: typer.Context,
183
+ azure: AzureOption = False,
184
+ entra: EntraOption = False,
185
+ groups: GroupsOption = False,
186
+ user: UserOption = None,
187
+ subscription: SubscriptionOption = None,
188
+ permanent_only: Annotated[
189
+ bool,
190
+ typer.Option("--permanent-only", help="Only standing access: roles with no end date."),
191
+ ] = False,
192
+ profile: ProfileOption = None,
193
+ output: OutputOption = Output.TABLE,
194
+ ) -> None:
195
+ """List the roles you (or --user) hold now: activated through PIM, or permanent."""
196
+ runtime = get_runtime(ctx).microsoft
197
+ selected = runtime.profile(profile)
198
+ principal = _principal(runtime, selected, user)
199
+ items = _gather(
200
+ _areas(azure, entra, groups),
201
+ {
202
+ "azure": lambda: runtime.azure_pim(selected).active(
203
+ principal_id=principal,
204
+ scopes=_scopes(runtime, selected, subscription) if principal else (),
205
+ ),
206
+ "entra": lambda: runtime.graph_pim(selected).role_active(principal_id=principal),
207
+ "groups": lambda: runtime.graph_pim(selected).group_active(principal_id=principal),
208
+ },
209
+ )
210
+ if permanent_only:
211
+ items = [item for item in items if item.permanent]
212
+ render.emit(
213
+ output,
214
+ ["AREA", "ROLE", "SCOPE", "MEMBERSHIP", "TYPE", "FROM", "UNTIL"],
215
+ _assignment_rows(items, active=True),
216
+ _assignment_records(items),
217
+ )
218
+ standing = sum(1 for item in items if item.permanent)
219
+ render.note(f"{len(items)} active role(s), {standing} of them permanent")
220
+
221
+
222
+ def _request_rows(items: list[PimRequest]) -> list[list[render.Cell]]:
223
+ colours = {"pendingapproval": "yellow", "denied": "red", "provisioned": "green"}
224
+ return [
225
+ [
226
+ item.area,
227
+ render.when(item.created),
228
+ item.action,
229
+ (item.status, colours.get(item.status.casefold())),
230
+ item.role,
231
+ item.scope,
232
+ _duration(item.duration) if item.duration else render.when(item.ends),
233
+ item.justification,
234
+ ]
235
+ for item in items
236
+ ]
237
+
238
+
239
+ def _request_records(items: list[PimRequest]) -> list[dict[str, object]]:
240
+ return [
241
+ {"area": item.area, "status": item.status, "role": item.role, "raw": dict(item.raw)}
242
+ for item in items
243
+ ]
244
+
245
+
246
+ _REQUEST_HEADERS = ["AREA", "CREATED", "ACTION", "STATUS", "ROLE", "SCOPE", "FOR", "JUSTIFICATION"]
247
+
248
+
249
+ @pim_app.command("requests")
250
+ def requests_(
251
+ ctx: typer.Context,
252
+ azure: AzureOption = False,
253
+ entra: EntraOption = False,
254
+ groups: GroupsOption = False,
255
+ user: UserOption = None,
256
+ subscription: SubscriptionOption = None,
257
+ pending: Annotated[
258
+ bool, typer.Option("--pending", help="Only requests still waiting.")
259
+ ] = False,
260
+ profile: ProfileOption = None,
261
+ output: OutputOption = Output.TABLE,
262
+ ) -> None:
263
+ """List PIM requests you (or --user) made, newest first, and where each has got to."""
264
+ runtime = get_runtime(ctx).microsoft
265
+ selected = runtime.profile(profile)
266
+ principal = _principal(runtime, selected, user)
267
+ items = _gather(
268
+ _areas(azure, entra, groups),
269
+ {
270
+ "azure": lambda: runtime.azure_pim(selected).requests(
271
+ principal_id=principal,
272
+ scopes=_scopes(runtime, selected, subscription) if principal else (),
273
+ ),
274
+ "entra": lambda: runtime.graph_pim(selected).role_requests(principal_id=principal),
275
+ "groups": lambda: runtime.graph_pim(selected).group_requests(principal_id=principal),
276
+ },
277
+ )
278
+ if pending:
279
+ items = [item for item in items if item.pending]
280
+ render.emit(output, _REQUEST_HEADERS, _request_rows(items), _request_records(items))
281
+ render.note(f"{len(items)} request(s)")
282
+
283
+
284
+ @pim_app.command("approvals")
285
+ def approvals(
286
+ ctx: typer.Context,
287
+ azure: AzureOption = False,
288
+ entra: EntraOption = False,
289
+ groups: GroupsOption = False,
290
+ profile: ProfileOption = None,
291
+ output: OutputOption = Output.TABLE,
292
+ ) -> None:
293
+ """List PIM requests waiting for you to approve them."""
294
+ runtime = get_runtime(ctx).microsoft
295
+ selected = runtime.profile(profile)
296
+ items = _gather(
297
+ _areas(azure, entra, groups),
298
+ {
299
+ "azure": lambda: runtime.azure_pim(selected).requests(approver=True),
300
+ "entra": lambda: runtime.graph_pim(selected).role_requests(approver=True),
301
+ "groups": lambda: runtime.graph_pim(selected).group_requests(approver=True),
302
+ },
303
+ )
304
+ items = [item for item in items if item.pending]
305
+ render.emit(output, _REQUEST_HEADERS, _request_rows(items), _request_records(items))
306
+ render.note(
307
+ f"{len(items)} request(s) waiting for you; approve them in the portal or with "
308
+ f"your usual tooling ({brand.COMMAND} only reads)"
309
+ )
310
+
311
+
312
+ @pim_app.command("settings")
313
+ def settings(
314
+ ctx: typer.Context,
315
+ role: Annotated[
316
+ str | None,
317
+ typer.Argument(help="Role name, e.g. Owner or 'Global Administrator'.", show_default=False),
318
+ ] = None,
319
+ scope: Annotated[
320
+ str | None,
321
+ typer.Option(
322
+ "--scope",
323
+ help="An Azure scope, e.g. /subscriptions/SUBSCRIPTION_ID, for an Azure role.",
324
+ ),
325
+ ] = None,
326
+ group: Annotated[
327
+ str | None, typer.Option("--group", help="A PIM for Groups group (name or object id).")
328
+ ] = None,
329
+ owner: Annotated[
330
+ bool, typer.Option("--owner", help="With --group: the owner settings.")
331
+ ] = False,
332
+ profile: ProfileOption = None,
333
+ output: OutputOption = Output.TABLE,
334
+ ) -> None:
335
+ """Show what activating a role takes: duration, MFA, justification, approval, approvers.
336
+
337
+ An Azure role needs --scope; an Entra role needs only its name; a group needs --group.
338
+ """
339
+ runtime = get_runtime(ctx).microsoft
340
+ selected = runtime.profile(profile)
341
+ area: Area = "groups" if group else "azure" if scope else "entra"
342
+ try:
343
+ if group:
344
+ group_id = group if is_guid(group) else runtime.entra(selected).get_group(group).id
345
+ found = runtime.graph_pim(selected).group_settings(
346
+ group_id, "owner" if owner else "member"
347
+ )
348
+ elif role is None:
349
+ raise typer.BadParameter("name a role, or pass --group")
350
+ elif scope:
351
+ found = runtime.azure_pim(selected).settings(role, scope)
352
+ else:
353
+ found = runtime.graph_pim(selected).role_settings(role)
354
+ except LdoError as exc:
355
+ raise LdoError(str(exc), hint=_hint(area, str(exc)) or exc.hint) from None
356
+ pairs = [
357
+ ("Role", found.role),
358
+ ("Area", found.area),
359
+ ("Scope", found.scope),
360
+ ("Longest activation", _duration(found.max_activation)),
361
+ ("Needs MFA", render.yes_no(found.requires_mfa)),
362
+ ("Needs justification", render.yes_no(found.requires_justification)),
363
+ ("Needs a ticket", render.yes_no(found.requires_ticket)),
364
+ ("Needs approval", render.yes_no(found.requires_approval)),
365
+ ("Approvers", ", ".join(found.approvers)),
366
+ ("Authentication context", found.authentication_context),
367
+ ("Eligible assignments", _duration(found.eligible_expiry)),
368
+ ("Active assignments", _duration(found.active_expiry)),
369
+ ]
370
+ if output is Output.TABLE:
371
+ render.echo(render.pairs(pairs))
372
+ else:
373
+ render.emit(
374
+ output,
375
+ [label for label, _ in pairs],
376
+ [[value for _, value in pairs]],
377
+ {
378
+ **{label: value for label, value in pairs},
379
+ "rules": [dict(rule) for rule in found.raw],
380
+ },
381
+ )
@@ -0,0 +1,141 @@
1
+ """The json command: pretty-print JSON from anywhere, in colour on a terminal, or as YAML.
2
+
3
+ For JSON that did not come from this tool: 'az rest', curl, a file. It reads one JSON
4
+ document, or JSON Lines (one document per line, as OTLP logs and many APIs write them).
5
+ """
6
+
7
+ import json
8
+ import sys
9
+ from pathlib import Path
10
+ from typing import Annotated, Any
11
+
12
+ import typer
13
+
14
+ from libre_devops_helpers.cli import render
15
+ from libre_devops_helpers.core import brand, yaml_text
16
+ from libre_devops_helpers.core.errors import InputError
17
+
18
+ # YAML's colours follow JSON's: keys, strings, numbers, booleans, null.
19
+ _YAML_COLOURS = {
20
+ "key": (75, True),
21
+ "string": (114, False),
22
+ "number": (215, False),
23
+ "bool": (176, False),
24
+ "null": (244, False),
25
+ "punct": (None, False),
26
+ }
27
+
28
+
29
+ def register(app: typer.Typer) -> None:
30
+ app.command("json")(pretty)
31
+
32
+
33
+ def pretty(
34
+ file: Annotated[
35
+ Path | None,
36
+ typer.Argument(
37
+ metavar="[FILE]",
38
+ help="A JSON or JSON Lines file. Omit, or pass -, to read stdin.",
39
+ show_default=False,
40
+ ),
41
+ ] = None,
42
+ sort_keys: Annotated[bool, typer.Option("--sort-keys", help="Sort object keys.")] = False,
43
+ compact: Annotated[
44
+ bool, typer.Option("--compact", "-c", help="One line per document, no spaces.")
45
+ ] = False,
46
+ indent: Annotated[int, typer.Option("--indent", min=0, max=8, help="Spaces per level.")] = 2,
47
+ yaml: Annotated[bool, typer.Option("--yaml", help="Write it as YAML instead.")] = False,
48
+ colour: Annotated[
49
+ bool | None,
50
+ typer.Option(
51
+ "--colour/--no-colour",
52
+ help="Colour it, e.g. for less -R. Default: on a terminal, unless NO_COLOR is set.",
53
+ show_default=False,
54
+ ),
55
+ ] = None,
56
+ ) -> None:
57
+ """Pretty-print JSON from stdin or a file, in colour on a terminal, or as YAML.
58
+
59
+ For JSON from anywhere, e.g. az rest --url ... | ldo json. It reads one document, or
60
+ JSON Lines, and shows it indented, with keys, strings, numbers and brackets coloured
61
+ (brackets by how deeply they nest). Piped on, it stays plain. --yaml writes YAML.
62
+ """
63
+ documents = _documents(_read(file))
64
+ painted = render.colour_wanted() if colour is None else colour
65
+ if yaml:
66
+ if compact:
67
+ raise InputError("--compact is for JSON; YAML has no one-line form here")
68
+ paint = _paint_yaml if painted else None
69
+ for number, document in enumerate(documents):
70
+ if number:
71
+ typer.echo(typer.style("---", dim=True) if painted else "---", color=painted)
72
+ data = _sorted(document) if sort_keys else document
73
+ typer.echo(
74
+ yaml_text.dumps(data, indent=indent or 2, paint=paint), nl=False, color=painted
75
+ )
76
+ return
77
+ spacing = None if compact else indent
78
+ for document in documents:
79
+ if painted:
80
+ text = render.colour_json(document, indent=spacing, sort_keys=sort_keys)
81
+ else:
82
+ text = json.dumps(
83
+ document,
84
+ indent=spacing,
85
+ sort_keys=sort_keys,
86
+ ensure_ascii=False,
87
+ separators=(",", ":") if compact else None,
88
+ )
89
+ typer.echo(text, color=painted)
90
+
91
+
92
+ def _paint_yaml(kind: str, text: str) -> str:
93
+ colour, bold = _YAML_COLOURS[kind]
94
+ return typer.style(text, fg=colour, bold=bold) if colour is not None else text
95
+
96
+
97
+ def _sorted(value: Any) -> Any:
98
+ if isinstance(value, dict):
99
+ return {key: _sorted(value[key]) for key in sorted(value)}
100
+ if isinstance(value, list):
101
+ return [_sorted(item) for item in value]
102
+ return value
103
+
104
+
105
+ def _read(file: Path | None) -> str:
106
+ if file is not None and str(file) != "-":
107
+ try:
108
+ return file.read_text(encoding="utf-8-sig")
109
+ except FileNotFoundError:
110
+ raise InputError(f"no such file: {file}") from None
111
+ except (OSError, UnicodeDecodeError) as exc:
112
+ raise InputError(f"cannot read {file}: {exc}") from None
113
+ if sys.stdin.isatty():
114
+ raise InputError(
115
+ "no JSON to show", hint=f"pipe some in, e.g. az rest --url ... | {brand.COMMAND} json"
116
+ )
117
+ return sys.stdin.read()
118
+
119
+
120
+ def _documents(text: str) -> list[Any]:
121
+ """One JSON document, or each line of JSON Lines. A bad one says where it broke."""
122
+ if not text.strip():
123
+ raise InputError("the input is empty")
124
+ try:
125
+ return [json.loads(text)]
126
+ except json.JSONDecodeError as whole:
127
+ lines = [line for line in text.splitlines() if line.strip()]
128
+ if len(lines) < 2:
129
+ raise _not_json(whole) from None
130
+ documents = []
131
+ for line in lines:
132
+ try:
133
+ documents.append(json.loads(line))
134
+ except json.JSONDecodeError:
135
+ # Neither one document nor JSON Lines: report the document's own error.
136
+ raise _not_json(whole) from None
137
+ return documents
138
+
139
+
140
+ def _not_json(error: json.JSONDecodeError) -> InputError:
141
+ return InputError(f"not JSON: {error.msg} at line {error.lineno}, column {error.colno}")
@@ -0,0 +1,153 @@
1
+ """The profiles command: every profile the config file defines, for every vendor."""
2
+
3
+ from typing import Any
4
+
5
+ import typer
6
+
7
+ from libre_devops_helpers.cli import render
8
+ from libre_devops_helpers.cli.options import OutputOption, get_runtime
9
+ from libre_devops_helpers.cli.render import Output
10
+ from libre_devops_helpers.cli.runtime import Runtime
11
+ from libre_devops_helpers.core import brand
12
+ from libre_devops_helpers.core.errors import LdoError
13
+ from libre_devops_helpers.microsoft.azcli import find_account, match_profile
14
+ from libre_devops_helpers.microsoft.process import AzCliError
15
+ from libre_devops_helpers.servicenow import OAuthCredential
16
+ from libre_devops_helpers.servicenow import Profile as ServiceNowProfile
17
+
18
+
19
+ def register(app: typer.Typer) -> None:
20
+ app.command("profiles")(profiles)
21
+
22
+
23
+ def profiles(ctx: typer.Context, output: OutputOption = Output.TABLE) -> None:
24
+ """List configured profiles, which is active, and whether each can sign in."""
25
+ runtime = get_runtime(ctx)
26
+ runtime.config_file()
27
+ rows, records = _microsoft(runtime)
28
+ snow_rows, snow_records = _servicenow(runtime)
29
+ rows += snow_rows
30
+ records += snow_records
31
+ render.emit(
32
+ output,
33
+ ["VENDOR", "", "PROFILE", "TARGET", "AUTH", "SIGNED IN", "DESCRIPTION"],
34
+ rows,
35
+ records,
36
+ )
37
+ if not rows:
38
+ render.warn(f"no profiles configured; {brand.command('config init')} writes a template")
39
+
40
+
41
+ def _microsoft(runtime: Runtime) -> tuple[list[list[render.Cell]], list[dict[str, Any]]]:
42
+ ms = runtime.microsoft
43
+ config = ms.optional_config()
44
+ if config is None:
45
+ return [], []
46
+ try:
47
+ accounts = ms.az.accounts()
48
+ except AzCliError as exc:
49
+ render.warn(f"cannot read Azure CLI accounts: {exc}")
50
+ accounts = None
51
+ active_account = next((account for account in accounts or [] if account.is_default), None)
52
+ active = match_profile(config.profiles.values(), active_account) if active_account else None
53
+
54
+ rows: list[list[render.Cell]] = []
55
+ records: list[dict[str, Any]] = []
56
+ for profile in config.profiles.values():
57
+ signed_in = None if accounts is None else find_account(accounts, profile) is not None
58
+ is_active = active is not None and profile.name == active.name
59
+ is_default = profile.name == config.default_profile
60
+ records.append(
61
+ {
62
+ "vendor": "microsoft",
63
+ "name": profile.name,
64
+ "kind": profile.kind,
65
+ "tenant_id": profile.tenant_id,
66
+ "subscription_id": profile.subscription_id,
67
+ "cloud": profile.cloud.name,
68
+ "auth": profile.auth,
69
+ "description": profile.description,
70
+ "default": is_default,
71
+ "active": is_active,
72
+ "signed_in": signed_in,
73
+ }
74
+ )
75
+ target = (
76
+ f"subscription {profile.subscription_id}"
77
+ if profile.subscription_id
78
+ else f"tenant {profile.tenant_id}"
79
+ )
80
+ if profile.cloud.name != "public":
81
+ target += f" ({profile.cloud.name})"
82
+ rows.append(
83
+ [
84
+ "microsoft",
85
+ ("*", "green") if is_active else " ",
86
+ profile.name + (" (default)" if is_default else ""),
87
+ target,
88
+ profile.auth,
89
+ _signed_in_cell(profile.auth, signed_in),
90
+ profile.description,
91
+ ]
92
+ )
93
+ placeholders = [p.name for p in config.profiles.values() if p.has_placeholder_ids]
94
+ if placeholders:
95
+ render.warn(f"placeholder ids in {', '.join(placeholders)}; edit {config.path}")
96
+ return rows, records
97
+
98
+
99
+ def _servicenow(runtime: Runtime) -> tuple[list[list[render.Cell]], list[dict[str, Any]]]:
100
+ snow = runtime.servicenow
101
+ config = snow.config()
102
+ default = config.default_profile if config else None
103
+ rows: list[list[render.Cell]] = []
104
+ records: list[dict[str, Any]] = []
105
+ for profile in snow.profiles():
106
+ signed_in = _snow_signed_in(runtime, profile)
107
+ method = f"oauth ({profile.sign_in})" if profile.auth == "oauth" else profile.auth
108
+ records.append(
109
+ {
110
+ "vendor": "servicenow",
111
+ "name": profile.name,
112
+ "instance": profile.instance,
113
+ "auth": profile.auth,
114
+ "sign_in": profile.sign_in if profile.auth == "oauth" else None,
115
+ "description": profile.description,
116
+ "default": profile.name == default,
117
+ "signed_in": signed_in,
118
+ }
119
+ )
120
+ rows.append(
121
+ [
122
+ "servicenow",
123
+ " ",
124
+ profile.name + (" (default)" if profile.name == default else ""),
125
+ profile.host,
126
+ method,
127
+ "?" if signed_in is None else ("yes", "green") if signed_in else ("no", "yellow"),
128
+ profile.description,
129
+ ]
130
+ )
131
+ return rows, records
132
+
133
+
134
+ def _snow_signed_in(runtime: Runtime, profile: ServiceNowProfile) -> bool | None:
135
+ """Whether a command could sign in now, without asking: a kept sign-in, or a password."""
136
+ if profile.auth == "basic":
137
+ return bool(runtime.environ.get(profile.password_env))
138
+ if profile.token_cache == "keychain":
139
+ return None # reading the keychain can prompt; not worth it for a listing
140
+ try:
141
+ credential = runtime.servicenow.credential(profile)
142
+ except LdoError:
143
+ return False
144
+ return isinstance(credential, OAuthCredential) and credential.has_kept_sign_in()
145
+
146
+
147
+ def _signed_in_cell(auth: str, signed_in: bool | None) -> render.Cell:
148
+ # Only azure-cli profiles depend on an az session; the others bring their own.
149
+ if auth != "azure-cli":
150
+ return ("n/a", None)
151
+ if signed_in is None:
152
+ return "?"
153
+ return ("yes", "green") if signed_in else ("no", "yellow")