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,23 @@
|
|
|
1
|
+
"""Libre DevOps Helpers: helpers and a CLI for Azure, Entra, Defender and more.
|
|
2
|
+
|
|
3
|
+
Subpackages, lowest layer first, in the style of the LibreDevOpsHelpers nested
|
|
4
|
+
PowerShell modules:
|
|
5
|
+
|
|
6
|
+
- ``core`` everything shared: errors, config and clouds, credentials, the az
|
|
7
|
+
runner, HTTP client, token checks, polling, inputs, logging
|
|
8
|
+
- ``azcli`` Azure CLI accounts, sign-in and switching between profiles
|
|
9
|
+
- ``entra`` Entra ID: devices, users, groups, roles, sign-ins, apps, policies
|
|
10
|
+
- ``xdr`` Defender for Endpoint: machines, alerts, vulnerabilities, hunting
|
|
11
|
+
- ``intune`` Intune managed devices and compliance
|
|
12
|
+
- ``azure`` Azure Resource Manager: Resource Graph, RBAC, Defender for Cloud
|
|
13
|
+
- ``keyvault`` Key Vault secret, certificate and key metadata
|
|
14
|
+
- ``loganalytics`` KQL against Log Analytics workspaces
|
|
15
|
+
- ``devices`` devices across Entra, Defender and Intune: check, watch, show
|
|
16
|
+
- ``cli`` the ``ldo`` command, a thin layer over the packages above
|
|
17
|
+
|
|
18
|
+
Feature modules depend on ``core`` only; ``devices`` also uses ``entra``, ``xdr`` and
|
|
19
|
+
``intune``. Every client takes any token provider, so where tokens come from is the
|
|
20
|
+
caller's choice.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
__version__ = "0.4.1"
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""The ldo root command.
|
|
2
|
+
|
|
3
|
+
Commands parse arguments and render results; the work happens in the importable
|
|
4
|
+
subpackages. Library errors are turned into a message and an exit code here,
|
|
5
|
+
and only here.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Annotated
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
|
|
14
|
+
from libre_devops_helpers import __version__
|
|
15
|
+
from libre_devops_helpers.cli import render
|
|
16
|
+
from libre_devops_helpers.cli.commands import (
|
|
17
|
+
automation,
|
|
18
|
+
az,
|
|
19
|
+
azure,
|
|
20
|
+
config,
|
|
21
|
+
devices,
|
|
22
|
+
entra,
|
|
23
|
+
graph,
|
|
24
|
+
incidents,
|
|
25
|
+
intune,
|
|
26
|
+
keyvault,
|
|
27
|
+
logicapp,
|
|
28
|
+
logs,
|
|
29
|
+
pim,
|
|
30
|
+
pretty,
|
|
31
|
+
profiles,
|
|
32
|
+
snow,
|
|
33
|
+
token,
|
|
34
|
+
welcome,
|
|
35
|
+
xdr,
|
|
36
|
+
)
|
|
37
|
+
from libre_devops_helpers.cli.runtime import Runtime
|
|
38
|
+
from libre_devops_helpers.core import brand
|
|
39
|
+
from libre_devops_helpers.core.errors import LdoError
|
|
40
|
+
from libre_devops_helpers.core.log import LOG_FORMATS, configure_logging, normalise_format
|
|
41
|
+
|
|
42
|
+
app = typer.Typer(
|
|
43
|
+
rich_markup_mode="markdown",
|
|
44
|
+
name=brand.COMMAND,
|
|
45
|
+
help=f"{brand.DISPLAY_NAME}: fast, read-only helpers for Entra ID, Defender XDR, Intune, "
|
|
46
|
+
"Azure, Graph, PIM, Logic Apps and ServiceNow. Signs in as you. "
|
|
47
|
+
f"Docs: {brand.docs('README')}",
|
|
48
|
+
pretty_exceptions_enable=False,
|
|
49
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _show_version(value: bool) -> None:
|
|
54
|
+
if value:
|
|
55
|
+
typer.echo(f"{brand.COMMAND} {__version__}")
|
|
56
|
+
raise typer.Exit()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@app.callback(invoke_without_command=True)
|
|
60
|
+
def _root(
|
|
61
|
+
ctx: typer.Context,
|
|
62
|
+
config_path: Annotated[
|
|
63
|
+
Path | None,
|
|
64
|
+
typer.Option(
|
|
65
|
+
"--config",
|
|
66
|
+
help=f"Config file. Default: ${brand.CONFIG_ENV}, "
|
|
67
|
+
f"else ~/.config/{brand.CONFIG_DIR}/config.toml.",
|
|
68
|
+
dir_okay=False,
|
|
69
|
+
show_default=False,
|
|
70
|
+
),
|
|
71
|
+
] = None,
|
|
72
|
+
verbose: Annotated[
|
|
73
|
+
int, typer.Option("--verbose", "-v", count=True, help="-v info, -vv debug (stderr).")
|
|
74
|
+
] = 0,
|
|
75
|
+
log_format: Annotated[
|
|
76
|
+
str,
|
|
77
|
+
typer.Option(
|
|
78
|
+
"--log-format",
|
|
79
|
+
envvar=brand.LOG_FORMAT_ENV,
|
|
80
|
+
help=f"Log line format on stderr: {', '.join(LOG_FORMATS)} (OTLP/JSON).",
|
|
81
|
+
),
|
|
82
|
+
] = "text",
|
|
83
|
+
log_level: Annotated[
|
|
84
|
+
str | None,
|
|
85
|
+
typer.Option(
|
|
86
|
+
"--log-level",
|
|
87
|
+
envvar=brand.LOG_LEVEL_ENV,
|
|
88
|
+
help="Minimum log level (trace, debug, info, warn, error, fatal). -v and -vv win.",
|
|
89
|
+
show_default=False,
|
|
90
|
+
),
|
|
91
|
+
] = None,
|
|
92
|
+
version: Annotated[
|
|
93
|
+
bool,
|
|
94
|
+
typer.Option(
|
|
95
|
+
"--version", callback=_show_version, is_eager=True, help="Show the version and exit."
|
|
96
|
+
),
|
|
97
|
+
] = False,
|
|
98
|
+
) -> None:
|
|
99
|
+
configure_logging(verbose, log_format, log_level)
|
|
100
|
+
render.structured_output(normalise_format(log_format) != "text")
|
|
101
|
+
# Tests pass a prepared Runtime as obj; a real run builds one here.
|
|
102
|
+
if not isinstance(ctx.obj, Runtime):
|
|
103
|
+
ctx.obj = Runtime(config_path=config_path)
|
|
104
|
+
elif config_path is not None:
|
|
105
|
+
ctx.obj.config_path = config_path
|
|
106
|
+
ctx.call_on_close(ctx.obj.close)
|
|
107
|
+
if ctx.invoked_subcommand is None:
|
|
108
|
+
# Bare 'ldo': a greeting, then the help.
|
|
109
|
+
render.banner()
|
|
110
|
+
typer.echo(ctx.get_help())
|
|
111
|
+
raise typer.Exit()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
for _module in (
|
|
115
|
+
welcome,
|
|
116
|
+
config,
|
|
117
|
+
profiles,
|
|
118
|
+
az,
|
|
119
|
+
entra,
|
|
120
|
+
graph,
|
|
121
|
+
xdr,
|
|
122
|
+
intune,
|
|
123
|
+
azure,
|
|
124
|
+
keyvault,
|
|
125
|
+
logs,
|
|
126
|
+
logicapp,
|
|
127
|
+
pim,
|
|
128
|
+
devices,
|
|
129
|
+
snow,
|
|
130
|
+
pretty,
|
|
131
|
+
):
|
|
132
|
+
_module.register(app)
|
|
133
|
+
# Token commands are about Entra-issued tokens, so they live in the entra group.
|
|
134
|
+
token.register(entra.entra_app)
|
|
135
|
+
# Incidents are Defender XDR's (Sentinel's included), so they live in the xdr group.
|
|
136
|
+
incidents.register(xdr.xdr_app)
|
|
137
|
+
# Automation accounts are Azure resources, so they live in the azure group.
|
|
138
|
+
automation.register(azure.azure_app)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def main() -> None:
|
|
142
|
+
"""Console entry point: run the app and report library errors cleanly."""
|
|
143
|
+
try:
|
|
144
|
+
app()
|
|
145
|
+
except LdoError as exc:
|
|
146
|
+
render.error(str(exc), exc.hint)
|
|
147
|
+
sys.exit(exc.exit_code)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CLI commands, one module per area. Each module exposes ``register(app)``."""
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
"""Azure Automation commands: accounts, runbook jobs, and a job's logs and output.
|
|
2
|
+
|
|
3
|
+
An account is named by its name (found across the subscriptions in scope, narrowed by
|
|
4
|
+
-g) or by its resource id. A job is named by its id; without one, 'logs' and 'output'
|
|
5
|
+
take the newest job, of --runbook when given. Everything reads.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from datetime import UTC, datetime
|
|
9
|
+
from typing import Annotated, Any
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
|
|
13
|
+
from libre_devops_helpers.cli import render
|
|
14
|
+
from libre_devops_helpers.cli.exits import ATTENTION
|
|
15
|
+
from libre_devops_helpers.cli.options import OutputOption, ProfileOption, duration, get_runtime
|
|
16
|
+
from libre_devops_helpers.cli.render import Output
|
|
17
|
+
from libre_devops_helpers.cli.runtime import MicrosoftRuntime
|
|
18
|
+
from libre_devops_helpers.core.errors import InputError, NotFoundError
|
|
19
|
+
from libre_devops_helpers.core.util import format_duration
|
|
20
|
+
from libre_devops_helpers.microsoft.automation import (
|
|
21
|
+
STREAMS,
|
|
22
|
+
AutomationAccount,
|
|
23
|
+
AutomationClient,
|
|
24
|
+
Job,
|
|
25
|
+
JobStream,
|
|
26
|
+
)
|
|
27
|
+
from libre_devops_helpers.microsoft.config import Profile
|
|
28
|
+
|
|
29
|
+
automation_app = typer.Typer(
|
|
30
|
+
rich_markup_mode="markdown",
|
|
31
|
+
name="automation",
|
|
32
|
+
help="Azure Automation: accounts, runbook jobs, and each job's logs and output.",
|
|
33
|
+
no_args_is_help=True,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
_STATUS_COLOURS = {"Completed": "green", "Running": "cyan", "Failed": "red", "Suspended": "red"}
|
|
37
|
+
_STREAM_COLOURS = {"Error": "red", "Warning": "yellow", "Verbose": "bright_black"}
|
|
38
|
+
|
|
39
|
+
AccountArgument = Annotated[
|
|
40
|
+
str, typer.Argument(metavar="ACCOUNT", help="The Automation account: its name or resource id.")
|
|
41
|
+
]
|
|
42
|
+
GroupOption = Annotated[
|
|
43
|
+
str | None,
|
|
44
|
+
typer.Option("--resource-group", "-g", help="The account's resource group, when names repeat."),
|
|
45
|
+
]
|
|
46
|
+
SubscriptionOption = Annotated[
|
|
47
|
+
list[str] | None,
|
|
48
|
+
typer.Option(
|
|
49
|
+
"--subscription",
|
|
50
|
+
"-s",
|
|
51
|
+
help="Subscription id to search. Repeatable. Default: the profile's subscription, "
|
|
52
|
+
"else every one in the tenant.",
|
|
53
|
+
show_default=False,
|
|
54
|
+
),
|
|
55
|
+
]
|
|
56
|
+
RunbookOption = Annotated[
|
|
57
|
+
str | None, typer.Option("--runbook", "-r", help="Only this runbook's jobs.")
|
|
58
|
+
]
|
|
59
|
+
JobArgument = Annotated[
|
|
60
|
+
str | None,
|
|
61
|
+
typer.Argument(
|
|
62
|
+
metavar="[JOB]",
|
|
63
|
+
help="The job id. Default: the newest job (of --runbook).",
|
|
64
|
+
show_default=False,
|
|
65
|
+
),
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def register(app: typer.Typer) -> None:
|
|
70
|
+
app.add_typer(automation_app)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _scope(runtime: MicrosoftRuntime, profile: Profile, given: list[str] | None) -> list[str]:
|
|
74
|
+
if given:
|
|
75
|
+
return given
|
|
76
|
+
if profile.subscription_id:
|
|
77
|
+
return [profile.subscription_id]
|
|
78
|
+
return runtime.subscription_ids(profile)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _account(
|
|
82
|
+
runtime: MicrosoftRuntime,
|
|
83
|
+
profile: Profile,
|
|
84
|
+
ref: str,
|
|
85
|
+
group: str | None,
|
|
86
|
+
subscriptions: list[str] | None,
|
|
87
|
+
) -> tuple[AutomationClient, AutomationAccount]:
|
|
88
|
+
client = runtime.automation(profile)
|
|
89
|
+
# A resource id needs no search, so the subscriptions are only listed for a name.
|
|
90
|
+
scope = [] if ref.strip().startswith("/") else _scope(runtime, profile, subscriptions)
|
|
91
|
+
return client, client.find_account(ref, scope, resource_group=group)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@automation_app.command("accounts")
|
|
95
|
+
def accounts(
|
|
96
|
+
ctx: typer.Context,
|
|
97
|
+
subscription: SubscriptionOption = None,
|
|
98
|
+
profile: ProfileOption = None,
|
|
99
|
+
output: OutputOption = Output.TABLE,
|
|
100
|
+
) -> None:
|
|
101
|
+
"""List the Automation accounts in the subscriptions in scope."""
|
|
102
|
+
runtime = get_runtime(ctx).microsoft
|
|
103
|
+
selected = runtime.profile(profile)
|
|
104
|
+
found = runtime.automation(selected).accounts(_scope(runtime, selected, subscription))
|
|
105
|
+
render.emit(
|
|
106
|
+
output,
|
|
107
|
+
["ACCOUNT", "RESOURCE GROUP", "LOCATION", "SUBSCRIPTION"],
|
|
108
|
+
[[item.name, item.resource_group, item.location, item.subscription_id] for item in found],
|
|
109
|
+
[dict(item.raw) for item in found],
|
|
110
|
+
)
|
|
111
|
+
render.note(f"{len(found)} account(s)")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@automation_app.command("jobs")
|
|
115
|
+
def jobs(
|
|
116
|
+
ctx: typer.Context,
|
|
117
|
+
account: AccountArgument,
|
|
118
|
+
runbook: RunbookOption = None,
|
|
119
|
+
status: Annotated[
|
|
120
|
+
list[str] | None,
|
|
121
|
+
typer.Option(
|
|
122
|
+
"--status",
|
|
123
|
+
help="Only jobs in this state, e.g. failed, completed, running. Repeatable.",
|
|
124
|
+
),
|
|
125
|
+
] = None,
|
|
126
|
+
failed: Annotated[
|
|
127
|
+
bool, typer.Option("--failed", help="Only jobs that failed, were suspended or stopped.")
|
|
128
|
+
] = False,
|
|
129
|
+
since: Annotated[
|
|
130
|
+
str | None, typer.Option("--since", help="Only jobs created in this window, e.g. 24h, 7d.")
|
|
131
|
+
] = None,
|
|
132
|
+
limit: Annotated[int, typer.Option("--limit", "-n", min=1, help="How many to show.")] = 20,
|
|
133
|
+
resource_group: GroupOption = None,
|
|
134
|
+
subscription: SubscriptionOption = None,
|
|
135
|
+
profile: ProfileOption = None,
|
|
136
|
+
output: OutputOption = Output.TABLE,
|
|
137
|
+
) -> None:
|
|
138
|
+
"""List a runbook's recent jobs (runs), newest first: when, how long, and how each ended.
|
|
139
|
+
|
|
140
|
+
Jobs are kept for 30 days. Exits 3 when any job shown failed, was suspended or stopped.
|
|
141
|
+
"""
|
|
142
|
+
runtime = get_runtime(ctx).microsoft
|
|
143
|
+
client, found = _account(
|
|
144
|
+
runtime, runtime.profile(profile), account, resource_group, subscription
|
|
145
|
+
)
|
|
146
|
+
window = duration(since)
|
|
147
|
+
wanted = {item.casefold() for item in status or ()}
|
|
148
|
+
now = datetime.now(UTC)
|
|
149
|
+
shown = [
|
|
150
|
+
job
|
|
151
|
+
for job in client.jobs(found)
|
|
152
|
+
if (runbook is None or job.runbook.casefold() == runbook.casefold())
|
|
153
|
+
and (not wanted or job.status.casefold() in wanted)
|
|
154
|
+
and (not failed or job.failed)
|
|
155
|
+
and (window is None or (job.created is not None and now - job.created <= window))
|
|
156
|
+
][:limit]
|
|
157
|
+
render.emit(
|
|
158
|
+
output,
|
|
159
|
+
["JOB", "RUNBOOK", "STATUS", "STARTED", "TOOK", "RUN ON"],
|
|
160
|
+
[_job_row(job, now) for job in shown],
|
|
161
|
+
[dict(job.raw) for job in shown],
|
|
162
|
+
)
|
|
163
|
+
failures = sum(1 for job in shown if job.failed)
|
|
164
|
+
note = f"{len(shown)} job(s) in {found.name}"
|
|
165
|
+
if failures:
|
|
166
|
+
note += f", {failures} failed"
|
|
167
|
+
render.note(note)
|
|
168
|
+
if failures:
|
|
169
|
+
raise typer.Exit(ATTENTION)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@automation_app.command("logs")
|
|
173
|
+
def logs(
|
|
174
|
+
ctx: typer.Context,
|
|
175
|
+
account: AccountArgument,
|
|
176
|
+
job: JobArgument = None,
|
|
177
|
+
runbook: RunbookOption = None,
|
|
178
|
+
stream: Annotated[
|
|
179
|
+
list[str] | None,
|
|
180
|
+
typer.Option(
|
|
181
|
+
"--stream",
|
|
182
|
+
help=f"Only these streams: {', '.join(STREAMS)}. Repeatable. Default: all.",
|
|
183
|
+
),
|
|
184
|
+
] = None,
|
|
185
|
+
full: Annotated[
|
|
186
|
+
bool,
|
|
187
|
+
typer.Option("--full", help="Read each record in full, not its summary (a call each)."),
|
|
188
|
+
] = False,
|
|
189
|
+
resource_group: GroupOption = None,
|
|
190
|
+
subscription: SubscriptionOption = None,
|
|
191
|
+
profile: ProfileOption = None,
|
|
192
|
+
output: OutputOption = Output.TABLE,
|
|
193
|
+
) -> None:
|
|
194
|
+
"""A job's logs, oldest first: its output, warnings, errors and other streams.
|
|
195
|
+
|
|
196
|
+
Without a job id, the newest job, of --runbook when given. Verbose, progress and debug
|
|
197
|
+
records appear only when the runbook turns them on. Exits 3 when the job failed.
|
|
198
|
+
"""
|
|
199
|
+
kinds = _stream_kinds(stream)
|
|
200
|
+
runtime = get_runtime(ctx).microsoft
|
|
201
|
+
client, found = _account(
|
|
202
|
+
runtime, runtime.profile(profile), account, resource_group, subscription
|
|
203
|
+
)
|
|
204
|
+
chosen = client.job(found, job or _newest(client, found, runbook).id)
|
|
205
|
+
records = [
|
|
206
|
+
item for item in client.streams(found, chosen.id) if kinds is None or item.stream in kinds
|
|
207
|
+
]
|
|
208
|
+
if full:
|
|
209
|
+
records = [client.stream(found, chosen.id, item.id) for item in records]
|
|
210
|
+
if output is Output.TABLE:
|
|
211
|
+
render.echo(render.pairs(_job_pairs(found, chosen)))
|
|
212
|
+
render.echo()
|
|
213
|
+
render.emit(
|
|
214
|
+
output,
|
|
215
|
+
["TIME", "STREAM", "MESSAGE"],
|
|
216
|
+
[
|
|
217
|
+
[
|
|
218
|
+
render.when(item.time),
|
|
219
|
+
(item.stream, _STREAM_COLOURS.get(item.stream)),
|
|
220
|
+
item.message,
|
|
221
|
+
]
|
|
222
|
+
for item in records
|
|
223
|
+
],
|
|
224
|
+
{
|
|
225
|
+
"job": dict(chosen.raw),
|
|
226
|
+
"streams": [_stream_record(item) for item in records],
|
|
227
|
+
},
|
|
228
|
+
)
|
|
229
|
+
render.note(f"{len(records)} record(s)")
|
|
230
|
+
if chosen.failed:
|
|
231
|
+
raise typer.Exit(ATTENTION)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
@automation_app.command("output")
|
|
235
|
+
def output_text(
|
|
236
|
+
ctx: typer.Context,
|
|
237
|
+
account: AccountArgument,
|
|
238
|
+
job: JobArgument = None,
|
|
239
|
+
runbook: RunbookOption = None,
|
|
240
|
+
resource_group: GroupOption = None,
|
|
241
|
+
subscription: SubscriptionOption = None,
|
|
242
|
+
profile: ProfileOption = None,
|
|
243
|
+
) -> None:
|
|
244
|
+
"""A job's output as plain text, as the portal's Output tab shows it, for piping.
|
|
245
|
+
|
|
246
|
+
Without a job id, the newest job, of --runbook when given.
|
|
247
|
+
"""
|
|
248
|
+
runtime = get_runtime(ctx).microsoft
|
|
249
|
+
client, found = _account(
|
|
250
|
+
runtime, runtime.profile(profile), account, resource_group, subscription
|
|
251
|
+
)
|
|
252
|
+
chosen = job or _newest(client, found, runbook).id
|
|
253
|
+
text = client.output(found, chosen)
|
|
254
|
+
render.echo(text.rstrip("\n"))
|
|
255
|
+
render.note(f"job {chosen} in {found.name}")
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _newest(client: AutomationClient, account: AutomationAccount, runbook: str | None) -> Job:
|
|
259
|
+
for job in client.jobs(account):
|
|
260
|
+
if runbook is None or job.runbook.casefold() == runbook.casefold():
|
|
261
|
+
return job
|
|
262
|
+
what = f"runbook {runbook!r}" if runbook else account.name
|
|
263
|
+
raise NotFoundError(f"no jobs for {what} in the last 30 days")
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _stream_kinds(given: list[str] | None) -> set[str] | None:
|
|
267
|
+
if not given:
|
|
268
|
+
return None
|
|
269
|
+
kinds = set()
|
|
270
|
+
for item in given:
|
|
271
|
+
kind = STREAMS.get(item.strip().casefold())
|
|
272
|
+
if kind is None:
|
|
273
|
+
raise InputError(f"unknown stream {item!r}", hint=f"use one of {', '.join(STREAMS)}")
|
|
274
|
+
kinds.add(kind)
|
|
275
|
+
return kinds
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _job_row(job: Job, now: datetime) -> list[render.Cell]:
|
|
279
|
+
return [
|
|
280
|
+
job.id,
|
|
281
|
+
job.runbook,
|
|
282
|
+
(job.status, "red" if job.failed else _STATUS_COLOURS.get(job.status)),
|
|
283
|
+
render.when(job.started or job.created, now=now),
|
|
284
|
+
_took(job, now),
|
|
285
|
+
job.run_on or "Azure",
|
|
286
|
+
]
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _took(job: Job, now: datetime) -> str:
|
|
290
|
+
if job.started is None:
|
|
291
|
+
return "-"
|
|
292
|
+
end = job.ended or now
|
|
293
|
+
suffix = "" if job.ended else " so far"
|
|
294
|
+
return format_duration(end - job.started) + suffix
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _job_pairs(account: AutomationAccount, job: Job) -> list[tuple[str, str]]:
|
|
298
|
+
now = datetime.now(UTC)
|
|
299
|
+
pairs = [
|
|
300
|
+
("Account", f"{account.name} ({account.resource_group})"),
|
|
301
|
+
("Runbook", job.runbook),
|
|
302
|
+
("Job", job.id),
|
|
303
|
+
("Status", job.status),
|
|
304
|
+
("Started", render.when(job.started or job.created)),
|
|
305
|
+
("Took", _took(job, now)),
|
|
306
|
+
("Run on", job.run_on or "Azure"),
|
|
307
|
+
]
|
|
308
|
+
if job.started_by:
|
|
309
|
+
pairs.append(("Started by", job.started_by))
|
|
310
|
+
if job.exception:
|
|
311
|
+
pairs.append(("Exception", job.exception))
|
|
312
|
+
return pairs
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _stream_record(item: JobStream) -> dict[str, Any]:
|
|
316
|
+
return {
|
|
317
|
+
"id": item.id,
|
|
318
|
+
"time": item.time.isoformat() if item.time else None,
|
|
319
|
+
"stream": item.stream,
|
|
320
|
+
"message": item.message,
|
|
321
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Azure CLI context commands: switch the active account to a profile, show the active one."""
|
|
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 OutputOption, complete_profile, get_runtime
|
|
9
|
+
from libre_devops_helpers.cli.render import Output
|
|
10
|
+
from libre_devops_helpers.core import brand
|
|
11
|
+
from libre_devops_helpers.microsoft.azcli import match_profile, switch_profile
|
|
12
|
+
from libre_devops_helpers.microsoft.process import AzCliError
|
|
13
|
+
|
|
14
|
+
az_app = typer.Typer(
|
|
15
|
+
rich_markup_mode="markdown",
|
|
16
|
+
help="Azure CLI context: switch profiles, show the active account.",
|
|
17
|
+
no_args_is_help=True,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def register(app: typer.Typer) -> None:
|
|
22
|
+
app.add_typer(az_app, name="az")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@az_app.command("use")
|
|
26
|
+
def use(
|
|
27
|
+
ctx: typer.Context,
|
|
28
|
+
name: Annotated[
|
|
29
|
+
str,
|
|
30
|
+
typer.Argument(help="Microsoft profile to switch to.", autocompletion=complete_profile),
|
|
31
|
+
],
|
|
32
|
+
device_code: Annotated[
|
|
33
|
+
bool, typer.Option("--device-code", help="Sign in with a device code, not a browser.")
|
|
34
|
+
] = False,
|
|
35
|
+
no_login: Annotated[
|
|
36
|
+
bool, typer.Option("--no-login", help="Fail instead of prompting when not signed in.")
|
|
37
|
+
] = False,
|
|
38
|
+
) -> None:
|
|
39
|
+
"""Switch the Azure CLI's active account to a profile, signing in when needed.
|
|
40
|
+
|
|
41
|
+
This changes the az context for every shell. The other commands do not need
|
|
42
|
+
it: they pass the profile's tenant to az explicitly.
|
|
43
|
+
"""
|
|
44
|
+
runtime = get_runtime(ctx).microsoft
|
|
45
|
+
result = switch_profile(
|
|
46
|
+
runtime.az, runtime.config().get(name), sign_in=not no_login, device_code=device_code
|
|
47
|
+
)
|
|
48
|
+
account = result.account
|
|
49
|
+
target = "tenant-level account" if account.tenant_level else f"{account.name} ({account.id})"
|
|
50
|
+
render.echo(
|
|
51
|
+
f"Switched to {name}: {target} in tenant {account.tenant_id} "
|
|
52
|
+
f"as {account.user or 'unknown user'}"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@az_app.command("whoami")
|
|
57
|
+
def whoami(ctx: typer.Context, output: OutputOption = Output.TABLE) -> None:
|
|
58
|
+
"""Show the Azure CLI's active account and the profile it matches."""
|
|
59
|
+
runtime = get_runtime(ctx).microsoft
|
|
60
|
+
account = runtime.az.current_account()
|
|
61
|
+
if account is None:
|
|
62
|
+
raise AzCliError(
|
|
63
|
+
"the Azure CLI is not signed in", hint=f"run {brand.command('az use <profile>')}"
|
|
64
|
+
)
|
|
65
|
+
config = runtime.optional_config()
|
|
66
|
+
profile = match_profile(config.profiles.values(), account) if config else None
|
|
67
|
+
data = {
|
|
68
|
+
"profile": profile.name if profile else None,
|
|
69
|
+
"tenant_id": account.tenant_id,
|
|
70
|
+
"subscription_id": None if account.tenant_level else account.id,
|
|
71
|
+
"subscription_name": None if account.tenant_level else account.name,
|
|
72
|
+
"user": account.user,
|
|
73
|
+
"state": account.state,
|
|
74
|
+
}
|
|
75
|
+
if output is not Output.TABLE:
|
|
76
|
+
render.emit(output, list(data), [[str(value or "") for value in data.values()]], data)
|
|
77
|
+
return
|
|
78
|
+
render.echo(
|
|
79
|
+
render.pairs(
|
|
80
|
+
[
|
|
81
|
+
("Profile", data["profile"] or "(no matching profile)"),
|
|
82
|
+
("Tenant", account.tenant_id),
|
|
83
|
+
(
|
|
84
|
+
"Subscription",
|
|
85
|
+
"tenant-level account"
|
|
86
|
+
if account.tenant_level
|
|
87
|
+
else f"{account.name} ({account.id})",
|
|
88
|
+
),
|
|
89
|
+
("User", account.user),
|
|
90
|
+
("State", account.state),
|
|
91
|
+
]
|
|
92
|
+
)
|
|
93
|
+
)
|