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,258 @@
|
|
|
1
|
+
"""Azure commands: subscriptions, Resource Graph, RBAC and Defender for Cloud."""
|
|
2
|
+
|
|
3
|
+
from typing import Annotated
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from libre_devops_helpers.cli import render
|
|
8
|
+
from libre_devops_helpers.cli.options import (
|
|
9
|
+
OutputOption,
|
|
10
|
+
ProfileOption,
|
|
11
|
+
QueryFileOption,
|
|
12
|
+
get_runtime,
|
|
13
|
+
read_query,
|
|
14
|
+
)
|
|
15
|
+
from libre_devops_helpers.cli.render import Output
|
|
16
|
+
from libre_devops_helpers.core.util import is_guid
|
|
17
|
+
|
|
18
|
+
azure_app = typer.Typer(
|
|
19
|
+
rich_markup_mode="markdown",
|
|
20
|
+
help="Azure: subscriptions, Resource Graph, RBAC and Defender for Cloud.",
|
|
21
|
+
no_args_is_help=True,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
SubscriptionOption = Annotated[
|
|
25
|
+
list[str] | None,
|
|
26
|
+
typer.Option(
|
|
27
|
+
"--subscription",
|
|
28
|
+
"-s",
|
|
29
|
+
help="Subscription id to cover. Repeatable. Default: the profile's subscription, "
|
|
30
|
+
"else every one in the tenant.",
|
|
31
|
+
show_default=False,
|
|
32
|
+
),
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def register(app: typer.Typer) -> None:
|
|
37
|
+
app.add_typer(azure_app, name="azure")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@azure_app.command("subscriptions")
|
|
41
|
+
def subscriptions(
|
|
42
|
+
ctx: typer.Context, profile: ProfileOption = None, output: OutputOption = Output.TABLE
|
|
43
|
+
) -> None:
|
|
44
|
+
"""List the subscriptions the profile's credential can see in its tenant."""
|
|
45
|
+
runtime = get_runtime(ctx).microsoft
|
|
46
|
+
selected = runtime.profile(profile)
|
|
47
|
+
found = runtime.azure(selected).subscriptions(selected.tenant_id)
|
|
48
|
+
render.emit(
|
|
49
|
+
output,
|
|
50
|
+
["SUBSCRIPTION", "ID", "STATE"],
|
|
51
|
+
[[item.name, item.id, item.state] for item in found],
|
|
52
|
+
[dict(item.raw) for item in found],
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@azure_app.command("resource-graph")
|
|
57
|
+
def resource_graph(
|
|
58
|
+
ctx: typer.Context,
|
|
59
|
+
query: Annotated[
|
|
60
|
+
str | None,
|
|
61
|
+
typer.Argument(
|
|
62
|
+
help="KQL query. Omit, or pass -, to read it from stdin.", show_default=False
|
|
63
|
+
),
|
|
64
|
+
] = None,
|
|
65
|
+
file: QueryFileOption = None,
|
|
66
|
+
limit: Annotated[int, typer.Option("--limit", min=1, help="Most rows to fetch.")] = 1000,
|
|
67
|
+
subscription: SubscriptionOption = None,
|
|
68
|
+
profile: ProfileOption = None,
|
|
69
|
+
output: OutputOption = Output.TABLE,
|
|
70
|
+
) -> None:
|
|
71
|
+
"""Run an Azure Resource Graph (KQL) query across subscriptions."""
|
|
72
|
+
text = read_query(query, file)
|
|
73
|
+
runtime = get_runtime(ctx).microsoft
|
|
74
|
+
selected = runtime.profile(profile)
|
|
75
|
+
scope = subscription or ([selected.subscription_id] if selected.subscription_id else [])
|
|
76
|
+
result = runtime.azure(selected).resource_graph(text, subscriptions=scope, limit=limit)
|
|
77
|
+
render.query_result(result, output)
|
|
78
|
+
render.note(f"{len(result.rows)} row(s)")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@azure_app.command("rbac")
|
|
82
|
+
def rbac(
|
|
83
|
+
ctx: typer.Context,
|
|
84
|
+
principal: Annotated[
|
|
85
|
+
str,
|
|
86
|
+
typer.Argument(help="User principal name, group or service principal name, or object id."),
|
|
87
|
+
],
|
|
88
|
+
subscription: SubscriptionOption = None,
|
|
89
|
+
profile: ProfileOption = None,
|
|
90
|
+
output: OutputOption = Output.TABLE,
|
|
91
|
+
) -> None:
|
|
92
|
+
"""List every Azure role assignment that applies to a principal, across subscriptions.
|
|
93
|
+
|
|
94
|
+
Includes assignments made to groups the principal is in, and those inherited from
|
|
95
|
+
management groups.
|
|
96
|
+
"""
|
|
97
|
+
runtime = get_runtime(ctx).microsoft
|
|
98
|
+
selected = runtime.profile(profile)
|
|
99
|
+
if is_guid(principal):
|
|
100
|
+
principal_id, label = principal.strip().lower(), principal
|
|
101
|
+
else:
|
|
102
|
+
found = runtime.entra(selected).find_principal(principal)
|
|
103
|
+
principal_id, label = found.id, f"{found.display_name} ({found.kind})"
|
|
104
|
+
subscriptions = runtime.subscription_ids(selected, subscription)
|
|
105
|
+
assignments = runtime.azure(selected).role_assignments(principal_id, subscriptions)
|
|
106
|
+
if output is Output.TABLE:
|
|
107
|
+
render.echo(render.title(f"{label} object {principal_id}"))
|
|
108
|
+
render.emit(
|
|
109
|
+
output,
|
|
110
|
+
["ROLE", "SCOPE", "ASSIGNED TO", "CONDITION"],
|
|
111
|
+
[
|
|
112
|
+
[
|
|
113
|
+
item.role_name,
|
|
114
|
+
item.scope,
|
|
115
|
+
"this principal"
|
|
116
|
+
if item.principal_id.lower() == principal_id
|
|
117
|
+
else f"{item.principal_type} {item.principal_id}",
|
|
118
|
+
"yes" if item.condition else "",
|
|
119
|
+
]
|
|
120
|
+
for item in assignments
|
|
121
|
+
],
|
|
122
|
+
[dict(item.raw) | {"roleName": item.role_name} for item in assignments],
|
|
123
|
+
)
|
|
124
|
+
render.note(f"{len(assignments)} assignment(s) across {len(subscriptions)} subscription(s)")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@azure_app.command("secure-score")
|
|
128
|
+
def secure_score(
|
|
129
|
+
ctx: typer.Context,
|
|
130
|
+
controls: Annotated[
|
|
131
|
+
bool, typer.Option("--controls", help="Also list the controls costing the most points.")
|
|
132
|
+
] = False,
|
|
133
|
+
subscription: SubscriptionOption = None,
|
|
134
|
+
profile: ProfileOption = None,
|
|
135
|
+
output: OutputOption = Output.TABLE,
|
|
136
|
+
) -> None:
|
|
137
|
+
"""Show the Defender for Cloud secure score for each subscription."""
|
|
138
|
+
runtime = get_runtime(ctx).microsoft
|
|
139
|
+
selected = runtime.profile(profile)
|
|
140
|
+
azure = runtime.azure(selected)
|
|
141
|
+
scores = [azure.secure_score(item) for item in runtime.subscription_ids(selected, subscription)]
|
|
142
|
+
found = [score for score in scores if score is not None]
|
|
143
|
+
render.emit(
|
|
144
|
+
output,
|
|
145
|
+
["SUBSCRIPTION", "SCORE", "MAX", "PERCENT"],
|
|
146
|
+
[
|
|
147
|
+
[
|
|
148
|
+
score.subscription_id,
|
|
149
|
+
_number(score.current),
|
|
150
|
+
_number(score.max),
|
|
151
|
+
"" if score.percentage is None else f"{score.percentage * 100:.0f}%",
|
|
152
|
+
]
|
|
153
|
+
for score in found
|
|
154
|
+
],
|
|
155
|
+
[dict(score.raw) for score in found],
|
|
156
|
+
)
|
|
157
|
+
if len(found) < len(scores):
|
|
158
|
+
render.warn(f"{len(scores) - len(found)} subscription(s) have no secure score")
|
|
159
|
+
if controls and output is not Output.JSON:
|
|
160
|
+
for score in found:
|
|
161
|
+
render.echo()
|
|
162
|
+
render.echo(render.title(f"controls for {score.subscription_id}"))
|
|
163
|
+
items = azure.secure_score_controls(score.subscription_id)
|
|
164
|
+
render.emit(
|
|
165
|
+
output,
|
|
166
|
+
["CONTROL", "POINTS LOST", "SCORE", "MAX", "UNHEALTHY", "HEALTHY"],
|
|
167
|
+
[
|
|
168
|
+
[
|
|
169
|
+
item.name,
|
|
170
|
+
f"{item.points_lost:.2f}",
|
|
171
|
+
_number(item.current),
|
|
172
|
+
_number(item.max),
|
|
173
|
+
str(item.unhealthy),
|
|
174
|
+
str(item.healthy),
|
|
175
|
+
]
|
|
176
|
+
for item in items
|
|
177
|
+
if item.max
|
|
178
|
+
],
|
|
179
|
+
None,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@azure_app.command("recommendations")
|
|
184
|
+
def recommendations(
|
|
185
|
+
ctx: typer.Context,
|
|
186
|
+
show_all: Annotated[
|
|
187
|
+
bool, typer.Option("--all", help="Include healthy and not applicable results.")
|
|
188
|
+
] = False,
|
|
189
|
+
severity: Annotated[
|
|
190
|
+
str | None, typer.Option("--severity", help="Only this severity: high, medium or low.")
|
|
191
|
+
] = None,
|
|
192
|
+
subscription: SubscriptionOption = None,
|
|
193
|
+
profile: ProfileOption = None,
|
|
194
|
+
output: OutputOption = Output.TABLE,
|
|
195
|
+
) -> None:
|
|
196
|
+
"""List Defender for Cloud recommendations that resources fail, most severe first."""
|
|
197
|
+
if severity is not None and severity.casefold() not in {"high", "medium", "low"}:
|
|
198
|
+
raise typer.BadParameter("--severity must be high, medium or low")
|
|
199
|
+
runtime = get_runtime(ctx).microsoft
|
|
200
|
+
selected = runtime.profile(profile)
|
|
201
|
+
azure = runtime.azure(selected)
|
|
202
|
+
found = [
|
|
203
|
+
item
|
|
204
|
+
for sub in runtime.subscription_ids(selected, subscription)
|
|
205
|
+
for item in azure.assessments(sub, unhealthy_only=not show_all)
|
|
206
|
+
if severity is None or item.severity.casefold() == severity.casefold()
|
|
207
|
+
]
|
|
208
|
+
colours = {"high": "red", "medium": "yellow"}
|
|
209
|
+
render.emit(
|
|
210
|
+
output,
|
|
211
|
+
["SEVERITY", "STATUS", "RECOMMENDATION", "RESOURCE"],
|
|
212
|
+
[
|
|
213
|
+
[
|
|
214
|
+
(item.severity, colours.get(item.severity.casefold())),
|
|
215
|
+
item.status,
|
|
216
|
+
item.name,
|
|
217
|
+
item.resource_id,
|
|
218
|
+
]
|
|
219
|
+
for item in found
|
|
220
|
+
],
|
|
221
|
+
[dict(item.raw) for item in found],
|
|
222
|
+
)
|
|
223
|
+
render.note(f"{len(found)} result(s)")
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
@azure_app.command("defender-plans")
|
|
227
|
+
def defender_plans(
|
|
228
|
+
ctx: typer.Context,
|
|
229
|
+
subscription: SubscriptionOption = None,
|
|
230
|
+
profile: ProfileOption = None,
|
|
231
|
+
output: OutputOption = Output.TABLE,
|
|
232
|
+
) -> None:
|
|
233
|
+
"""List every Defender for Cloud plan on each subscription and whether it is on."""
|
|
234
|
+
runtime = get_runtime(ctx).microsoft
|
|
235
|
+
selected = runtime.profile(profile)
|
|
236
|
+
azure = runtime.azure(selected)
|
|
237
|
+
rows: list[list[render.Cell]] = []
|
|
238
|
+
records: list[dict[str, object]] = []
|
|
239
|
+
for sub in runtime.subscription_ids(selected, subscription):
|
|
240
|
+
for plan in azure.defender_plans(sub):
|
|
241
|
+
rows.append(
|
|
242
|
+
[
|
|
243
|
+
sub,
|
|
244
|
+
plan.name,
|
|
245
|
+
("on", "green") if plan.enabled else ("off", "yellow"),
|
|
246
|
+
plan.sub_plan,
|
|
247
|
+
render.when(plan.enabled_since) if plan.enabled_since else "",
|
|
248
|
+
"deprecated" if plan.deprecated else "",
|
|
249
|
+
]
|
|
250
|
+
)
|
|
251
|
+
records.append({"subscriptionId": sub, **plan.raw})
|
|
252
|
+
render.emit(
|
|
253
|
+
output, ["SUBSCRIPTION", "PLAN", "STATE", "SUB PLAN", "SINCE", "NOTE"], rows, records
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _number(value: float | None) -> str:
|
|
258
|
+
return "" if value is None else f"{value:g}"
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Config commands: create the config file from the template, or print where it lives."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from libre_devops_helpers.cli import render
|
|
9
|
+
from libre_devops_helpers.cli.options import get_runtime
|
|
10
|
+
from libre_devops_helpers.core import brand
|
|
11
|
+
from libre_devops_helpers.core.config import CONFIG_HEADER, default_config_path
|
|
12
|
+
from libre_devops_helpers.core.errors import ConfigError
|
|
13
|
+
from libre_devops_helpers.microsoft.config import CONFIG_TEMPLATE as MICROSOFT_TEMPLATE
|
|
14
|
+
from libre_devops_helpers.servicenow.config import CONFIG_TEMPLATE as SERVICENOW_TEMPLATE
|
|
15
|
+
|
|
16
|
+
# The file 'config init' writes: shared settings, then each vendor's section.
|
|
17
|
+
TEMPLATE = "\n".join([CONFIG_HEADER, MICROSOFT_TEMPLATE, SERVICENOW_TEMPLATE])
|
|
18
|
+
|
|
19
|
+
config_app = typer.Typer(
|
|
20
|
+
rich_markup_mode="markdown", help="Create or locate the config file.", no_args_is_help=True
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def register(app: typer.Typer) -> None:
|
|
25
|
+
app.add_typer(config_app, name="config")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@config_app.command("init")
|
|
29
|
+
def init(
|
|
30
|
+
ctx: typer.Context,
|
|
31
|
+
force: Annotated[bool, typer.Option("--force", help="Overwrite an existing file.")] = False,
|
|
32
|
+
) -> None:
|
|
33
|
+
"""Write a config template, with example Microsoft profiles to fill in."""
|
|
34
|
+
path = get_runtime(ctx).config_path or default_config_path()
|
|
35
|
+
render.banner()
|
|
36
|
+
if path.exists() and not force:
|
|
37
|
+
raise ConfigError(f"{path} already exists", hint="pass --force to overwrite it")
|
|
38
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
# Created 0600, rather than written and then narrowed, so no other account can read it
|
|
40
|
+
# even for a moment. The template holds no secrets, but the ids filled in later are
|
|
41
|
+
# nobody else's business.
|
|
42
|
+
descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
43
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
|
44
|
+
handle.write(TEMPLATE)
|
|
45
|
+
if os.name == "posix":
|
|
46
|
+
path.chmod(0o600) # open keeps an existing file's mode, so --force narrows it here
|
|
47
|
+
render.echo(f"Wrote {path}")
|
|
48
|
+
render.note(f"Next: replace the placeholder ids, then run {brand.command('profiles')}.")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@config_app.command("path")
|
|
52
|
+
def path(ctx: typer.Context) -> None:
|
|
53
|
+
"""Print the path of the config file in use."""
|
|
54
|
+
render.echo(str(get_runtime(ctx).config_path or default_config_path()))
|