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,117 @@
1
+ """Run KQL against a Log Analytics (or Sentinel) workspace through the query API.
2
+
3
+ Access rests on Azure RBAC on the workspace (Log Analytics Reader is enough), not on
4
+ token scopes. The workspace is named by its workspace id (the GUID the portal calls the
5
+ "Workspace ID"), not by its resource id.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from datetime import timedelta
11
+ from typing import Any, Self
12
+
13
+ import requests
14
+
15
+ from libre_devops_helpers.core.auth import TokenProvider, token_source
16
+ from libre_devops_helpers.core.errors import ApiError, LdoError
17
+ from libre_devops_helpers.core.http import ApiClient
18
+ from libre_devops_helpers.core.tables import QueryResult
19
+ from libre_devops_helpers.core.util import is_guid
20
+ from libre_devops_helpers.microsoft.clouds import PUBLIC
21
+ from libre_devops_helpers.microsoft.config import Profile
22
+
23
+
24
+ class LogAnalyticsClient:
25
+ """Log Analytics queries. Close it (or use ``with``) when done."""
26
+
27
+ def __init__(self, api: ApiClient) -> None:
28
+ self.api = api
29
+
30
+ @classmethod
31
+ def create(
32
+ cls,
33
+ tokens: TokenProvider,
34
+ tenant_id: str,
35
+ *,
36
+ api_url: str = PUBLIC.log_analytics_url,
37
+ verify: bool | str = True,
38
+ session: requests.Session | None = None,
39
+ ) -> LogAnalyticsClient:
40
+ """A client for ``tenant_id`` that takes its tokens from ``tokens``."""
41
+ api = ApiClient(
42
+ api_url,
43
+ token_source(tokens, api_url, tenant_id),
44
+ name="Log Analytics",
45
+ verify=verify,
46
+ session=session,
47
+ # A query can legitimately run for minutes; the server caps it at 10.
48
+ timeout=600.0,
49
+ )
50
+ return cls(api)
51
+
52
+ @classmethod
53
+ def for_profile(
54
+ cls,
55
+ profile: Profile,
56
+ tokens: TokenProvider,
57
+ *,
58
+ verify: bool | str = True,
59
+ session: requests.Session | None = None,
60
+ ) -> LogAnalyticsClient:
61
+ """A client for a configured profile's tenant, in the profile's cloud."""
62
+ return cls.create(
63
+ tokens,
64
+ profile.tenant_id,
65
+ api_url=profile.cloud.log_analytics_url,
66
+ verify=verify,
67
+ session=session,
68
+ )
69
+
70
+ def close(self) -> None:
71
+ self.api.close()
72
+
73
+ def __enter__(self) -> Self:
74
+ return self
75
+
76
+ def __exit__(self, *exc_info: object) -> None:
77
+ self.close()
78
+
79
+ def query(
80
+ self, workspace_id: str, query: str, *, timespan: timedelta | None = None
81
+ ) -> QueryResult:
82
+ """Run ``query`` and return its first table.
83
+
84
+ ``timespan`` bounds the query from now backwards, on top of any time filter in
85
+ the query itself. A partial result comes back with the service's error in
86
+ ``warnings`` rather than failing.
87
+ """
88
+ if not is_guid(workspace_id):
89
+ raise LdoError(
90
+ f"not a Log Analytics workspace id: {workspace_id!r}",
91
+ hint="use the workspace's Workspace ID (a GUID), not its resource id",
92
+ )
93
+ if not query.strip():
94
+ raise LdoError("the Log Analytics query is empty")
95
+ body: dict[str, Any] = {"query": query}
96
+ if timespan is not None:
97
+ body["timespan"] = f"PT{int(timespan.total_seconds())}S"
98
+ data = self.api.post(f"/v1/workspaces/{workspace_id.strip().lower()}/query", body)
99
+ tables = data.get("tables")
100
+ warnings: list[str] = []
101
+ error = data.get("error")
102
+ if isinstance(error, dict):
103
+ detail = str(error.get("message") or error.get("code") or "partial result")
104
+ if not isinstance(tables, list) or not tables:
105
+ raise ApiError(f"Log Analytics: {detail}", code=str(error.get("code") or ""))
106
+ warnings.append(detail)
107
+ if not isinstance(tables, list) or not tables or not isinstance(tables[0], dict):
108
+ return QueryResult((), (), warnings=tuple(warnings))
109
+ table = tables[0]
110
+ columns = [
111
+ str(column.get("name"))
112
+ for column in table.get("columns") or []
113
+ if isinstance(column, dict)
114
+ ]
115
+ rows = [row for row in table.get("rows") or [] if isinstance(row, list)]
116
+ result = QueryResult.from_columns(columns, rows)
117
+ return QueryResult(result.columns, result.rows, warnings=tuple(warnings))
@@ -0,0 +1,79 @@
1
+ """Consumption Logic App workflows: offline checks, Azure's own validation, export.
2
+
3
+ Ported from the LibreDevOpsHelpers LogicApps module. The offline half checks a
4
+ definition against the contract Azure enforces, with no network; the online half reads
5
+ deployed workflows and asks the resource provider to validate a definition without
6
+ deploying it.
7
+
8
+ Public API::
9
+
10
+ from libre_devops_helpers.microsoft.logicapps import check, load
11
+
12
+ findings = check(load(Path("router.json")), connections=["office365"])
13
+ """
14
+
15
+ from libre_devops_helpers.microsoft.logicapps.checks import (
16
+ Connection,
17
+ ConnectionReference,
18
+ DeployStep,
19
+ Difference,
20
+ Finding,
21
+ ParameterStatus,
22
+ check,
23
+ compare,
24
+ connection_references,
25
+ connections_of,
26
+ deploy_order,
27
+ parameter_status,
28
+ rewrite_references,
29
+ with_parameter_defaults,
30
+ )
31
+ from libre_devops_helpers.microsoft.logicapps.client import (
32
+ API_VERSION,
33
+ LogicAppsClient,
34
+ Validation,
35
+ )
36
+ from libre_devops_helpers.microsoft.logicapps.document import (
37
+ SHAPES,
38
+ TOKEN_MARK,
39
+ ActionNode,
40
+ WorkflowDocument,
41
+ action_nodes,
42
+ load,
43
+ parse,
44
+ token_safe_json,
45
+ workflow_name_from,
46
+ )
47
+
48
+ # ARM access rests on Azure RBAC (Logic App Reader, or Reader), not token scopes.
49
+ REQUIREMENTS: tuple = ()
50
+
51
+ __all__ = [
52
+ "API_VERSION",
53
+ "REQUIREMENTS",
54
+ "SHAPES",
55
+ "TOKEN_MARK",
56
+ "ActionNode",
57
+ "Connection",
58
+ "ConnectionReference",
59
+ "DeployStep",
60
+ "Difference",
61
+ "Finding",
62
+ "LogicAppsClient",
63
+ "ParameterStatus",
64
+ "Validation",
65
+ "WorkflowDocument",
66
+ "action_nodes",
67
+ "check",
68
+ "compare",
69
+ "connection_references",
70
+ "connections_of",
71
+ "deploy_order",
72
+ "load",
73
+ "parameter_status",
74
+ "parse",
75
+ "rewrite_references",
76
+ "token_safe_json",
77
+ "with_parameter_defaults",
78
+ "workflow_name_from",
79
+ ]
@@ -0,0 +1,432 @@
1
+ """Offline checks on workflow definitions, against the contract Azure enforces.
2
+
3
+ These are not matters of taste; each is something the resource provider rejects, or
4
+ that deploys cleanly and fails when the workflow runs:
5
+
6
+ - Every declared parameter needs a value by deploy time, or the engine returns
7
+ ``InvalidTemplate`` ("The value for the workflow parameter ... is not provided").
8
+ ``$connections`` is the exception a deployment tool generates. SecureString and
9
+ SecureObject should arrive as inputs, so a secret never lands in the definition.
10
+ - A connection is referenced by an arbitrary key, and that key has to match the one
11
+ wired at deploy time; nothing checks it until a run fails.
12
+ - A native Workflow dispatch action's target is checked when the caller is written
13
+ (``NestedWorkflowNotFound``), so a caller deploys after the workflow it calls.
14
+
15
+ Nothing here touches the network.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import copy
21
+ import json
22
+ import re
23
+ from collections.abc import Iterable, Mapping, Sequence
24
+ from dataclasses import dataclass
25
+ from typing import Any
26
+
27
+ from libre_devops_helpers.core.errors import InputError
28
+ from libre_devops_helpers.microsoft.logicapps.document import WorkflowDocument, action_nodes
29
+
30
+ SECURE_TYPES = frozenset({"SecureString", "SecureObject"})
31
+ CONNECTIONS = "$connections"
32
+ _REFERENCE = re.compile(r"\$connections'\)\[\s*'([^']+)'\s*\]")
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class ParameterStatus:
37
+ """Whether one declared parameter will have a value at deploy time, and from where."""
38
+
39
+ workflow: str | None
40
+ name: str
41
+ type: str | None
42
+ secure: bool
43
+ satisfied: bool
44
+ satisfied_by: str # generated, supplied, default, wrapper, or none
45
+ reason: str
46
+ source: str
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class Finding:
51
+ """Something wrong with a definition: an error Azure rejects, or a warning."""
52
+
53
+ workflow: str | None
54
+ severity: str # error or warning
55
+ rule: str
56
+ message: str
57
+ source: str
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class Connection:
62
+ """A managed API connection a wrapper resolves, and how it authenticates."""
63
+
64
+ workflow: str | None
65
+ key: str
66
+ connection_name: str
67
+ connection_id: str | None
68
+ managed_api_id: str | None
69
+ authentication: str | None
70
+ source: str
71
+
72
+ @property
73
+ def managed_identity(self) -> bool:
74
+ return self.authentication == "ManagedServiceIdentity"
75
+
76
+
77
+ @dataclass(frozen=True)
78
+ class ConnectionReference:
79
+ """A ``$connections`` key the definition uses, where, and whether it is wired.
80
+
81
+ ``wired`` is None for a bare definition: it carries no values, so whether the key
82
+ resolves cannot be known from the file, and False would claim a fault not shown.
83
+ """
84
+
85
+ workflow: str | None
86
+ key: str
87
+ used_by: tuple[str, ...]
88
+ wired: bool | None
89
+ source: str
90
+
91
+
92
+ @dataclass(frozen=True)
93
+ class DeployStep:
94
+ """A workflow's place in the deploy order: tier 0 first, then 1, and so on."""
95
+
96
+ workflow: str
97
+ tier: int
98
+ depends_on: tuple[str, ...]
99
+ source: str
100
+
101
+
102
+ @dataclass(frozen=True)
103
+ class Difference:
104
+ """One JSON path where two definitions differ."""
105
+
106
+ path: str
107
+ change: str # changed, added (only in the second) or missing (only in the first)
108
+ reference: Any
109
+ difference: Any
110
+
111
+
112
+ def parameter_status(
113
+ document: WorkflowDocument, supplied: Sequence[str] = ()
114
+ ) -> list[ParameterStatus]:
115
+ """One record per declared parameter: satisfied, and by which source.
116
+
117
+ A parameter is satisfied by being ``$connections`` (generated), by a name passed in
118
+ ``supplied`` (a deployment tool's own parameters input), by a ``defaultValue``, or by
119
+ a value in the wrapper (never for a secure type, nor for a bare definition, whose
120
+ ``parameters`` block is its declarations).
121
+ """
122
+ declarations = document.declarations or {}
123
+ values = document.parameter_values or {}
124
+ found = []
125
+ for name, declaration in declarations.items():
126
+ declaration = declaration if isinstance(declaration, Mapping) else {}
127
+ kind = declaration.get("type") if isinstance(declaration.get("type"), str) else None
128
+ secure = kind in SECURE_TYPES
129
+ in_wrapper = name in values
130
+ if name == CONNECTIONS:
131
+ verdict = (
132
+ True,
133
+ "generated",
134
+ "generated by the deployment tool from its connections input",
135
+ )
136
+ elif name in supplied:
137
+ verdict = (True, "supplied", "supplied out of band as a deployment tool input")
138
+ elif "defaultValue" in declaration:
139
+ verdict = (True, "default", "the declaration carries a defaultValue")
140
+ elif in_wrapper and not secure:
141
+ verdict = (True, "wrapper", f"the {document.shape} wrapper carries a value")
142
+ elif in_wrapper and secure:
143
+ verdict = (
144
+ False,
145
+ "none",
146
+ "secure type: the wrapper's value is ignored on purpose; supply it as an input "
147
+ "so the secret stays out of the definition",
148
+ )
149
+ elif document.shape == "bare":
150
+ verdict = (
151
+ False,
152
+ "none",
153
+ "no defaultValue, and a bare definition has no wrapper values",
154
+ )
155
+ else:
156
+ verdict = (
157
+ False,
158
+ "none",
159
+ f"no defaultValue, and the {document.shape} wrapper has no value",
160
+ )
161
+ satisfied, satisfied_by, reason = verdict
162
+ found.append(
163
+ ParameterStatus(
164
+ document.name, name, kind, secure, satisfied, satisfied_by, reason, document.source
165
+ )
166
+ )
167
+ return found
168
+
169
+
170
+ def check(
171
+ document: WorkflowDocument,
172
+ *,
173
+ supplied: Sequence[str] = (),
174
+ connections: Sequence[str] = (),
175
+ callback_trigger: str | None = None,
176
+ ) -> list[Finding]:
177
+ """Every offline finding for ``document``, errors first.
178
+
179
+ Errors (the deploy is rejected): a parameter with no value; a supplied parameter the
180
+ definition does not declare; connections wired to a definition without a
181
+ ``$connections`` declaration; a callback trigger the definition does not have.
182
+ Warnings (it deploys, then surprises you): no triggers; no actions; a
183
+ ``$connections`` declaration with nothing wired to it.
184
+ """
185
+ findings: list[Finding] = []
186
+
187
+ def add(severity: str, rule: str, message: str) -> None:
188
+ findings.append(Finding(document.name, severity, rule, message, document.source))
189
+
190
+ for status in parameter_status(document, supplied):
191
+ if not status.satisfied:
192
+ add(
193
+ "error",
194
+ "parameter-has-no-value",
195
+ f"parameter {status.name!r} ({status.type}) has no value: {status.reason}",
196
+ )
197
+ declared = set(document.declarations or {})
198
+ for name in supplied:
199
+ if name not in declared:
200
+ add(
201
+ "error",
202
+ "supplied-parameter-not-declared",
203
+ f"parameter {name!r} is supplied but not declared: the definition is the "
204
+ "contract, and values only fill it",
205
+ )
206
+ if connections and CONNECTIONS not in declared:
207
+ add(
208
+ "error",
209
+ "connections-not-declared",
210
+ f"{len(connections)} connection(s) are wired, but the definition declares no "
211
+ "$connections parameter",
212
+ )
213
+ if not connections and CONNECTIONS in declared:
214
+ add(
215
+ "warning",
216
+ "connections-unwired",
217
+ "the definition declares $connections, but no connections are wired to it",
218
+ )
219
+ triggers = list(document.triggers)
220
+ if not triggers:
221
+ add("warning", "no-trigger", "the definition has no triggers, so nothing can start it")
222
+ if callback_trigger is not None and callback_trigger not in triggers:
223
+ add(
224
+ "error",
225
+ "callback-trigger-not-found",
226
+ f"callback trigger {callback_trigger!r} is not in the definition "
227
+ f"(triggers: {', '.join(triggers) or 'none'})",
228
+ )
229
+ if not document.actions:
230
+ add(
231
+ "warning",
232
+ "no-actions",
233
+ "the definition has no actions, so it does nothing when it runs",
234
+ )
235
+ return sorted(findings, key=lambda finding: finding.severity != "error")
236
+
237
+
238
+ def connections_of(document: WorkflowDocument) -> list[Connection]:
239
+ """The connections a wrapper resolves; none for a bare definition, which holds none."""
240
+ entry = (document.parameter_values or {}).get(CONNECTIONS)
241
+ value = entry.get("value") if isinstance(entry, Mapping) else None
242
+ if not isinstance(value, Mapping):
243
+ return []
244
+ found = []
245
+ for key, detail in value.items():
246
+ detail = detail if isinstance(detail, Mapping) else {}
247
+ authentication = (detail.get("connectionProperties") or {}).get("authentication") or {}
248
+ kind = authentication.get("type") if isinstance(authentication, Mapping) else None
249
+ found.append(
250
+ Connection(
251
+ workflow=document.name,
252
+ key=key,
253
+ connection_name=str(detail.get("connectionName") or key),
254
+ connection_id=detail.get("connectionId"),
255
+ managed_api_id=detail.get("id"),
256
+ authentication=kind,
257
+ source=document.source,
258
+ )
259
+ )
260
+ return found
261
+
262
+
263
+ def connection_references(document: WorkflowDocument) -> list[ConnectionReference]:
264
+ """Each ``$connections`` key the definition uses, from triggers and nested actions.
265
+
266
+ Triggers count: a Sentinel incident trigger reaches for a connection key exactly as an
267
+ action does.
268
+ """
269
+ wired: set[str] | None = None
270
+ if document.shape != "bare" and document.parameter_values is not None:
271
+ entry = document.parameter_values.get(CONNECTIONS)
272
+ value = entry.get("value") if isinstance(entry, Mapping) else None
273
+ if isinstance(value, Mapping):
274
+ wired = set(value)
275
+ nodes = [(f"triggers/{name}", trigger) for name, trigger in document.triggers.items()]
276
+ nodes += [(node.path, node.action) for node in action_nodes(document.actions)]
277
+ usage: dict[str, list[str]] = {}
278
+ for path, node in nodes:
279
+ inputs = node.get("inputs") if isinstance(node, Mapping) else None
280
+ if inputs is None:
281
+ continue
282
+ for key in _REFERENCE.findall(json.dumps(inputs)):
283
+ users = usage.setdefault(key, [])
284
+ if path not in users:
285
+ users.append(path)
286
+ return [
287
+ ConnectionReference(
288
+ document.name,
289
+ key,
290
+ tuple(users),
291
+ None if wired is None else key in wired,
292
+ document.source,
293
+ )
294
+ for key, users in usage.items()
295
+ ]
296
+
297
+
298
+ def deploy_order(documents: Iterable[WorkflowDocument]) -> list[DeployStep]:
299
+ """The order a set of workflows must deploy in, from their dispatch actions.
300
+
301
+ Only a native Workflow dispatch action makes a dependency, since only its target is
302
+ resolved when the caller is written: a workflow that merely names a sibling (a
303
+ watchdog listing what it watches) does not. Tier 0 dispatches to nothing in the set;
304
+ each other workflow is one tier above the highest it calls. A cycle is an error.
305
+ """
306
+ by_name: dict[str, WorkflowDocument] = {}
307
+ for document in documents:
308
+ name = document.name or document.source
309
+ if name in by_name:
310
+ raise InputError(f"two definitions are both called {name!r}")
311
+ by_name[name] = document
312
+ depends: dict[str, tuple[str, ...]] = {}
313
+ for name, document in by_name.items():
314
+ targets = []
315
+ for node in action_nodes(document.actions):
316
+ if node.action.get("type") != "Workflow":
317
+ continue
318
+ workflow = ((node.action.get("inputs") or {}).get("host") or {}).get("workflow") or {}
319
+ if isinstance(workflow, Mapping) and isinstance(workflow.get("id"), str):
320
+ targets.append(workflow["id"].lower())
321
+ depends[name] = tuple(
322
+ sibling
323
+ for sibling in by_name
324
+ if sibling != name
325
+ and any(target.endswith(f"/workflows/{sibling.lower()}") for target in targets)
326
+ )
327
+ tiers: dict[str, int] = {}
328
+ remaining = list(by_name)
329
+ tier = 0
330
+ while remaining:
331
+ ready = [name for name in remaining if all(dep in tiers for dep in depends[name])]
332
+ if not ready:
333
+ raise InputError(
334
+ "no deploy order exists: these workflows dispatch to each other in a cycle: "
335
+ + ", ".join(sorted(remaining))
336
+ )
337
+ for name in ready:
338
+ tiers[name] = tier
339
+ remaining.remove(name)
340
+ tier += 1
341
+ return [
342
+ DeployStep(name, tiers[name], depends[name], by_name[name].source)
343
+ for name in sorted(by_name, key=lambda item: (tiers[item], item))
344
+ ]
345
+
346
+
347
+ def compare(
348
+ reference: WorkflowDocument, difference: WorkflowDocument, *, parameter_values: bool = False
349
+ ) -> list[Difference]:
350
+ """The JSON paths where two definitions differ, after unwrapping both.
351
+
352
+ Unwrapping first means a code view export compares cleanly with an ARM GET of the
353
+ same workflow, and a rendered template with what is deployed: drift review as a diff.
354
+ """
355
+ found: list[Difference] = []
356
+ _walk(reference.definition, difference.definition, "definition", found)
357
+ if parameter_values:
358
+ _walk(reference.parameter_values, difference.parameter_values, "parameters", found)
359
+ return found
360
+
361
+
362
+ def _walk(left: Any, right: Any, path: str, found: list[Difference]) -> None:
363
+ if left is None and right is None:
364
+ return
365
+ if left is None or right is None:
366
+ change = "added" if left is None else "missing"
367
+ found.append(Difference(path, change, left, right))
368
+ return
369
+ if isinstance(left, Mapping) and isinstance(right, Mapping):
370
+ for key in sorted(set(left) | set(right)):
371
+ _walk(left.get(key), right.get(key), f"{path}.{key}", found)
372
+ return
373
+ if isinstance(left, list) and isinstance(right, list):
374
+ for index in range(max(len(left), len(right))):
375
+ _walk(
376
+ left[index] if index < len(left) else None,
377
+ right[index] if index < len(right) else None,
378
+ f"{path}[{index}]",
379
+ found,
380
+ )
381
+ return
382
+ if json.dumps(left, sort_keys=True) != json.dumps(right, sort_keys=True):
383
+ found.append(Difference(path, "changed", left, right))
384
+
385
+
386
+ def with_parameter_defaults(
387
+ document: WorkflowDocument, *, force: bool = False
388
+ ) -> tuple[dict[str, Any], int]:
389
+ """The definition with each wrapper value copied onto its declaration as a default.
390
+
391
+ That makes an export deployable on its own. ``$connections`` is left for the
392
+ deployment tool to generate, and secure types are never copied: writing a secret into
393
+ the definition is what they exist to avoid. The result then carries the SOURCE
394
+ estate's values; ``rewrite_references`` re-points them. Returns the new definition
395
+ and how many defaults were added.
396
+ """
397
+ if document.shape == "bare":
398
+ raise InputError(
399
+ f"{document.source} is a bare definition: it has declarations but no values to copy"
400
+ )
401
+ definition = copy.deepcopy(dict(document.definition))
402
+ declarations = definition.get("parameters")
403
+ values = document.parameter_values or {}
404
+ added = 0
405
+ if isinstance(declarations, dict):
406
+ for name, declaration in declarations.items():
407
+ if name == CONNECTIONS or not isinstance(declaration, dict):
408
+ continue
409
+ if declaration.get("type") in SECURE_TYPES:
410
+ continue
411
+ if "defaultValue" in declaration and not force:
412
+ continue
413
+ entry = values.get(name)
414
+ if isinstance(entry, Mapping) and "value" in entry:
415
+ declaration["defaultValue"] = copy.deepcopy(entry["value"])
416
+ added += 1
417
+ return definition, added
418
+
419
+
420
+ def rewrite_references(text: str, replacements: Sequence[tuple[str, str]]) -> str:
421
+ """``text`` with each literal find and replace applied, in the order given.
422
+
423
+ Estate-specific references (subscription ids, resource group and connection names,
424
+ instance URLs) live in the serialised JSON, so rewriting the text keeps everything
425
+ else byte-identical. Put the most specific replacement first: a later, general one
426
+ would otherwise rewrite what an earlier one produced.
427
+ """
428
+ for old, new in replacements:
429
+ if not old:
430
+ raise InputError("a replacement needs something to find")
431
+ text = text.replace(old, new)
432
+ return text