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,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
|