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,489 @@
1
+ """Logic App commands: check, audit and compare workflow definitions; read and validate them.
2
+
3
+ The offline commands (check, params, references, connections, order, diff, defaults,
4
+ rewrite) read files and never touch the network; they take files or folders, and a
5
+ folder means every .json and .json.tftpl in it. export and validate go to Azure, and
6
+ only read: validate asks the provider for its verdict and creates nothing.
7
+ """
8
+
9
+ import json
10
+ from pathlib import Path
11
+ from typing import Annotated, Any
12
+
13
+ import typer
14
+
15
+ from libre_devops_helpers.cli import render
16
+ from libre_devops_helpers.cli.exits import ATTENTION
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.core.errors import InputError
20
+ from libre_devops_helpers.microsoft.config import Profile
21
+ from libre_devops_helpers.microsoft.logicapps import (
22
+ WorkflowDocument,
23
+ check,
24
+ compare,
25
+ connection_references,
26
+ connections_of,
27
+ deploy_order,
28
+ load,
29
+ parameter_status,
30
+ rewrite_references,
31
+ with_parameter_defaults,
32
+ )
33
+
34
+ logicapp_app = typer.Typer(
35
+ rich_markup_mode="markdown",
36
+ name="logicapp",
37
+ help="Consumption Logic Apps: check, audit, compare, export and validate workflows.",
38
+ no_args_is_help=True,
39
+ )
40
+
41
+ _SEVERITY_COLOURS = {"error": "red", "warning": "yellow"}
42
+
43
+
44
+ def register(app: typer.Typer) -> None:
45
+ app.add_typer(logicapp_app)
46
+
47
+
48
+ FilesArgument = Annotated[
49
+ list[Path],
50
+ typer.Argument(
51
+ help="Definition files (.json, .json.tftpl), or folders of them.", show_default=False
52
+ ),
53
+ ]
54
+ SuppliedOption = Annotated[
55
+ list[str] | None,
56
+ typer.Option(
57
+ "--supplied",
58
+ help="A parameter the deployment tool supplies (a Terraform parameters input). Repeatable.",
59
+ show_default=False,
60
+ ),
61
+ ]
62
+ ResourceGroupOption = Annotated[
63
+ str, typer.Option("--resource-group", "-g", help="The resource group.", show_default=False)
64
+ ]
65
+ SubscriptionOption = Annotated[
66
+ str | None,
67
+ typer.Option(
68
+ "--subscription",
69
+ "-s",
70
+ help="Subscription id. Default: the profile's, or its only one.",
71
+ show_default=False,
72
+ ),
73
+ ]
74
+ OutFileOption = Annotated[
75
+ Path | None,
76
+ typer.Option(
77
+ "--out", help="Write here instead of printing.", dir_okay=False, show_default=False
78
+ ),
79
+ ]
80
+
81
+
82
+ @logicapp_app.command("check")
83
+ def check_command(
84
+ files: FilesArgument,
85
+ supplied: SuppliedOption = None,
86
+ connection: Annotated[
87
+ list[str] | None,
88
+ typer.Option(
89
+ "--connection",
90
+ help="A connection key the deployment wires. Repeatable.",
91
+ show_default=False,
92
+ ),
93
+ ] = None,
94
+ callback_trigger: Annotated[
95
+ str | None,
96
+ typer.Option("--callback-trigger", help="A trigger that must exist, to be called."),
97
+ ] = None,
98
+ strict: Annotated[bool, typer.Option("--strict", help="Warnings fail the check too.")] = False,
99
+ output: OutputOption = Output.TABLE,
100
+ ) -> None:
101
+ """Check definitions offline against what Azure rejects, or what fails once it runs.
102
+
103
+ Exits 3 when any definition has an error (or, with --strict, a warning), so a build
104
+ step can gate on it.
105
+ """
106
+ documents = _documents(files)
107
+ findings = [
108
+ finding
109
+ for document in documents
110
+ for finding in check(
111
+ document,
112
+ supplied=supplied or (),
113
+ connections=connection or (),
114
+ callback_trigger=callback_trigger,
115
+ )
116
+ ]
117
+ render.emit(
118
+ output,
119
+ ["WORKFLOW", "SEVERITY", "RULE", "MESSAGE"],
120
+ [
121
+ [
122
+ finding.workflow or finding.source,
123
+ (finding.severity, _SEVERITY_COLOURS.get(finding.severity)),
124
+ finding.rule,
125
+ finding.message,
126
+ ]
127
+ for finding in findings
128
+ ],
129
+ [_record(finding) for finding in findings],
130
+ )
131
+ errors = sum(1 for finding in findings if finding.severity == "error")
132
+ warnings = len(findings) - errors
133
+ render.note(f"{len(documents)} definition(s): {errors} error(s), {warnings} warning(s)")
134
+ if errors or (strict and warnings):
135
+ raise typer.Exit(ATTENTION)
136
+
137
+
138
+ @logicapp_app.command("params")
139
+ def params(
140
+ files: FilesArgument,
141
+ supplied: SuppliedOption = None,
142
+ unsatisfied: Annotated[
143
+ bool, typer.Option("--unsatisfied", help="Only the parameters that will fail.")
144
+ ] = False,
145
+ output: OutputOption = Output.TABLE,
146
+ ) -> None:
147
+ """Whether each declared parameter will have a value at deploy time, and from where."""
148
+ statuses = [
149
+ status
150
+ for document in _documents(files)
151
+ for status in parameter_status(document, supplied or ())
152
+ if not (unsatisfied and status.satisfied)
153
+ ]
154
+ render.emit(
155
+ output,
156
+ ["WORKFLOW", "PARAMETER", "TYPE", "SATISFIED", "BY", "REASON"],
157
+ [
158
+ [
159
+ status.workflow or status.source,
160
+ status.name,
161
+ status.type or "",
162
+ ("yes", "green") if status.satisfied else ("no", "red"),
163
+ status.satisfied_by,
164
+ status.reason,
165
+ ]
166
+ for status in statuses
167
+ ],
168
+ [_record(status) for status in statuses],
169
+ )
170
+
171
+
172
+ @logicapp_app.command("references")
173
+ def references(
174
+ files: FilesArgument,
175
+ unwired: Annotated[
176
+ bool, typer.Option("--unwired", help="Only keys the wrapper does not wire.")
177
+ ] = False,
178
+ output: OutputOption = Output.TABLE,
179
+ ) -> None:
180
+ """The $connections keys each definition uses, where, and whether each is wired.
181
+
182
+ A key the definition uses but nobody wired saves and deploys, then fails when the
183
+ workflow runs. Exits 3 when any key is unwired. A bare definition carries no values,
184
+ so its keys show as unknown.
185
+ """
186
+ found = [
187
+ reference
188
+ for document in _documents(files)
189
+ for reference in connection_references(document)
190
+ if not (unwired and reference.wired is not False)
191
+ ]
192
+ render.emit(
193
+ output,
194
+ ["WORKFLOW", "KEY", "USED BY", "WIRED"],
195
+ [
196
+ [
197
+ reference.workflow or reference.source,
198
+ reference.key,
199
+ ", ".join(reference.used_by),
200
+ "?"
201
+ if reference.wired is None
202
+ else ("yes", "green")
203
+ if reference.wired
204
+ else ("no", "red"),
205
+ ]
206
+ for reference in found
207
+ ],
208
+ [_record(reference) for reference in found],
209
+ )
210
+ if any(reference.wired is False for reference in found):
211
+ raise typer.Exit(ATTENTION)
212
+
213
+
214
+ @logicapp_app.command("connections")
215
+ def connections(files: FilesArgument, output: OutputOption = Output.TABLE) -> None:
216
+ """The managed API connections each export resolves, and whether they use managed identity."""
217
+ found = [
218
+ connection for document in _documents(files) for connection in connections_of(document)
219
+ ]
220
+ render.emit(
221
+ output,
222
+ ["WORKFLOW", "KEY", "CONNECTION", "AUTHENTICATION", "MANAGED IDENTITY", "CONNECTION ID"],
223
+ [
224
+ [
225
+ connection.workflow or connection.source,
226
+ connection.key,
227
+ connection.connection_name,
228
+ connection.authentication or "",
229
+ render.yes_no(connection.managed_identity),
230
+ connection.connection_id or "",
231
+ ]
232
+ for connection in found
233
+ ],
234
+ [
235
+ {**_record(connection), "managed_identity": connection.managed_identity}
236
+ for connection in found
237
+ ],
238
+ )
239
+
240
+
241
+ @logicapp_app.command("order")
242
+ def order(files: FilesArgument, output: OutputOption = Output.TABLE) -> None:
243
+ """The order a set of workflows must deploy in: tier 0 first.
244
+
245
+ A workflow that dispatches to a sibling (a native Workflow action) deploys after it,
246
+ since Azure checks the target when the caller is written.
247
+ """
248
+ steps = deploy_order(_documents(files))
249
+ render.emit(
250
+ output,
251
+ ["TIER", "WORKFLOW", "DEPENDS ON"],
252
+ [[str(step.tier), step.workflow, ", ".join(step.depends_on)] for step in steps],
253
+ [_record(step) for step in steps],
254
+ )
255
+
256
+
257
+ @logicapp_app.command("diff")
258
+ def diff(
259
+ reference: Annotated[Path, typer.Argument(help="The baseline, e.g. the rendered template.")],
260
+ difference: Annotated[Path, typer.Argument(help="The other, e.g. the deployed export.")],
261
+ parameters: Annotated[
262
+ bool, typer.Option("--parameters", help="Compare the wrapper parameter values too.")
263
+ ] = False,
264
+ output: OutputOption = Output.TABLE,
265
+ ) -> None:
266
+ """Compare two definitions, whatever their shapes, and list where they differ.
267
+
268
+ Exits 3 when they differ.
269
+ """
270
+ found = compare(load(reference), load(difference), parameter_values=parameters)
271
+ render.emit(
272
+ output,
273
+ ["PATH", "CHANGE", "REFERENCE", "DIFFERENCE"],
274
+ [
275
+ [item.path, item.change, _short(item.reference), _short(item.difference)]
276
+ for item in found
277
+ ],
278
+ [_record(item) for item in found],
279
+ )
280
+ render.note(f"{len(found)} difference(s)")
281
+ if found:
282
+ raise typer.Exit(ATTENTION)
283
+
284
+
285
+ @logicapp_app.command("defaults")
286
+ def defaults(
287
+ file: Annotated[Path, typer.Argument(help="An export (code view or ARM shape).")],
288
+ force: Annotated[
289
+ bool, typer.Option("--force", help="Replace defaults that are already there.")
290
+ ] = False,
291
+ out: OutFileOption = None,
292
+ ) -> None:
293
+ """Copy each wrapper value onto its declaration as a default, so the definition stands alone.
294
+
295
+ $connections and secure parameters are left alone. The result carries the source
296
+ estate's values; rewrite re-points them.
297
+ """
298
+ definition, added = with_parameter_defaults(load(file), force=force)
299
+ _write(json.dumps(definition, indent=2) + "\n", out)
300
+ render.note(f"added {added} default(s)")
301
+
302
+
303
+ @logicapp_app.command("rewrite")
304
+ def rewrite(
305
+ file: Annotated[Path, typer.Argument(help="The definition to re-point.")],
306
+ replace: Annotated[
307
+ list[str],
308
+ typer.Option(
309
+ "--replace",
310
+ help="OLD=NEW, a literal find and replace. Repeatable; applied in order.",
311
+ show_default=False,
312
+ ),
313
+ ],
314
+ out: OutFileOption = None,
315
+ ) -> None:
316
+ """Rewrite estate-specific references (ids, names, URLs) to lift a definition elsewhere.
317
+
318
+ Replacements are literal and applied in the order given, most specific first.
319
+ """
320
+ pairs = []
321
+ for item in replace:
322
+ old, separator, new = item.partition("=")
323
+ if not separator or not old:
324
+ raise typer.BadParameter(f"--replace takes OLD=NEW, not {item!r}")
325
+ pairs.append((old, new))
326
+ _write(rewrite_references(load(file).text, pairs), out)
327
+
328
+
329
+ @logicapp_app.command("export")
330
+ def export(
331
+ ctx: typer.Context,
332
+ resource_group: ResourceGroupOption,
333
+ out: Annotated[Path, typer.Option("--out", help="The folder to write into.", file_okay=False)],
334
+ name: Annotated[
335
+ list[str] | None,
336
+ typer.Option("--name", help="Only this workflow. Repeatable.", show_default=False),
337
+ ] = None,
338
+ shape: Annotated[
339
+ str,
340
+ typer.Option(
341
+ "--shape", help="code-view (definition and values) or arm (the whole resource)."
342
+ ),
343
+ ] = "code-view",
344
+ subscription: SubscriptionOption = None,
345
+ profile: ProfileOption = None,
346
+ output: OutputOption = Output.TABLE,
347
+ ) -> None:
348
+ """Export deployed workflows to files, one per workflow. Reads Azure; writes only files."""
349
+ if shape not in {"code-view", "arm"}:
350
+ raise typer.BadParameter("--shape must be code-view or arm")
351
+ runtime = get_runtime(ctx).microsoft
352
+ selected = runtime.profile(profile)
353
+ sub = _subscription(runtime, selected, subscription)
354
+ client = runtime.logicapps(selected)
355
+ wanted = {item.casefold() for item in name or ()}
356
+ listed = [
357
+ item
358
+ for item in client.workflows(sub, resource_group)
359
+ if not wanted or str(item.get("name", "")).casefold() in wanted
360
+ ]
361
+ if not listed:
362
+ render.warn(f"no Logic App workflows matched in {resource_group}")
363
+ return
364
+ out.mkdir(parents=True, exist_ok=True)
365
+ rows = []
366
+ for item in listed:
367
+ workflow = str(item["name"])
368
+ resource = client.workflow(sub, resource_group, workflow)
369
+ document: dict[str, Any] = resource
370
+ if shape == "code-view":
371
+ properties = resource.get("properties") or {}
372
+ document = {"definition": properties.get("definition")}
373
+ if "parameters" in properties:
374
+ document["parameters"] = properties["parameters"]
375
+ path = out / f"{workflow}.json"
376
+ path.write_text(json.dumps(document, indent=2) + "\n", encoding="utf-8")
377
+ rows.append(
378
+ {"workflow": workflow, "id": resource.get("id"), "shape": shape, "path": str(path)}
379
+ )
380
+ render.emit(
381
+ output,
382
+ ["WORKFLOW", "PATH"],
383
+ [[row["workflow"], row["path"]] for row in rows],
384
+ rows,
385
+ )
386
+ render.note(f"exported {len(rows)} workflow(s) to {out}")
387
+
388
+
389
+ @logicapp_app.command("validate")
390
+ def validate(
391
+ ctx: typer.Context,
392
+ file: Annotated[Path, typer.Argument(help="The definition, in any of the three shapes.")],
393
+ resource_group: ResourceGroupOption,
394
+ location: Annotated[
395
+ str | None,
396
+ typer.Option("--location", help="Region, e.g. uksouth. Default: the resource group's."),
397
+ ] = None,
398
+ name: Annotated[
399
+ str | None,
400
+ typer.Option("--name", help="Workflow name for the call. Default: from the file."),
401
+ ] = None,
402
+ subscription: SubscriptionOption = None,
403
+ profile: ProfileOption = None,
404
+ output: OutputOption = Output.TABLE,
405
+ ) -> None:
406
+ """Ask Azure whether it would accept a definition, without deploying anything.
407
+
408
+ The provider's own verdict: it type-checks the whole definition and creates nothing.
409
+ Exits 3 when it is rejected.
410
+ """
411
+ document = load(file)
412
+ runtime = get_runtime(ctx).microsoft
413
+ selected = runtime.profile(profile)
414
+ sub = _subscription(runtime, selected, subscription)
415
+ client = runtime.logicapps(selected)
416
+ region = location or client.location_of(sub, resource_group)
417
+ verdict = client.validate(
418
+ document, subscription=sub, resource_group=resource_group, location=region, name=name
419
+ )
420
+ render.emit(
421
+ output,
422
+ ["WORKFLOW", "LOCATION", "VALID", "MESSAGE"],
423
+ [
424
+ [
425
+ verdict.workflow,
426
+ verdict.location,
427
+ ("yes", "green") if verdict.valid else ("no", "red"),
428
+ verdict.message,
429
+ ]
430
+ ],
431
+ _record(verdict),
432
+ )
433
+ if not verdict.valid:
434
+ raise typer.Exit(ATTENTION)
435
+
436
+
437
+ # Shared -------------------------------------------------------------------------------
438
+
439
+
440
+ def _documents(paths: list[Path]) -> list[WorkflowDocument]:
441
+ """Every definition named, folders expanded to their .json and .json.tftpl files."""
442
+ files: list[Path] = []
443
+ for path in paths:
444
+ if path.is_dir():
445
+ files.extend(
446
+ sorted(
447
+ item
448
+ for item in path.iterdir()
449
+ if item.is_file() and item.name.endswith((".json", ".json.tftpl"))
450
+ )
451
+ )
452
+ else:
453
+ files.append(path)
454
+ if not files:
455
+ raise InputError("no definition files found", hint="pass .json or .json.tftpl files")
456
+ return [load(file) for file in files]
457
+
458
+
459
+ def _subscription(runtime, profile: Profile, given: str | None) -> str:
460
+ if given:
461
+ return given
462
+ if profile.subscription_id:
463
+ return profile.subscription_id
464
+ found = runtime.subscription_ids(profile)
465
+ if len(found) == 1:
466
+ return found[0]
467
+ raise InputError(
468
+ f"profile {profile.name!r} can see {len(found)} subscriptions",
469
+ hint="pass --subscription, or pin the profile to one",
470
+ )
471
+
472
+
473
+ def _record(item: object) -> dict[str, Any]:
474
+ from dataclasses import asdict
475
+
476
+ return asdict(item) # type: ignore[call-overload]
477
+
478
+
479
+ def _short(value: object) -> str:
480
+ text = value if isinstance(value, str) else json.dumps(value)
481
+ return text if len(text) <= 60 else text[:57] + "..."
482
+
483
+
484
+ def _write(text: str, out: Path | None) -> None:
485
+ if out is None:
486
+ typer.echo(text, nl=not text.endswith("\n"))
487
+ return
488
+ out.write_text(text, encoding="utf-8")
489
+ render.note(f"wrote {out}")
@@ -0,0 +1,69 @@
1
+ """Log Analytics commands: run KQL against a workspace."""
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 (
9
+ OutputOption,
10
+ ProfileOption,
11
+ QueryFileOption,
12
+ duration,
13
+ get_runtime,
14
+ read_query,
15
+ )
16
+ from libre_devops_helpers.cli.render import Output
17
+ from libre_devops_helpers.core.errors import ConfigError
18
+
19
+ logs_app = typer.Typer(
20
+ rich_markup_mode="markdown", help="Log Analytics and Sentinel: run KQL.", no_args_is_help=True
21
+ )
22
+
23
+
24
+ def register(app: typer.Typer) -> None:
25
+ app.add_typer(logs_app, name="logs")
26
+
27
+
28
+ @logs_app.command("query")
29
+ def query(
30
+ ctx: typer.Context,
31
+ text: Annotated[
32
+ str | None,
33
+ typer.Argument(
34
+ metavar="[QUERY]",
35
+ help="KQL query. Omit, or pass -, to read it from stdin.",
36
+ show_default=False,
37
+ ),
38
+ ] = None,
39
+ file: QueryFileOption = None,
40
+ workspace: Annotated[
41
+ str | None,
42
+ typer.Option(
43
+ "--workspace",
44
+ "-w",
45
+ help="Workspace ID (a GUID). Default: the profile's workspace_id.",
46
+ show_default=False,
47
+ ),
48
+ ] = None,
49
+ timespan: Annotated[
50
+ str | None,
51
+ typer.Option("--timespan", help="Bound the query to this long ago, e.g. 24h, 7d."),
52
+ ] = None,
53
+ profile: ProfileOption = None,
54
+ output: OutputOption = Output.TABLE,
55
+ ) -> None:
56
+ """Run a KQL query against a Log Analytics (or Sentinel) workspace."""
57
+ kql = read_query(text, file)
58
+ window = duration(timespan)
59
+ runtime = get_runtime(ctx).microsoft
60
+ selected = runtime.profile(profile)
61
+ workspace_id = workspace or selected.workspace_id
62
+ if not workspace_id:
63
+ raise ConfigError(
64
+ "no workspace given",
65
+ hint="pass --workspace, or set workspace_id on the profile",
66
+ )
67
+ result = runtime.logs(selected).query(workspace_id, kql, timespan=window)
68
+ render.query_result(result, output)
69
+ render.note(f"{len(result.rows)} row(s)")