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