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,23 @@
1
+ """Libre DevOps Helpers: helpers and a CLI for Azure, Entra, Defender and more.
2
+
3
+ Subpackages, lowest layer first, in the style of the LibreDevOpsHelpers nested
4
+ PowerShell modules:
5
+
6
+ - ``core`` everything shared: errors, config and clouds, credentials, the az
7
+ runner, HTTP client, token checks, polling, inputs, logging
8
+ - ``azcli`` Azure CLI accounts, sign-in and switching between profiles
9
+ - ``entra`` Entra ID: devices, users, groups, roles, sign-ins, apps, policies
10
+ - ``xdr`` Defender for Endpoint: machines, alerts, vulnerabilities, hunting
11
+ - ``intune`` Intune managed devices and compliance
12
+ - ``azure`` Azure Resource Manager: Resource Graph, RBAC, Defender for Cloud
13
+ - ``keyvault`` Key Vault secret, certificate and key metadata
14
+ - ``loganalytics`` KQL against Log Analytics workspaces
15
+ - ``devices`` devices across Entra, Defender and Intune: check, watch, show
16
+ - ``cli`` the ``ldo`` command, a thin layer over the packages above
17
+
18
+ Feature modules depend on ``core`` only; ``devices`` also uses ``entra``, ``xdr`` and
19
+ ``intune``. Every client takes any token provider, so where tokens come from is the
20
+ caller's choice.
21
+ """
22
+
23
+ __version__ = "0.4.1"
@@ -0,0 +1,5 @@
1
+ """Allow ``python -m libre_devops_helpers``."""
2
+
3
+ from libre_devops_helpers.cli import main
4
+
5
+ main()
@@ -0,0 +1,5 @@
1
+ """The ``ldo`` command line: a thin layer that parses, calls the library, renders."""
2
+
3
+ from libre_devops_helpers.cli.app import app, main
4
+
5
+ __all__ = ["app", "main"]
@@ -0,0 +1,147 @@
1
+ """The ldo root command.
2
+
3
+ Commands parse arguments and render results; the work happens in the importable
4
+ subpackages. Library errors are turned into a message and an exit code here,
5
+ and only here.
6
+ """
7
+
8
+ import sys
9
+ from pathlib import Path
10
+ from typing import Annotated
11
+
12
+ import typer
13
+
14
+ from libre_devops_helpers import __version__
15
+ from libre_devops_helpers.cli import render
16
+ from libre_devops_helpers.cli.commands import (
17
+ automation,
18
+ az,
19
+ azure,
20
+ config,
21
+ devices,
22
+ entra,
23
+ graph,
24
+ incidents,
25
+ intune,
26
+ keyvault,
27
+ logicapp,
28
+ logs,
29
+ pim,
30
+ pretty,
31
+ profiles,
32
+ snow,
33
+ token,
34
+ welcome,
35
+ xdr,
36
+ )
37
+ from libre_devops_helpers.cli.runtime import Runtime
38
+ from libre_devops_helpers.core import brand
39
+ from libre_devops_helpers.core.errors import LdoError
40
+ from libre_devops_helpers.core.log import LOG_FORMATS, configure_logging, normalise_format
41
+
42
+ app = typer.Typer(
43
+ rich_markup_mode="markdown",
44
+ name=brand.COMMAND,
45
+ help=f"{brand.DISPLAY_NAME}: fast, read-only helpers for Entra ID, Defender XDR, Intune, "
46
+ "Azure, Graph, PIM, Logic Apps and ServiceNow. Signs in as you. "
47
+ f"Docs: {brand.docs('README')}",
48
+ pretty_exceptions_enable=False,
49
+ context_settings={"help_option_names": ["-h", "--help"]},
50
+ )
51
+
52
+
53
+ def _show_version(value: bool) -> None:
54
+ if value:
55
+ typer.echo(f"{brand.COMMAND} {__version__}")
56
+ raise typer.Exit()
57
+
58
+
59
+ @app.callback(invoke_without_command=True)
60
+ def _root(
61
+ ctx: typer.Context,
62
+ config_path: Annotated[
63
+ Path | None,
64
+ typer.Option(
65
+ "--config",
66
+ help=f"Config file. Default: ${brand.CONFIG_ENV}, "
67
+ f"else ~/.config/{brand.CONFIG_DIR}/config.toml.",
68
+ dir_okay=False,
69
+ show_default=False,
70
+ ),
71
+ ] = None,
72
+ verbose: Annotated[
73
+ int, typer.Option("--verbose", "-v", count=True, help="-v info, -vv debug (stderr).")
74
+ ] = 0,
75
+ log_format: Annotated[
76
+ str,
77
+ typer.Option(
78
+ "--log-format",
79
+ envvar=brand.LOG_FORMAT_ENV,
80
+ help=f"Log line format on stderr: {', '.join(LOG_FORMATS)} (OTLP/JSON).",
81
+ ),
82
+ ] = "text",
83
+ log_level: Annotated[
84
+ str | None,
85
+ typer.Option(
86
+ "--log-level",
87
+ envvar=brand.LOG_LEVEL_ENV,
88
+ help="Minimum log level (trace, debug, info, warn, error, fatal). -v and -vv win.",
89
+ show_default=False,
90
+ ),
91
+ ] = None,
92
+ version: Annotated[
93
+ bool,
94
+ typer.Option(
95
+ "--version", callback=_show_version, is_eager=True, help="Show the version and exit."
96
+ ),
97
+ ] = False,
98
+ ) -> None:
99
+ configure_logging(verbose, log_format, log_level)
100
+ render.structured_output(normalise_format(log_format) != "text")
101
+ # Tests pass a prepared Runtime as obj; a real run builds one here.
102
+ if not isinstance(ctx.obj, Runtime):
103
+ ctx.obj = Runtime(config_path=config_path)
104
+ elif config_path is not None:
105
+ ctx.obj.config_path = config_path
106
+ ctx.call_on_close(ctx.obj.close)
107
+ if ctx.invoked_subcommand is None:
108
+ # Bare 'ldo': a greeting, then the help.
109
+ render.banner()
110
+ typer.echo(ctx.get_help())
111
+ raise typer.Exit()
112
+
113
+
114
+ for _module in (
115
+ welcome,
116
+ config,
117
+ profiles,
118
+ az,
119
+ entra,
120
+ graph,
121
+ xdr,
122
+ intune,
123
+ azure,
124
+ keyvault,
125
+ logs,
126
+ logicapp,
127
+ pim,
128
+ devices,
129
+ snow,
130
+ pretty,
131
+ ):
132
+ _module.register(app)
133
+ # Token commands are about Entra-issued tokens, so they live in the entra group.
134
+ token.register(entra.entra_app)
135
+ # Incidents are Defender XDR's (Sentinel's included), so they live in the xdr group.
136
+ incidents.register(xdr.xdr_app)
137
+ # Automation accounts are Azure resources, so they live in the azure group.
138
+ automation.register(azure.azure_app)
139
+
140
+
141
+ def main() -> None:
142
+ """Console entry point: run the app and report library errors cleanly."""
143
+ try:
144
+ app()
145
+ except LdoError as exc:
146
+ render.error(str(exc), exc.hint)
147
+ sys.exit(exc.exit_code)
@@ -0,0 +1 @@
1
+ """CLI commands, one module per area. Each module exposes ``register(app)``."""
@@ -0,0 +1,321 @@
1
+ """Azure Automation commands: accounts, runbook jobs, and a job's logs and output.
2
+
3
+ An account is named by its name (found across the subscriptions in scope, narrowed by
4
+ -g) or by its resource id. A job is named by its id; without one, 'logs' and 'output'
5
+ take the newest job, of --runbook when given. Everything reads.
6
+ """
7
+
8
+ from datetime import UTC, datetime
9
+ from typing import Annotated, Any
10
+
11
+ import typer
12
+
13
+ from libre_devops_helpers.cli import render
14
+ from libre_devops_helpers.cli.exits import ATTENTION
15
+ from libre_devops_helpers.cli.options import OutputOption, ProfileOption, duration, get_runtime
16
+ from libre_devops_helpers.cli.render import Output
17
+ from libre_devops_helpers.cli.runtime import MicrosoftRuntime
18
+ from libre_devops_helpers.core.errors import InputError, NotFoundError
19
+ from libre_devops_helpers.core.util import format_duration
20
+ from libre_devops_helpers.microsoft.automation import (
21
+ STREAMS,
22
+ AutomationAccount,
23
+ AutomationClient,
24
+ Job,
25
+ JobStream,
26
+ )
27
+ from libre_devops_helpers.microsoft.config import Profile
28
+
29
+ automation_app = typer.Typer(
30
+ rich_markup_mode="markdown",
31
+ name="automation",
32
+ help="Azure Automation: accounts, runbook jobs, and each job's logs and output.",
33
+ no_args_is_help=True,
34
+ )
35
+
36
+ _STATUS_COLOURS = {"Completed": "green", "Running": "cyan", "Failed": "red", "Suspended": "red"}
37
+ _STREAM_COLOURS = {"Error": "red", "Warning": "yellow", "Verbose": "bright_black"}
38
+
39
+ AccountArgument = Annotated[
40
+ str, typer.Argument(metavar="ACCOUNT", help="The Automation account: its name or resource id.")
41
+ ]
42
+ GroupOption = Annotated[
43
+ str | None,
44
+ typer.Option("--resource-group", "-g", help="The account's resource group, when names repeat."),
45
+ ]
46
+ SubscriptionOption = Annotated[
47
+ list[str] | None,
48
+ typer.Option(
49
+ "--subscription",
50
+ "-s",
51
+ help="Subscription id to search. Repeatable. Default: the profile's subscription, "
52
+ "else every one in the tenant.",
53
+ show_default=False,
54
+ ),
55
+ ]
56
+ RunbookOption = Annotated[
57
+ str | None, typer.Option("--runbook", "-r", help="Only this runbook's jobs.")
58
+ ]
59
+ JobArgument = Annotated[
60
+ str | None,
61
+ typer.Argument(
62
+ metavar="[JOB]",
63
+ help="The job id. Default: the newest job (of --runbook).",
64
+ show_default=False,
65
+ ),
66
+ ]
67
+
68
+
69
+ def register(app: typer.Typer) -> None:
70
+ app.add_typer(automation_app)
71
+
72
+
73
+ def _scope(runtime: MicrosoftRuntime, profile: Profile, given: list[str] | None) -> list[str]:
74
+ if given:
75
+ return given
76
+ if profile.subscription_id:
77
+ return [profile.subscription_id]
78
+ return runtime.subscription_ids(profile)
79
+
80
+
81
+ def _account(
82
+ runtime: MicrosoftRuntime,
83
+ profile: Profile,
84
+ ref: str,
85
+ group: str | None,
86
+ subscriptions: list[str] | None,
87
+ ) -> tuple[AutomationClient, AutomationAccount]:
88
+ client = runtime.automation(profile)
89
+ # A resource id needs no search, so the subscriptions are only listed for a name.
90
+ scope = [] if ref.strip().startswith("/") else _scope(runtime, profile, subscriptions)
91
+ return client, client.find_account(ref, scope, resource_group=group)
92
+
93
+
94
+ @automation_app.command("accounts")
95
+ def accounts(
96
+ ctx: typer.Context,
97
+ subscription: SubscriptionOption = None,
98
+ profile: ProfileOption = None,
99
+ output: OutputOption = Output.TABLE,
100
+ ) -> None:
101
+ """List the Automation accounts in the subscriptions in scope."""
102
+ runtime = get_runtime(ctx).microsoft
103
+ selected = runtime.profile(profile)
104
+ found = runtime.automation(selected).accounts(_scope(runtime, selected, subscription))
105
+ render.emit(
106
+ output,
107
+ ["ACCOUNT", "RESOURCE GROUP", "LOCATION", "SUBSCRIPTION"],
108
+ [[item.name, item.resource_group, item.location, item.subscription_id] for item in found],
109
+ [dict(item.raw) for item in found],
110
+ )
111
+ render.note(f"{len(found)} account(s)")
112
+
113
+
114
+ @automation_app.command("jobs")
115
+ def jobs(
116
+ ctx: typer.Context,
117
+ account: AccountArgument,
118
+ runbook: RunbookOption = None,
119
+ status: Annotated[
120
+ list[str] | None,
121
+ typer.Option(
122
+ "--status",
123
+ help="Only jobs in this state, e.g. failed, completed, running. Repeatable.",
124
+ ),
125
+ ] = None,
126
+ failed: Annotated[
127
+ bool, typer.Option("--failed", help="Only jobs that failed, were suspended or stopped.")
128
+ ] = False,
129
+ since: Annotated[
130
+ str | None, typer.Option("--since", help="Only jobs created in this window, e.g. 24h, 7d.")
131
+ ] = None,
132
+ limit: Annotated[int, typer.Option("--limit", "-n", min=1, help="How many to show.")] = 20,
133
+ resource_group: GroupOption = None,
134
+ subscription: SubscriptionOption = None,
135
+ profile: ProfileOption = None,
136
+ output: OutputOption = Output.TABLE,
137
+ ) -> None:
138
+ """List a runbook's recent jobs (runs), newest first: when, how long, and how each ended.
139
+
140
+ Jobs are kept for 30 days. Exits 3 when any job shown failed, was suspended or stopped.
141
+ """
142
+ runtime = get_runtime(ctx).microsoft
143
+ client, found = _account(
144
+ runtime, runtime.profile(profile), account, resource_group, subscription
145
+ )
146
+ window = duration(since)
147
+ wanted = {item.casefold() for item in status or ()}
148
+ now = datetime.now(UTC)
149
+ shown = [
150
+ job
151
+ for job in client.jobs(found)
152
+ if (runbook is None or job.runbook.casefold() == runbook.casefold())
153
+ and (not wanted or job.status.casefold() in wanted)
154
+ and (not failed or job.failed)
155
+ and (window is None or (job.created is not None and now - job.created <= window))
156
+ ][:limit]
157
+ render.emit(
158
+ output,
159
+ ["JOB", "RUNBOOK", "STATUS", "STARTED", "TOOK", "RUN ON"],
160
+ [_job_row(job, now) for job in shown],
161
+ [dict(job.raw) for job in shown],
162
+ )
163
+ failures = sum(1 for job in shown if job.failed)
164
+ note = f"{len(shown)} job(s) in {found.name}"
165
+ if failures:
166
+ note += f", {failures} failed"
167
+ render.note(note)
168
+ if failures:
169
+ raise typer.Exit(ATTENTION)
170
+
171
+
172
+ @automation_app.command("logs")
173
+ def logs(
174
+ ctx: typer.Context,
175
+ account: AccountArgument,
176
+ job: JobArgument = None,
177
+ runbook: RunbookOption = None,
178
+ stream: Annotated[
179
+ list[str] | None,
180
+ typer.Option(
181
+ "--stream",
182
+ help=f"Only these streams: {', '.join(STREAMS)}. Repeatable. Default: all.",
183
+ ),
184
+ ] = None,
185
+ full: Annotated[
186
+ bool,
187
+ typer.Option("--full", help="Read each record in full, not its summary (a call each)."),
188
+ ] = False,
189
+ resource_group: GroupOption = None,
190
+ subscription: SubscriptionOption = None,
191
+ profile: ProfileOption = None,
192
+ output: OutputOption = Output.TABLE,
193
+ ) -> None:
194
+ """A job's logs, oldest first: its output, warnings, errors and other streams.
195
+
196
+ Without a job id, the newest job, of --runbook when given. Verbose, progress and debug
197
+ records appear only when the runbook turns them on. Exits 3 when the job failed.
198
+ """
199
+ kinds = _stream_kinds(stream)
200
+ runtime = get_runtime(ctx).microsoft
201
+ client, found = _account(
202
+ runtime, runtime.profile(profile), account, resource_group, subscription
203
+ )
204
+ chosen = client.job(found, job or _newest(client, found, runbook).id)
205
+ records = [
206
+ item for item in client.streams(found, chosen.id) if kinds is None or item.stream in kinds
207
+ ]
208
+ if full:
209
+ records = [client.stream(found, chosen.id, item.id) for item in records]
210
+ if output is Output.TABLE:
211
+ render.echo(render.pairs(_job_pairs(found, chosen)))
212
+ render.echo()
213
+ render.emit(
214
+ output,
215
+ ["TIME", "STREAM", "MESSAGE"],
216
+ [
217
+ [
218
+ render.when(item.time),
219
+ (item.stream, _STREAM_COLOURS.get(item.stream)),
220
+ item.message,
221
+ ]
222
+ for item in records
223
+ ],
224
+ {
225
+ "job": dict(chosen.raw),
226
+ "streams": [_stream_record(item) for item in records],
227
+ },
228
+ )
229
+ render.note(f"{len(records)} record(s)")
230
+ if chosen.failed:
231
+ raise typer.Exit(ATTENTION)
232
+
233
+
234
+ @automation_app.command("output")
235
+ def output_text(
236
+ ctx: typer.Context,
237
+ account: AccountArgument,
238
+ job: JobArgument = None,
239
+ runbook: RunbookOption = None,
240
+ resource_group: GroupOption = None,
241
+ subscription: SubscriptionOption = None,
242
+ profile: ProfileOption = None,
243
+ ) -> None:
244
+ """A job's output as plain text, as the portal's Output tab shows it, for piping.
245
+
246
+ Without a job id, the newest job, of --runbook when given.
247
+ """
248
+ runtime = get_runtime(ctx).microsoft
249
+ client, found = _account(
250
+ runtime, runtime.profile(profile), account, resource_group, subscription
251
+ )
252
+ chosen = job or _newest(client, found, runbook).id
253
+ text = client.output(found, chosen)
254
+ render.echo(text.rstrip("\n"))
255
+ render.note(f"job {chosen} in {found.name}")
256
+
257
+
258
+ def _newest(client: AutomationClient, account: AutomationAccount, runbook: str | None) -> Job:
259
+ for job in client.jobs(account):
260
+ if runbook is None or job.runbook.casefold() == runbook.casefold():
261
+ return job
262
+ what = f"runbook {runbook!r}" if runbook else account.name
263
+ raise NotFoundError(f"no jobs for {what} in the last 30 days")
264
+
265
+
266
+ def _stream_kinds(given: list[str] | None) -> set[str] | None:
267
+ if not given:
268
+ return None
269
+ kinds = set()
270
+ for item in given:
271
+ kind = STREAMS.get(item.strip().casefold())
272
+ if kind is None:
273
+ raise InputError(f"unknown stream {item!r}", hint=f"use one of {', '.join(STREAMS)}")
274
+ kinds.add(kind)
275
+ return kinds
276
+
277
+
278
+ def _job_row(job: Job, now: datetime) -> list[render.Cell]:
279
+ return [
280
+ job.id,
281
+ job.runbook,
282
+ (job.status, "red" if job.failed else _STATUS_COLOURS.get(job.status)),
283
+ render.when(job.started or job.created, now=now),
284
+ _took(job, now),
285
+ job.run_on or "Azure",
286
+ ]
287
+
288
+
289
+ def _took(job: Job, now: datetime) -> str:
290
+ if job.started is None:
291
+ return "-"
292
+ end = job.ended or now
293
+ suffix = "" if job.ended else " so far"
294
+ return format_duration(end - job.started) + suffix
295
+
296
+
297
+ def _job_pairs(account: AutomationAccount, job: Job) -> list[tuple[str, str]]:
298
+ now = datetime.now(UTC)
299
+ pairs = [
300
+ ("Account", f"{account.name} ({account.resource_group})"),
301
+ ("Runbook", job.runbook),
302
+ ("Job", job.id),
303
+ ("Status", job.status),
304
+ ("Started", render.when(job.started or job.created)),
305
+ ("Took", _took(job, now)),
306
+ ("Run on", job.run_on or "Azure"),
307
+ ]
308
+ if job.started_by:
309
+ pairs.append(("Started by", job.started_by))
310
+ if job.exception:
311
+ pairs.append(("Exception", job.exception))
312
+ return pairs
313
+
314
+
315
+ def _stream_record(item: JobStream) -> dict[str, Any]:
316
+ return {
317
+ "id": item.id,
318
+ "time": item.time.isoformat() if item.time else None,
319
+ "stream": item.stream,
320
+ "message": item.message,
321
+ }
@@ -0,0 +1,93 @@
1
+ """Azure CLI context commands: switch the active account to a profile, show the active one."""
2
+
3
+ from typing import Annotated
4
+
5
+ import typer
6
+
7
+ from libre_devops_helpers.cli import render
8
+ from libre_devops_helpers.cli.options import OutputOption, complete_profile, get_runtime
9
+ from libre_devops_helpers.cli.render import Output
10
+ from libre_devops_helpers.core import brand
11
+ from libre_devops_helpers.microsoft.azcli import match_profile, switch_profile
12
+ from libre_devops_helpers.microsoft.process import AzCliError
13
+
14
+ az_app = typer.Typer(
15
+ rich_markup_mode="markdown",
16
+ help="Azure CLI context: switch profiles, show the active account.",
17
+ no_args_is_help=True,
18
+ )
19
+
20
+
21
+ def register(app: typer.Typer) -> None:
22
+ app.add_typer(az_app, name="az")
23
+
24
+
25
+ @az_app.command("use")
26
+ def use(
27
+ ctx: typer.Context,
28
+ name: Annotated[
29
+ str,
30
+ typer.Argument(help="Microsoft profile to switch to.", autocompletion=complete_profile),
31
+ ],
32
+ device_code: Annotated[
33
+ bool, typer.Option("--device-code", help="Sign in with a device code, not a browser.")
34
+ ] = False,
35
+ no_login: Annotated[
36
+ bool, typer.Option("--no-login", help="Fail instead of prompting when not signed in.")
37
+ ] = False,
38
+ ) -> None:
39
+ """Switch the Azure CLI's active account to a profile, signing in when needed.
40
+
41
+ This changes the az context for every shell. The other commands do not need
42
+ it: they pass the profile's tenant to az explicitly.
43
+ """
44
+ runtime = get_runtime(ctx).microsoft
45
+ result = switch_profile(
46
+ runtime.az, runtime.config().get(name), sign_in=not no_login, device_code=device_code
47
+ )
48
+ account = result.account
49
+ target = "tenant-level account" if account.tenant_level else f"{account.name} ({account.id})"
50
+ render.echo(
51
+ f"Switched to {name}: {target} in tenant {account.tenant_id} "
52
+ f"as {account.user or 'unknown user'}"
53
+ )
54
+
55
+
56
+ @az_app.command("whoami")
57
+ def whoami(ctx: typer.Context, output: OutputOption = Output.TABLE) -> None:
58
+ """Show the Azure CLI's active account and the profile it matches."""
59
+ runtime = get_runtime(ctx).microsoft
60
+ account = runtime.az.current_account()
61
+ if account is None:
62
+ raise AzCliError(
63
+ "the Azure CLI is not signed in", hint=f"run {brand.command('az use <profile>')}"
64
+ )
65
+ config = runtime.optional_config()
66
+ profile = match_profile(config.profiles.values(), account) if config else None
67
+ data = {
68
+ "profile": profile.name if profile else None,
69
+ "tenant_id": account.tenant_id,
70
+ "subscription_id": None if account.tenant_level else account.id,
71
+ "subscription_name": None if account.tenant_level else account.name,
72
+ "user": account.user,
73
+ "state": account.state,
74
+ }
75
+ if output is not Output.TABLE:
76
+ render.emit(output, list(data), [[str(value or "") for value in data.values()]], data)
77
+ return
78
+ render.echo(
79
+ render.pairs(
80
+ [
81
+ ("Profile", data["profile"] or "(no matching profile)"),
82
+ ("Tenant", account.tenant_id),
83
+ (
84
+ "Subscription",
85
+ "tenant-level account"
86
+ if account.tenant_level
87
+ else f"{account.name} ({account.id})",
88
+ ),
89
+ ("User", account.user),
90
+ ("State", account.state),
91
+ ]
92
+ )
93
+ )