libre-devops-helpers 0.4.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- libre_devops_helpers/__init__.py +23 -0
- libre_devops_helpers/__main__.py +5 -0
- libre_devops_helpers/cli/__init__.py +5 -0
- libre_devops_helpers/cli/app.py +147 -0
- libre_devops_helpers/cli/commands/__init__.py +1 -0
- libre_devops_helpers/cli/commands/automation.py +321 -0
- libre_devops_helpers/cli/commands/az.py +93 -0
- libre_devops_helpers/cli/commands/azure.py +258 -0
- libre_devops_helpers/cli/commands/config.py +54 -0
- libre_devops_helpers/cli/commands/devices.py +548 -0
- libre_devops_helpers/cli/commands/entra.py +554 -0
- libre_devops_helpers/cli/commands/graph.py +358 -0
- libre_devops_helpers/cli/commands/incidents.py +420 -0
- libre_devops_helpers/cli/commands/intune.py +79 -0
- libre_devops_helpers/cli/commands/keyvault.py +142 -0
- libre_devops_helpers/cli/commands/logicapp.py +489 -0
- libre_devops_helpers/cli/commands/logs.py +69 -0
- libre_devops_helpers/cli/commands/pim.py +381 -0
- libre_devops_helpers/cli/commands/pretty.py +141 -0
- libre_devops_helpers/cli/commands/profiles.py +153 -0
- libre_devops_helpers/cli/commands/snow.py +268 -0
- libre_devops_helpers/cli/commands/token.py +222 -0
- libre_devops_helpers/cli/commands/welcome.py +43 -0
- libre_devops_helpers/cli/commands/xdr.py +353 -0
- libre_devops_helpers/cli/exits.py +12 -0
- libre_devops_helpers/cli/options.py +146 -0
- libre_devops_helpers/cli/render.py +360 -0
- libre_devops_helpers/cli/runtime.py +348 -0
- libre_devops_helpers/cli/servicenow_runtime.py +170 -0
- libre_devops_helpers/core/__init__.py +94 -0
- libre_devops_helpers/core/auth.py +103 -0
- libre_devops_helpers/core/brand.py +67 -0
- libre_devops_helpers/core/browser.py +21 -0
- libre_devops_helpers/core/config.py +159 -0
- libre_devops_helpers/core/dpapi.py +60 -0
- libre_devops_helpers/core/errors.py +90 -0
- libre_devops_helpers/core/http.py +412 -0
- libre_devops_helpers/core/inputs.py +193 -0
- libre_devops_helpers/core/log.py +246 -0
- libre_devops_helpers/core/poll.py +88 -0
- libre_devops_helpers/core/process.py +106 -0
- libre_devops_helpers/core/sheets.py +330 -0
- libre_devops_helpers/core/tables.py +49 -0
- libre_devops_helpers/core/timewindow.py +127 -0
- libre_devops_helpers/core/token_store.py +266 -0
- libre_devops_helpers/core/util.py +120 -0
- libre_devops_helpers/core/yaml_text.py +142 -0
- libre_devops_helpers/microsoft/__init__.py +94 -0
- libre_devops_helpers/microsoft/auth/__init__.py +43 -0
- libre_devops_helpers/microsoft/auth/azure_cli.py +91 -0
- libre_devops_helpers/microsoft/auth/delegated.py +391 -0
- libre_devops_helpers/microsoft/auth/entra.py +241 -0
- libre_devops_helpers/microsoft/auth/factory.py +114 -0
- libre_devops_helpers/microsoft/auth/lapse.py +42 -0
- libre_devops_helpers/microsoft/auth/managed_identity.py +84 -0
- libre_devops_helpers/microsoft/automation/__init__.py +24 -0
- libre_devops_helpers/microsoft/automation/client.py +241 -0
- libre_devops_helpers/microsoft/automation/models.py +131 -0
- libre_devops_helpers/microsoft/azcli/__init__.py +27 -0
- libre_devops_helpers/microsoft/azcli/client.py +81 -0
- libre_devops_helpers/microsoft/azcli/context.py +95 -0
- libre_devops_helpers/microsoft/azure/__init__.py +34 -0
- libre_devops_helpers/microsoft/azure/client.py +260 -0
- libre_devops_helpers/microsoft/azure/models.py +198 -0
- libre_devops_helpers/microsoft/clouds.py +83 -0
- libre_devops_helpers/microsoft/config.py +244 -0
- libre_devops_helpers/microsoft/devices/__init__.py +45 -0
- libre_devops_helpers/microsoft/devices/antivirus.py +149 -0
- libre_devops_helpers/microsoft/devices/check.py +286 -0
- libre_devops_helpers/microsoft/devices/inspect.py +146 -0
- libre_devops_helpers/microsoft/devices/models.py +148 -0
- libre_devops_helpers/microsoft/entra/__init__.py +41 -0
- libre_devops_helpers/microsoft/entra/client.py +422 -0
- libre_devops_helpers/microsoft/entra/models.py +334 -0
- libre_devops_helpers/microsoft/entra/permissions.py +51 -0
- libre_devops_helpers/microsoft/graph/__init__.py +38 -0
- libre_devops_helpers/microsoft/graph/client.py +292 -0
- libre_devops_helpers/microsoft/incidents/__init__.py +51 -0
- libre_devops_helpers/microsoft/incidents/client.py +217 -0
- libre_devops_helpers/microsoft/incidents/models.py +165 -0
- libre_devops_helpers/microsoft/incidents/permissions.py +15 -0
- libre_devops_helpers/microsoft/intune/__init__.py +18 -0
- libre_devops_helpers/microsoft/intune/client.py +94 -0
- libre_devops_helpers/microsoft/intune/models.py +63 -0
- libre_devops_helpers/microsoft/intune/permissions.py +16 -0
- libre_devops_helpers/microsoft/keyvault/__init__.py +34 -0
- libre_devops_helpers/microsoft/keyvault/client.py +185 -0
- libre_devops_helpers/microsoft/loganalytics/__init__.py +17 -0
- libre_devops_helpers/microsoft/loganalytics/client.py +117 -0
- libre_devops_helpers/microsoft/logicapps/__init__.py +79 -0
- libre_devops_helpers/microsoft/logicapps/checks.py +432 -0
- libre_devops_helpers/microsoft/logicapps/client.py +162 -0
- libre_devops_helpers/microsoft/logicapps/document.py +202 -0
- libre_devops_helpers/microsoft/pim/__init__.py +39 -0
- libre_devops_helpers/microsoft/pim/azure.py +238 -0
- libre_devops_helpers/microsoft/pim/entra.py +294 -0
- libre_devops_helpers/microsoft/pim/models.py +93 -0
- libre_devops_helpers/microsoft/pim/permissions.py +98 -0
- libre_devops_helpers/microsoft/pim/rules.py +70 -0
- libre_devops_helpers/microsoft/process.py +70 -0
- libre_devops_helpers/microsoft/resources.py +137 -0
- libre_devops_helpers/microsoft/tokens.py +269 -0
- libre_devops_helpers/microsoft/xdr/__init__.py +33 -0
- libre_devops_helpers/microsoft/xdr/client.py +246 -0
- libre_devops_helpers/microsoft/xdr/models.py +181 -0
- libre_devops_helpers/microsoft/xdr/permissions.py +24 -0
- libre_devops_helpers/py.typed +0 -0
- libre_devops_helpers/servicenow/__init__.py +50 -0
- libre_devops_helpers/servicenow/auth.py +409 -0
- libre_devops_helpers/servicenow/config.py +261 -0
- libre_devops_helpers/servicenow/instance/__init__.py +32 -0
- libre_devops_helpers/servicenow/instance/client.py +91 -0
- libre_devops_helpers/servicenow/instance/models.py +131 -0
- libre_devops_helpers/servicenow/roles.py +23 -0
- libre_devops_helpers/servicenow/tables.py +133 -0
- libre_devops_helpers-0.4.1.dist-info/METADATA +153 -0
- libre_devops_helpers-0.4.1.dist-info/RECORD +120 -0
- libre_devops_helpers-0.4.1.dist-info/WHEEL +4 -0
- libre_devops_helpers-0.4.1.dist-info/entry_points.txt +2 -0
- libre_devops_helpers-0.4.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,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)")
|