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,241 @@
|
|
|
1
|
+
"""Azure Automation through ARM: accounts, the jobs their runbooks ran, and the jobs' logs.
|
|
2
|
+
|
|
3
|
+
A job is one run of a runbook. It writes streams, as the portal's job page shows them:
|
|
4
|
+
output, warnings, errors, and verbose, progress and debug records when the runbook turns
|
|
5
|
+
those on. Jobs are kept for 30 days. Everything here reads: nothing starts, stops or
|
|
6
|
+
changes a runbook.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from collections.abc import Iterable, Sequence
|
|
13
|
+
from typing import Self
|
|
14
|
+
from urllib.parse import quote
|
|
15
|
+
|
|
16
|
+
import requests
|
|
17
|
+
|
|
18
|
+
from libre_devops_helpers.core.auth import TokenProvider, token_source
|
|
19
|
+
from libre_devops_helpers.core.errors import AmbiguousError, ApiError, InputError, NotFoundError
|
|
20
|
+
from libre_devops_helpers.core.http import ApiClient
|
|
21
|
+
from libre_devops_helpers.core.util import is_guid
|
|
22
|
+
from libre_devops_helpers.microsoft.automation.models import AutomationAccount, Job, JobStream
|
|
23
|
+
from libre_devops_helpers.microsoft.clouds import PUBLIC
|
|
24
|
+
from libre_devops_helpers.microsoft.config import Profile
|
|
25
|
+
|
|
26
|
+
API_VERSION = "2023-11-01"
|
|
27
|
+
PROVIDER = "Microsoft.Automation/automationAccounts"
|
|
28
|
+
# What each part of a path may hold, so nothing typed can reach another resource.
|
|
29
|
+
_ACCOUNT = re.compile(r"^[A-Za-z0-9-]{1,50}$")
|
|
30
|
+
_GROUP = re.compile(r"^[A-Za-z0-9._()-]{1,90}$")
|
|
31
|
+
_JOB = re.compile(r"^[A-Za-z0-9-]{1,64}$")
|
|
32
|
+
_STREAM = re.compile(r"^[A-Za-z0-9:._-]{1,128}$")
|
|
33
|
+
_ACCOUNT_ID = re.compile(
|
|
34
|
+
r"^/subscriptions/(?P<subscription>[^/]+)/resourceGroups/(?P<group>[^/]+)"
|
|
35
|
+
r"/providers/Microsoft\.Automation/automationAccounts/(?P<name>[^/]+)$",
|
|
36
|
+
re.IGNORECASE,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class AutomationClient:
|
|
41
|
+
"""Reads Automation accounts and their jobs through ARM. Close it when done."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, api: ApiClient) -> None:
|
|
44
|
+
self.api = api
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
def create(
|
|
48
|
+
cls,
|
|
49
|
+
tokens: TokenProvider,
|
|
50
|
+
tenant_id: str,
|
|
51
|
+
*,
|
|
52
|
+
arm_url: str = PUBLIC.arm_url,
|
|
53
|
+
verify: bool | str = True,
|
|
54
|
+
session: requests.Session | None = None,
|
|
55
|
+
) -> Self:
|
|
56
|
+
api = ApiClient(
|
|
57
|
+
arm_url,
|
|
58
|
+
token_source(tokens, arm_url.rstrip("/") + "/", tenant_id),
|
|
59
|
+
name="Azure Resource Manager",
|
|
60
|
+
verify=verify,
|
|
61
|
+
session=session,
|
|
62
|
+
)
|
|
63
|
+
return cls(api)
|
|
64
|
+
|
|
65
|
+
@classmethod
|
|
66
|
+
def for_profile(
|
|
67
|
+
cls,
|
|
68
|
+
profile: Profile,
|
|
69
|
+
tokens: TokenProvider,
|
|
70
|
+
*,
|
|
71
|
+
verify: bool | str = True,
|
|
72
|
+
session: requests.Session | None = None,
|
|
73
|
+
) -> Self:
|
|
74
|
+
return cls.create(
|
|
75
|
+
tokens, profile.tenant_id, arm_url=profile.cloud.arm_url, verify=verify, session=session
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
def close(self) -> None:
|
|
79
|
+
self.api.close()
|
|
80
|
+
|
|
81
|
+
def __enter__(self) -> Self:
|
|
82
|
+
return self
|
|
83
|
+
|
|
84
|
+
def __exit__(self, *exc_info: object) -> None:
|
|
85
|
+
self.close()
|
|
86
|
+
|
|
87
|
+
# Accounts -----------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
def accounts(self, subscriptions: Iterable[str]) -> list[AutomationAccount]:
|
|
90
|
+
"""Every Automation account in the subscriptions, by name."""
|
|
91
|
+
found: list[AutomationAccount] = []
|
|
92
|
+
for subscription in subscriptions:
|
|
93
|
+
path = f"/subscriptions/{_guid(subscription)}/providers/{PROVIDER}"
|
|
94
|
+
found.extend(
|
|
95
|
+
AutomationAccount.from_json(item)
|
|
96
|
+
for item in self.api.get_all(
|
|
97
|
+
path, params={"api-version": API_VERSION}, next_link="nextLink"
|
|
98
|
+
)
|
|
99
|
+
)
|
|
100
|
+
return sorted(found, key=lambda account: (account.name.casefold(), account.id))
|
|
101
|
+
|
|
102
|
+
def find_account(
|
|
103
|
+
self, ref: str, subscriptions: Sequence[str], *, resource_group: str | None = None
|
|
104
|
+
) -> AutomationAccount:
|
|
105
|
+
"""One account, by its resource id, or by name within ``subscriptions``.
|
|
106
|
+
|
|
107
|
+
A name several accounts share is refused, with their ids to choose from.
|
|
108
|
+
"""
|
|
109
|
+
ref = ref.strip()
|
|
110
|
+
by_id = _ACCOUNT_ID.match(ref)
|
|
111
|
+
if by_id:
|
|
112
|
+
path = (
|
|
113
|
+
f"/subscriptions/{_guid(by_id['subscription'])}/resourceGroups/"
|
|
114
|
+
f"{_group(by_id['group'])}/providers/{PROVIDER}/{_account(by_id['name'])}"
|
|
115
|
+
)
|
|
116
|
+
try:
|
|
117
|
+
return AutomationAccount.from_json(
|
|
118
|
+
self.api.get(path, params={"api-version": API_VERSION})
|
|
119
|
+
)
|
|
120
|
+
except ApiError as exc:
|
|
121
|
+
if exc.status == 404:
|
|
122
|
+
raise NotFoundError(f"no Automation account {ref}") from None
|
|
123
|
+
raise
|
|
124
|
+
name = _account(ref)
|
|
125
|
+
matches = [
|
|
126
|
+
account
|
|
127
|
+
for account in self.accounts(subscriptions)
|
|
128
|
+
if account.name.casefold() == name.casefold()
|
|
129
|
+
and (
|
|
130
|
+
resource_group is None
|
|
131
|
+
or account.resource_group.casefold() == resource_group.casefold()
|
|
132
|
+
)
|
|
133
|
+
]
|
|
134
|
+
if not matches:
|
|
135
|
+
where = f" in resource group {resource_group}" if resource_group else ""
|
|
136
|
+
raise NotFoundError(
|
|
137
|
+
f"no Automation account is named {name!r}{where}",
|
|
138
|
+
hint="check the subscription (-s), or list them: 'azure automation accounts'",
|
|
139
|
+
)
|
|
140
|
+
if len(matches) > 1:
|
|
141
|
+
raise AmbiguousError(
|
|
142
|
+
f"{len(matches)} Automation accounts are named {name!r}",
|
|
143
|
+
hint="pass -g for its resource group, or its resource id: "
|
|
144
|
+
+ ", ".join(account.id for account in matches),
|
|
145
|
+
)
|
|
146
|
+
return matches[0]
|
|
147
|
+
|
|
148
|
+
# Jobs ---------------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
def jobs(self, account: AutomationAccount) -> list[Job]:
|
|
151
|
+
"""Every job the account keeps (30 days of them), newest first."""
|
|
152
|
+
found = [
|
|
153
|
+
Job.from_json(item)
|
|
154
|
+
for item in self.api.get_all(
|
|
155
|
+
f"{_account_path(account)}/jobs",
|
|
156
|
+
params={"api-version": API_VERSION},
|
|
157
|
+
next_link="nextLink",
|
|
158
|
+
)
|
|
159
|
+
]
|
|
160
|
+
return sorted(found, key=_newest_first)
|
|
161
|
+
|
|
162
|
+
def job(self, account: AutomationAccount, job_id: str) -> Job:
|
|
163
|
+
"""One job, with what only a single job's GET gives: who started it, and why it failed."""
|
|
164
|
+
try:
|
|
165
|
+
data = self.api.get(
|
|
166
|
+
f"{_account_path(account)}/jobs/{_job(job_id)}", params={"api-version": API_VERSION}
|
|
167
|
+
)
|
|
168
|
+
except ApiError as exc:
|
|
169
|
+
if exc.status == 404:
|
|
170
|
+
raise NotFoundError(f"no job {job_id} in {account.name}") from None
|
|
171
|
+
raise
|
|
172
|
+
return Job.from_json(data)
|
|
173
|
+
|
|
174
|
+
def streams(self, account: AutomationAccount, job_id: str) -> list[JobStream]:
|
|
175
|
+
"""Every record the job wrote, oldest first, each with its summary."""
|
|
176
|
+
found = [
|
|
177
|
+
JobStream.from_json(item)
|
|
178
|
+
for item in self.api.get_all(
|
|
179
|
+
f"{_account_path(account)}/jobs/{_job(job_id)}/streams",
|
|
180
|
+
params={"api-version": API_VERSION},
|
|
181
|
+
next_link="nextLink",
|
|
182
|
+
)
|
|
183
|
+
]
|
|
184
|
+
return sorted(found, key=lambda stream: (stream.time is None, stream.time))
|
|
185
|
+
|
|
186
|
+
def stream(self, account: AutomationAccount, job_id: str, stream_id: str) -> JobStream:
|
|
187
|
+
"""One record in full: a listing carries only its summary."""
|
|
188
|
+
if not _STREAM.match(stream_id):
|
|
189
|
+
raise InputError(f"{stream_id!r} is not a job stream id")
|
|
190
|
+
data = self.api.get(
|
|
191
|
+
f"{_account_path(account)}/jobs/{_job(job_id)}/streams/{quote(stream_id, safe='')}",
|
|
192
|
+
params={"api-version": API_VERSION},
|
|
193
|
+
)
|
|
194
|
+
return JobStream.from_json(data)
|
|
195
|
+
|
|
196
|
+
def output(self, account: AutomationAccount, job_id: str) -> str:
|
|
197
|
+
"""The job's output stream as text, as the portal's Output tab shows it."""
|
|
198
|
+
return self.api.get_text(
|
|
199
|
+
f"{_account_path(account)}/jobs/{_job(job_id)}/output",
|
|
200
|
+
params={"api-version": API_VERSION},
|
|
201
|
+
headers={"Accept": "text/plain"},
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _newest_first(job: Job) -> tuple[bool, float]:
|
|
206
|
+
when = job.created or job.started
|
|
207
|
+
return (when is None, -when.timestamp() if when else 0.0)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _account_path(account: AutomationAccount) -> str:
|
|
211
|
+
matched = _ACCOUNT_ID.match(account.id)
|
|
212
|
+
if not matched:
|
|
213
|
+
raise InputError(f"{account.id!r} is not an Automation account's resource id")
|
|
214
|
+
return (
|
|
215
|
+
f"/subscriptions/{_guid(matched['subscription'])}/resourceGroups/"
|
|
216
|
+
f"{_group(matched['group'])}/providers/{PROVIDER}/{_account(matched['name'])}"
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _guid(value: str) -> str:
|
|
221
|
+
if not is_guid(value):
|
|
222
|
+
raise InputError(f"{value!r} is not a subscription id")
|
|
223
|
+
return value
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _group(value: str) -> str:
|
|
227
|
+
if not _GROUP.match(value):
|
|
228
|
+
raise InputError(f"{value!r} is not a resource group name")
|
|
229
|
+
return value
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _account(value: str) -> str:
|
|
233
|
+
if not _ACCOUNT.match(value):
|
|
234
|
+
raise InputError(f"{value!r} is not an Automation account name")
|
|
235
|
+
return value
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _job(value: str) -> str:
|
|
239
|
+
if not _JOB.match(value.strip()):
|
|
240
|
+
raise InputError(f"{value!r} is not a job id")
|
|
241
|
+
return value.strip()
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Azure Automation accounts, the jobs their runbooks ran, and each job's log streams."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from libre_devops_helpers.core.util import parse_datetime
|
|
11
|
+
|
|
12
|
+
# Job states that need someone to look: the runbook did not finish as it should.
|
|
13
|
+
FAILED = frozenset({"Failed", "Suspended", "Stopped", "Blocked"})
|
|
14
|
+
# The streams a job writes, as the portal's tabs name them, and as ARM spells them.
|
|
15
|
+
STREAMS = {
|
|
16
|
+
"output": "Output",
|
|
17
|
+
"error": "Error",
|
|
18
|
+
"warning": "Warning",
|
|
19
|
+
"verbose": "Verbose",
|
|
20
|
+
"progress": "Progress",
|
|
21
|
+
"debug": "Debug",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class AutomationAccount:
|
|
27
|
+
"""An Automation account, and where it lives."""
|
|
28
|
+
|
|
29
|
+
id: str
|
|
30
|
+
name: str
|
|
31
|
+
subscription_id: str
|
|
32
|
+
resource_group: str
|
|
33
|
+
location: str
|
|
34
|
+
raw: Mapping[str, Any] = field(default_factory=dict, compare=False, repr=False)
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def from_json(cls, data: Mapping[str, Any]) -> AutomationAccount:
|
|
38
|
+
resource_id = str(data.get("id") or "")
|
|
39
|
+
parts = resource_id.split("/")
|
|
40
|
+
lowered = [part.lower() for part in parts]
|
|
41
|
+
|
|
42
|
+
def after(segment: str) -> str:
|
|
43
|
+
return parts[lowered.index(segment) + 1] if segment in lowered[:-1] else ""
|
|
44
|
+
|
|
45
|
+
return cls(
|
|
46
|
+
id=resource_id,
|
|
47
|
+
name=str(data.get("name") or ""),
|
|
48
|
+
subscription_id=after("subscriptions"),
|
|
49
|
+
resource_group=after("resourcegroups"),
|
|
50
|
+
location=str(data.get("location") or ""),
|
|
51
|
+
raw=dict(data),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def path(self) -> str:
|
|
56
|
+
"""The account's ARM path, which every job request starts from."""
|
|
57
|
+
return self.id
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class Job:
|
|
62
|
+
"""One run of a runbook."""
|
|
63
|
+
|
|
64
|
+
id: str
|
|
65
|
+
runbook: str
|
|
66
|
+
status: str
|
|
67
|
+
created: datetime | None
|
|
68
|
+
started: datetime | None
|
|
69
|
+
ended: datetime | None
|
|
70
|
+
run_on: str
|
|
71
|
+
started_by: str
|
|
72
|
+
exception: str
|
|
73
|
+
raw: Mapping[str, Any] = field(default_factory=dict, compare=False, repr=False)
|
|
74
|
+
|
|
75
|
+
@classmethod
|
|
76
|
+
def from_json(cls, data: Mapping[str, Any]) -> Job:
|
|
77
|
+
properties = data.get("properties")
|
|
78
|
+
props: Mapping[str, Any] = properties if isinstance(properties, Mapping) else {}
|
|
79
|
+
runbook = props.get("runbook")
|
|
80
|
+
return cls(
|
|
81
|
+
# The job's resource name is its id: the one the portal shows.
|
|
82
|
+
id=str(data.get("name") or props.get("jobId") or ""),
|
|
83
|
+
runbook=str(runbook.get("name") or "") if isinstance(runbook, Mapping) else "",
|
|
84
|
+
status=str(props.get("status") or ""),
|
|
85
|
+
created=parse_datetime(props.get("creationTime")),
|
|
86
|
+
started=parse_datetime(props.get("startTime")),
|
|
87
|
+
ended=parse_datetime(props.get("endTime")),
|
|
88
|
+
# Empty for Azure's own sandboxes; a Hybrid Runbook Worker group's name otherwise.
|
|
89
|
+
run_on=str(props.get("runOn") or ""),
|
|
90
|
+
started_by=str(props.get("startedBy") or ""),
|
|
91
|
+
exception=str(props.get("exception") or ""),
|
|
92
|
+
raw=dict(data),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def failed(self) -> bool:
|
|
97
|
+
return self.status in FAILED
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def finished(self) -> bool:
|
|
101
|
+
return self.ended is not None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass(frozen=True)
|
|
105
|
+
class JobStream:
|
|
106
|
+
"""One record a job wrote: a line of output, a warning, an error and so on."""
|
|
107
|
+
|
|
108
|
+
id: str
|
|
109
|
+
time: datetime | None
|
|
110
|
+
stream: str
|
|
111
|
+
summary: str
|
|
112
|
+
text: str
|
|
113
|
+
raw: Mapping[str, Any] = field(default_factory=dict, compare=False, repr=False)
|
|
114
|
+
|
|
115
|
+
@classmethod
|
|
116
|
+
def from_json(cls, data: Mapping[str, Any]) -> JobStream:
|
|
117
|
+
properties = data.get("properties")
|
|
118
|
+
props: Mapping[str, Any] = properties if isinstance(properties, Mapping) else {}
|
|
119
|
+
return cls(
|
|
120
|
+
id=str(props.get("jobStreamId") or ""),
|
|
121
|
+
time=parse_datetime(props.get("time")),
|
|
122
|
+
stream=str(props.get("streamType") or ""),
|
|
123
|
+
summary=str(props.get("summary") or ""),
|
|
124
|
+
# Only a single stream's GET carries the full text; a listing has the summary.
|
|
125
|
+
text=str(props.get("streamText") or ""),
|
|
126
|
+
raw=dict(data),
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def message(self) -> str:
|
|
131
|
+
return self.text or self.summary
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Azure CLI context: accounts, sign-in and switching the active account between profiles.
|
|
2
|
+
|
|
3
|
+
Tokens from the Azure CLI come from ``core.auth.AzureCliCredential``, not from here.
|
|
4
|
+
|
|
5
|
+
Public API::
|
|
6
|
+
|
|
7
|
+
from libre_devops_helpers.microsoft.azcli import AzCli, switch_profile
|
|
8
|
+
|
|
9
|
+
result = switch_profile(AzCli(), profile)
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from libre_devops_helpers.microsoft.azcli.client import Account, AzCli
|
|
13
|
+
from libre_devops_helpers.microsoft.azcli.context import (
|
|
14
|
+
SwitchResult,
|
|
15
|
+
find_account,
|
|
16
|
+
match_profile,
|
|
17
|
+
switch_profile,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"Account",
|
|
22
|
+
"AzCli",
|
|
23
|
+
"SwitchResult",
|
|
24
|
+
"find_account",
|
|
25
|
+
"match_profile",
|
|
26
|
+
"switch_profile",
|
|
27
|
+
]
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Azure CLI accounts and sign-in.
|
|
2
|
+
|
|
3
|
+
The subprocess handling lives in ``core.process``; tokens come from
|
|
4
|
+
``core.auth.AzureCliCredential``. This module is only about which accounts the CLI
|
|
5
|
+
knows and which one is active.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Mapping
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from libre_devops_helpers.microsoft.process import AzCliError, AzureCliRunner, signed_out
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class Account:
|
|
19
|
+
"""One entry from ``az account list``: a subscription, or a tenant-level sign-in."""
|
|
20
|
+
|
|
21
|
+
id: str
|
|
22
|
+
name: str
|
|
23
|
+
tenant_id: str
|
|
24
|
+
state: str
|
|
25
|
+
is_default: bool
|
|
26
|
+
user: str
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def tenant_level(self) -> bool:
|
|
30
|
+
"""True for the entry ``az login --allow-no-subscriptions`` records for a tenant."""
|
|
31
|
+
return self.id == self.tenant_id
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
def from_json(cls, data: Mapping[str, Any]) -> Account:
|
|
35
|
+
user = data.get("user")
|
|
36
|
+
return cls(
|
|
37
|
+
id=str(data.get("id", "")).lower(),
|
|
38
|
+
name=str(data.get("name", "")),
|
|
39
|
+
tenant_id=str(data.get("tenantId", "")).lower(),
|
|
40
|
+
state=str(data.get("state", "")),
|
|
41
|
+
is_default=bool(data.get("isDefault", False)),
|
|
42
|
+
user=str(user.get("name", "")) if isinstance(user, Mapping) else "",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class AzCli:
|
|
47
|
+
"""The Azure CLI's accounts: list them, read the active one, switch, sign in."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, runner: AzureCliRunner | None = None) -> None:
|
|
50
|
+
self.runner = runner or AzureCliRunner()
|
|
51
|
+
|
|
52
|
+
def current_account(self) -> Account | None:
|
|
53
|
+
"""The active account, or None when the CLI is not signed in."""
|
|
54
|
+
try:
|
|
55
|
+
data = self.runner.run_json("account", "show")
|
|
56
|
+
except AzCliError as exc:
|
|
57
|
+
if signed_out(str(exc)):
|
|
58
|
+
return None
|
|
59
|
+
raise
|
|
60
|
+
return Account.from_json(data) if isinstance(data, dict) else None
|
|
61
|
+
|
|
62
|
+
def accounts(self) -> list[Account]:
|
|
63
|
+
"""Every account the CLI knows, including disabled subscriptions."""
|
|
64
|
+
data = self.runner.run_json("account", "list", "--all")
|
|
65
|
+
return [Account.from_json(item) for item in data or [] if isinstance(item, dict)]
|
|
66
|
+
|
|
67
|
+
def set_account(self, subscription: str) -> None:
|
|
68
|
+
"""Make ``subscription`` (an id or tenant-level entry) the active account."""
|
|
69
|
+
self.runner.run("account", "set", "--subscription", subscription, "--only-show-errors")
|
|
70
|
+
|
|
71
|
+
def login(
|
|
72
|
+
self, tenant_id: str, *, device_code: bool = False, allow_no_subscriptions: bool = False
|
|
73
|
+
) -> None:
|
|
74
|
+
"""Sign in to ``tenant_id`` interactively (browser, or device code)."""
|
|
75
|
+
args = ["login", "--tenant", tenant_id, "--output", "none"]
|
|
76
|
+
if device_code:
|
|
77
|
+
args.append("--use-device-code")
|
|
78
|
+
if allow_no_subscriptions:
|
|
79
|
+
args.append("--allow-no-subscriptions")
|
|
80
|
+
# The account is chosen afterwards by 'az account set'; skip the CLI's own picker.
|
|
81
|
+
self.runner.run(*args, interactive=True, env={"AZURE_CORE_LOGIN_EXPERIENCE_V2": "off"})
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Switch the Azure CLI's active account between configured profiles.
|
|
2
|
+
|
|
3
|
+
The Azure CLI keeps one active account per user (in ``~/.azure``), so a switch
|
|
4
|
+
applies to every shell at once. The Entra and XDR helpers do not rely on it:
|
|
5
|
+
they request tokens with an explicit ``--tenant``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Iterable
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
from libre_devops_helpers.microsoft.azcli.client import Account, AzCli
|
|
14
|
+
from libre_devops_helpers.microsoft.config import Profile
|
|
15
|
+
from libre_devops_helpers.microsoft.process import AzCliError
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class SwitchResult:
|
|
20
|
+
"""What ``switch_profile`` did."""
|
|
21
|
+
|
|
22
|
+
profile: Profile
|
|
23
|
+
account: Account
|
|
24
|
+
signed_in: bool
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def find_account(accounts: Iterable[Account], profile: Profile) -> Account | None:
|
|
28
|
+
"""The account that satisfies ``profile``, or None when the CLI has none.
|
|
29
|
+
|
|
30
|
+
A subscription profile needs that exact subscription in its tenant. A tenant
|
|
31
|
+
profile takes any account in the tenant, preferring the one already active
|
|
32
|
+
(so switching does not needlessly move off a subscription), then the
|
|
33
|
+
tenant-level entry, then any enabled subscription.
|
|
34
|
+
"""
|
|
35
|
+
in_tenant = [account for account in accounts if account.tenant_id == profile.tenant_id]
|
|
36
|
+
if profile.subscription_id:
|
|
37
|
+
return next((a for a in in_tenant if a.id == profile.subscription_id), None)
|
|
38
|
+
for preferred in (
|
|
39
|
+
lambda a: a.is_default,
|
|
40
|
+
lambda a: a.tenant_level,
|
|
41
|
+
lambda a: a.state == "Enabled",
|
|
42
|
+
):
|
|
43
|
+
match = next((a for a in in_tenant if preferred(a)), None)
|
|
44
|
+
if match is not None:
|
|
45
|
+
return match
|
|
46
|
+
return in_tenant[0] if in_tenant else None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def match_profile(profiles: Iterable[Profile], account: Account) -> Profile | None:
|
|
50
|
+
"""The profile describing ``account``: a subscription match first, then a tenant match."""
|
|
51
|
+
candidates = list(profiles)
|
|
52
|
+
for profile in candidates:
|
|
53
|
+
if profile.subscription_id == account.id and profile.tenant_id == account.tenant_id:
|
|
54
|
+
return profile
|
|
55
|
+
for profile in candidates:
|
|
56
|
+
if profile.subscription_id is None and profile.tenant_id == account.tenant_id:
|
|
57
|
+
return profile
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def switch_profile(
|
|
62
|
+
az: AzCli, profile: Profile, *, sign_in: bool = True, device_code: bool = False
|
|
63
|
+
) -> SwitchResult:
|
|
64
|
+
"""Make ``profile`` the Azure CLI's active context, signing in first when needed.
|
|
65
|
+
|
|
66
|
+
With ``sign_in=False`` a missing session raises instead of prompting, which
|
|
67
|
+
suits scripts. The switch is read back and verified before returning.
|
|
68
|
+
"""
|
|
69
|
+
profile.require_real_ids()
|
|
70
|
+
account = find_account(az.accounts(), profile)
|
|
71
|
+
signed_in = False
|
|
72
|
+
if account is None:
|
|
73
|
+
if not sign_in:
|
|
74
|
+
raise AzCliError(
|
|
75
|
+
f"the Azure CLI has no session for profile {profile.name!r}",
|
|
76
|
+
hint=f"sign in with: az login --tenant {profile.tenant_id}",
|
|
77
|
+
)
|
|
78
|
+
az.login(
|
|
79
|
+
profile.tenant_id,
|
|
80
|
+
device_code=device_code,
|
|
81
|
+
allow_no_subscriptions=profile.subscription_id is None,
|
|
82
|
+
)
|
|
83
|
+
signed_in = True
|
|
84
|
+
account = find_account(az.accounts(), profile)
|
|
85
|
+
if account is None:
|
|
86
|
+
raise AzCliError(
|
|
87
|
+
f"signed in to tenant {profile.tenant_id}, but subscription "
|
|
88
|
+
f"{profile.subscription_id} is not visible to that account"
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
az.set_account(account.id)
|
|
92
|
+
current = az.current_account()
|
|
93
|
+
if current is None or current.id != account.id:
|
|
94
|
+
raise AzCliError(f"the Azure CLI did not switch to {account.id}")
|
|
95
|
+
return SwitchResult(profile=profile, account=current, signed_in=signed_in)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Azure Resource Manager: subscriptions, Resource Graph, RBAC and Defender for Cloud.
|
|
2
|
+
|
|
3
|
+
Depends only on ``core`` and the shared Microsoft layer. Access rests on Azure RBAC, so
|
|
4
|
+
there are no token scope requirements to declare. Public API::
|
|
5
|
+
|
|
6
|
+
from libre_devops_helpers.microsoft.azure import AzureClient
|
|
7
|
+
|
|
8
|
+
with AzureClient.for_profile(profile, tokens) as azure:
|
|
9
|
+
result = azure.resource_graph("resources | summarize count() by type")
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from libre_devops_helpers.microsoft.azure.client import AzureClient
|
|
13
|
+
from libre_devops_helpers.microsoft.azure.models import (
|
|
14
|
+
Assessment,
|
|
15
|
+
AzureRoleAssignment,
|
|
16
|
+
DefenderPlan,
|
|
17
|
+
SecureScore,
|
|
18
|
+
SecureScoreControl,
|
|
19
|
+
Subscription,
|
|
20
|
+
)
|
|
21
|
+
from libre_devops_helpers.microsoft.resources import Requirement
|
|
22
|
+
|
|
23
|
+
REQUIREMENTS: tuple[Requirement, ...] = ()
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"REQUIREMENTS",
|
|
27
|
+
"Assessment",
|
|
28
|
+
"AzureClient",
|
|
29
|
+
"AzureRoleAssignment",
|
|
30
|
+
"DefenderPlan",
|
|
31
|
+
"SecureScore",
|
|
32
|
+
"SecureScoreControl",
|
|
33
|
+
"Subscription",
|
|
34
|
+
]
|