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,103 @@
|
|
|
1
|
+
"""Access tokens and token providers.
|
|
2
|
+
|
|
3
|
+
A token provider is anything with ``get_token(resource, tenant_id)``. Each vendor layer
|
|
4
|
+
supplies its own credentials (``microsoft.auth`` for Entra ID), and its service modules
|
|
5
|
+
accept any provider, so none of them depends on where a token comes from.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import threading
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from datetime import UTC, datetime, timedelta
|
|
14
|
+
from typing import Protocol
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def utc_now() -> datetime:
|
|
18
|
+
return datetime.now(UTC)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class AccessToken:
|
|
23
|
+
"""A bearer token. ``token`` is left out of repr so it cannot leak into logs."""
|
|
24
|
+
|
|
25
|
+
token: str = field(repr=False)
|
|
26
|
+
expires_on: datetime
|
|
27
|
+
tenant_id: str
|
|
28
|
+
resource: str
|
|
29
|
+
|
|
30
|
+
def expires_within(self, window: timedelta, *, now: datetime | None = None) -> bool:
|
|
31
|
+
"""True when the token expires within ``window`` of ``now``."""
|
|
32
|
+
return self.expires_on - (now or utc_now()) <= window
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class TokenProvider(Protocol):
|
|
36
|
+
"""Anything that can produce an access token for a resource in a tenant."""
|
|
37
|
+
|
|
38
|
+
def get_token(self, resource: str, tenant_id: str) -> AccessToken:
|
|
39
|
+
"""An access token for ``resource`` in ``tenant_id``."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class CachingTokenProvider:
|
|
43
|
+
"""Wraps a provider and reuses each token until it is close to expiry.
|
|
44
|
+
|
|
45
|
+
The cache is in memory only; tokens are never written to disk. It is safe to share
|
|
46
|
+
between threads, and a token is fetched once even when several threads ask at once.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
inner: TokenProvider,
|
|
52
|
+
*,
|
|
53
|
+
refresh_before: timedelta = timedelta(minutes=5),
|
|
54
|
+
clock: Callable[[], datetime] = utc_now,
|
|
55
|
+
) -> None:
|
|
56
|
+
self._inner = inner
|
|
57
|
+
self._refresh_before = refresh_before
|
|
58
|
+
self._clock = clock
|
|
59
|
+
self._cache: dict[tuple[str, str], AccessToken] = {}
|
|
60
|
+
self._lock = threading.Lock()
|
|
61
|
+
|
|
62
|
+
def get_token(self, resource: str, tenant_id: str) -> AccessToken:
|
|
63
|
+
key = (resource, tenant_id.lower())
|
|
64
|
+
with self._lock:
|
|
65
|
+
cached = self._cache.get(key)
|
|
66
|
+
if cached is None or cached.expires_within(self._refresh_before, now=self._clock()):
|
|
67
|
+
cached = self._inner.get_token(resource, tenant_id)
|
|
68
|
+
self._cache[key] = cached
|
|
69
|
+
return cached
|
|
70
|
+
|
|
71
|
+
def invalidate(self, resource: str, tenant_id: str) -> None:
|
|
72
|
+
"""Forget the cached token, so the next request fetches a new one."""
|
|
73
|
+
with self._lock:
|
|
74
|
+
self._cache.pop((resource, tenant_id.lower()), None)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class BearerToken:
|
|
78
|
+
"""A current bearer token each time it is called, as ApiClient expects.
|
|
79
|
+
|
|
80
|
+
``refresh`` drops the provider's cached token (when it caches), so the next call
|
|
81
|
+
fetches a new one. ApiClient calls it once when an API answers 401.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
def __init__(self, provider: TokenProvider, resource: str, tenant_id: str) -> None:
|
|
85
|
+
self._provider = provider
|
|
86
|
+
self._resource = resource
|
|
87
|
+
self._tenant_id = tenant_id
|
|
88
|
+
|
|
89
|
+
def __repr__(self) -> str:
|
|
90
|
+
return f"BearerToken(resource={self._resource!r}, tenant_id={self._tenant_id!r})"
|
|
91
|
+
|
|
92
|
+
def __call__(self) -> str:
|
|
93
|
+
return self._provider.get_token(self._resource, self._tenant_id).token
|
|
94
|
+
|
|
95
|
+
def refresh(self) -> None:
|
|
96
|
+
invalidate = getattr(self._provider, "invalidate", None)
|
|
97
|
+
if callable(invalidate):
|
|
98
|
+
invalidate(self._resource, self._tenant_id)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def token_source(provider: TokenProvider, resource: str, tenant_id: str) -> BearerToken:
|
|
102
|
+
"""A zero-argument callable returning a current bearer token, as ApiClient expects."""
|
|
103
|
+
return BearerToken(provider, resource, tenant_id)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Who this tool is, in one place.
|
|
2
|
+
|
|
3
|
+
Every name the running tool shows or reads comes from here: its command, the prefix of
|
|
4
|
+
its environment variables, its config directory, and how it introduces itself. To ship it
|
|
5
|
+
under another name, run ``just rebrand``. That rewrites these values together with the
|
|
6
|
+
names no module can hold, such as the Python package name in every import; see
|
|
7
|
+
``scripts/rebrand.py`` and ``brand.toml``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
DISPLAY_NAME = "Libre DevOps Helpers"
|
|
11
|
+
COMMAND = "ldo"
|
|
12
|
+
# The name to install, from PyPI or a git URL.
|
|
13
|
+
DISTRIBUTION = "libre-devops-helpers"
|
|
14
|
+
ENV_PREFIX = "LDO"
|
|
15
|
+
CONFIG_DIR = "ldo"
|
|
16
|
+
REPOSITORY = "https://github.com/libre-devops/python-helpers"
|
|
17
|
+
|
|
18
|
+
# The welcome banner, drawn after the Libre DevOps unicorn. Plain ASCII, so it renders in
|
|
19
|
+
# any terminal and font. 'just rebrand --banner FILE' swaps it; --no-banner empties it.
|
|
20
|
+
# banner-start
|
|
21
|
+
BANNER = r"""
|
|
22
|
+
`.
|
|
23
|
+
`.`.
|
|
24
|
+
`. `. |`.
|
|
25
|
+
`. `. | `.
|
|
26
|
+
`. `. | /
|
|
27
|
+
`. `. | `.
|
|
28
|
+
`. .' `.
|
|
29
|
+
`..' \ `.
|
|
30
|
+
.' \ `.
|
|
31
|
+
.' __ \ `.
|
|
32
|
+
.' / / `.
|
|
33
|
+
.' / `.
|
|
34
|
+
.' / `.
|
|
35
|
+
.' - .- `.
|
|
36
|
+
`. .--''
|
|
37
|
+
`. .--'' |
|
|
38
|
+
`._.-'' |
|
|
39
|
+
|
|
|
40
|
+
|
|
41
|
+
_ ___ ___ ___ ___ ___ _____ _____ ___ ___
|
|
42
|
+
| | |_ _| _ ) _ \ __| | \| __\ \ / / _ \| _ \/ __|
|
|
43
|
+
| |__ | || _ \ / _| | |) | _| \ V / (_) | _/\__ \
|
|
44
|
+
|____|___|___/_|_\___| |___/|___| \_/ \___/|_| |___/
|
|
45
|
+
"""
|
|
46
|
+
# banner-end
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def env_var(name: str) -> str:
|
|
50
|
+
"""The environment variable ``name`` under this tool's prefix, e.g. ``LDO_CONFIG``."""
|
|
51
|
+
return f"{ENV_PREFIX}_{name}"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def command(text: str) -> str:
|
|
55
|
+
"""A command line to suggest to the person, e.g. ``'ldo config init'``, quoted."""
|
|
56
|
+
return f"'{COMMAND} {text}'"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def docs(page: str) -> str:
|
|
60
|
+
"""The web address of a page in docs/, for a hint, e.g. ``docs("servicenow")``."""
|
|
61
|
+
return f"{REPOSITORY}/blob/main/docs/{page}.md"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
CONFIG_ENV = env_var("CONFIG")
|
|
65
|
+
PROFILE_ENV = env_var("PROFILE")
|
|
66
|
+
LOG_FORMAT_ENV = env_var("LOG_FORMAT")
|
|
67
|
+
LOG_LEVEL_ENV = env_var("LOG_LEVEL")
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Whether a browser can be opened here, decided as the Azure CLI decides it."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
import webbrowser
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def can_launch_browser() -> bool:
|
|
10
|
+
"""False on a Linux machine with no browser to open (a headless server, say).
|
|
11
|
+
|
|
12
|
+
Windows and macOS always have one. On Linux, a browser registered with Python's
|
|
13
|
+
webbrowser module (a desktop session, or BROWSER set) counts.
|
|
14
|
+
"""
|
|
15
|
+
if not sys.platform.startswith("linux"):
|
|
16
|
+
return True
|
|
17
|
+
try:
|
|
18
|
+
webbrowser.get()
|
|
19
|
+
except webbrowser.Error:
|
|
20
|
+
return False
|
|
21
|
+
return True
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""The config file: one TOML file, with a section per vendor.
|
|
2
|
+
|
|
3
|
+
Its path is, in order: an explicit path, the ``LDO_CONFIG`` environment variable, then the
|
|
4
|
+
platform config directory (``~/.config/ldo/config.toml`` on Linux and macOS, under
|
|
5
|
+
``%APPDATA%`` on Windows). It lives outside any repository because it describes
|
|
6
|
+
environments, not code, and it never holds a secret.
|
|
7
|
+
|
|
8
|
+
The top level holds what every vendor shares (``ca_bundle``). Each vendor layer reads and
|
|
9
|
+
validates its own section (``[microsoft]``, ...) with the field readers here, so every
|
|
10
|
+
section rejects unknown keys and bad values the same way.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
import sys
|
|
18
|
+
import tomllib
|
|
19
|
+
from collections.abc import Iterable, Mapping
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from libre_devops_helpers.core import brand
|
|
25
|
+
from libre_devops_helpers.core.errors import ConfigError, ConfigNotFoundError
|
|
26
|
+
from libre_devops_helpers.core.util import is_guid
|
|
27
|
+
|
|
28
|
+
CONFIG_HEADER = f"""\
|
|
29
|
+
# {brand.DISPLAY_NAME} ({brand.COMMAND}) configuration. Each vendor has its own section;
|
|
30
|
+
# every key is described in {brand.docs("configuration")}
|
|
31
|
+
#
|
|
32
|
+
# Optional PEM bundle for a TLS-inspecting proxy, used for every HTTPS call. Without it,
|
|
33
|
+
# SSL_CERT_FILE or the bundled CA list is used.
|
|
34
|
+
# ca_bundle = "~/certs/proxy-ca.pem"
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
_NAME = re.compile(r"^[a-z0-9][a-z0-9_-]*$")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class ConfigFile:
|
|
42
|
+
"""The parsed file: shared settings, plus each vendor's raw section."""
|
|
43
|
+
|
|
44
|
+
path: Path
|
|
45
|
+
data: Mapping[str, Any] = field(default_factory=dict)
|
|
46
|
+
ca_bundle: Path | None = None
|
|
47
|
+
|
|
48
|
+
def section(self, name: str) -> Mapping[str, Any] | None:
|
|
49
|
+
"""A vendor's section, or None when the file has none."""
|
|
50
|
+
value = self.data.get(name)
|
|
51
|
+
if value is None:
|
|
52
|
+
return None
|
|
53
|
+
if not isinstance(value, dict):
|
|
54
|
+
raise ConfigError(f"{self.path}: [{name}] must be a table")
|
|
55
|
+
return value
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def default_config_path() -> Path:
|
|
59
|
+
"""Where the config file lives when no explicit path is given."""
|
|
60
|
+
override = os.environ.get(brand.CONFIG_ENV)
|
|
61
|
+
if override:
|
|
62
|
+
return Path(override).expanduser()
|
|
63
|
+
if sys.platform == "win32":
|
|
64
|
+
base = Path(os.environ.get("APPDATA") or Path.home() / "AppData" / "Roaming")
|
|
65
|
+
else:
|
|
66
|
+
base = Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config")
|
|
67
|
+
return base / brand.CONFIG_DIR / "config.toml"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def load_config_file(
|
|
71
|
+
path: Path | None = None, *, sections: Iterable[str] | None = None
|
|
72
|
+
) -> ConfigFile:
|
|
73
|
+
"""Read and parse the config file. Raises ConfigNotFoundError when it is absent.
|
|
74
|
+
|
|
75
|
+
``sections`` names the vendor sections that may appear; anything else at the top
|
|
76
|
+
level is then an error, so a typo fails loudly. None skips that check, for a library
|
|
77
|
+
caller that only knows its own vendor.
|
|
78
|
+
"""
|
|
79
|
+
path = path or default_config_path()
|
|
80
|
+
try:
|
|
81
|
+
text = path.read_text(encoding="utf-8")
|
|
82
|
+
except FileNotFoundError:
|
|
83
|
+
raise ConfigNotFoundError(
|
|
84
|
+
f"config file not found: {path}",
|
|
85
|
+
hint=f"create one with {brand.command('config init')}",
|
|
86
|
+
) from None
|
|
87
|
+
except OSError as exc:
|
|
88
|
+
raise ConfigError(f"cannot read {path}: {exc}") from None
|
|
89
|
+
try:
|
|
90
|
+
data = tomllib.loads(text)
|
|
91
|
+
except tomllib.TOMLDecodeError as exc:
|
|
92
|
+
raise ConfigError(f"{path} is not valid TOML: {exc}") from None
|
|
93
|
+
return parse_config_file(data, path, sections=sections)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def parse_config_file(
|
|
97
|
+
data: Mapping[str, Any], path: Path, *, sections: Iterable[str] | None = None
|
|
98
|
+
) -> ConfigFile:
|
|
99
|
+
"""Validate already-parsed TOML's shared settings."""
|
|
100
|
+
where = str(path)
|
|
101
|
+
if sections is not None:
|
|
102
|
+
reject_unknown(data, frozenset({"ca_bundle", *sections}), where)
|
|
103
|
+
ca_bundle = data.get("ca_bundle")
|
|
104
|
+
if ca_bundle is not None and not isinstance(ca_bundle, str):
|
|
105
|
+
raise ConfigError(f"{where}: ca_bundle must be a path string")
|
|
106
|
+
return ConfigFile(
|
|
107
|
+
path=path,
|
|
108
|
+
data=dict(data),
|
|
109
|
+
ca_bundle=Path(ca_bundle).expanduser() if ca_bundle else None,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# Field readers for vendor sections --------------------------------------------------
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def check_name(name: str, where: str) -> None:
|
|
117
|
+
"""Profile and instance names are lowercase, so they are easy to type and complete."""
|
|
118
|
+
if not _NAME.match(name):
|
|
119
|
+
raise ConfigError(f"{where}: use lowercase letters, digits, '-' and '_' in names")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def table(value: object, where: str) -> Mapping[str, Any]:
|
|
123
|
+
if not isinstance(value, dict):
|
|
124
|
+
raise ConfigError(f"{where}: expected a table")
|
|
125
|
+
return value
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def guid(data: Mapping[str, Any], key: str, where: str) -> str | None:
|
|
129
|
+
value = data.get(key)
|
|
130
|
+
if value is None:
|
|
131
|
+
return None
|
|
132
|
+
if not isinstance(value, str) or not is_guid(value):
|
|
133
|
+
raise ConfigError(f"{where}: {key} must be a GUID, got {value!r}")
|
|
134
|
+
return value.strip().lower()
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def text(data: Mapping[str, Any], key: str, where: str) -> str | None:
|
|
138
|
+
value = data.get(key)
|
|
139
|
+
if value is None:
|
|
140
|
+
return None
|
|
141
|
+
if not isinstance(value, str):
|
|
142
|
+
raise ConfigError(f"{where}: {key} must be a string")
|
|
143
|
+
return value.strip()
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def https_url(data: Mapping[str, Any], key: str, where: str) -> str | None:
|
|
147
|
+
# Tokens are attached to every request, so plain http is never acceptable.
|
|
148
|
+
value = text(data, key, where)
|
|
149
|
+
if value is not None and not value.startswith("https://"):
|
|
150
|
+
raise ConfigError(f"{where}: {key} must be an https:// URL")
|
|
151
|
+
return value.rstrip("/") if value else None
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def reject_unknown(data: Mapping[str, Any], allowed: frozenset[str], where: str) -> None:
|
|
155
|
+
unknown = sorted(set(data) - allowed)
|
|
156
|
+
if unknown:
|
|
157
|
+
raise ConfigError(
|
|
158
|
+
f"{where}: unknown key(s) {', '.join(unknown)} (allowed: {', '.join(sorted(allowed))})"
|
|
159
|
+
)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Encrypt bytes for the signed-in Windows account with DPAPI, through ctypes.
|
|
2
|
+
|
|
3
|
+
``CryptProtectData`` ties the result to the Windows account (and this machine), so only
|
|
4
|
+
that account can decrypt it; the brand name is mixed in as extra entropy, so another
|
|
5
|
+
program running as the same account cannot decrypt it by asking DPAPI alone. Windows
|
|
6
|
+
only: the Windows CI job tests it.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import ctypes
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from libre_devops_helpers.core import brand
|
|
16
|
+
|
|
17
|
+
_ENTROPY = f"{brand.COMMAND} refresh tokens".encode()
|
|
18
|
+
_UI_FORBIDDEN = 0x01 # never show a prompt
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class _Blob(ctypes.Structure):
|
|
22
|
+
_fields_ = [("size", ctypes.c_uint32), ("data", ctypes.POINTER(ctypes.c_char))]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def protect(data: bytes) -> bytes: # pragma: no cover - Windows only
|
|
26
|
+
"""``data`` encrypted for the current Windows account."""
|
|
27
|
+
return _crypt(_library("crypt32").CryptProtectData, data)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def unprotect(data: bytes) -> bytes: # pragma: no cover - Windows only
|
|
31
|
+
"""``data`` decrypted; OSError when it was encrypted for another account."""
|
|
32
|
+
return _crypt(_library("crypt32").CryptUnprotectData, data)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _library(name: str) -> Any: # pragma: no cover - Windows only
|
|
36
|
+
# use_last_error keeps the call's error code, which a later call could overwrite.
|
|
37
|
+
return ctypes.WinDLL(name, use_last_error=True) # type: ignore[attr-defined]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _crypt(function: Callable[..., Any], data: bytes) -> bytes: # pragma: no cover - Windows
|
|
41
|
+
source = ctypes.create_string_buffer(data, len(data))
|
|
42
|
+
entropy = ctypes.create_string_buffer(_ENTROPY, len(_ENTROPY))
|
|
43
|
+
blob_in = _Blob(len(data), ctypes.cast(source, ctypes.POINTER(ctypes.c_char)))
|
|
44
|
+
blob_entropy = _Blob(len(_ENTROPY), ctypes.cast(entropy, ctypes.POINTER(ctypes.c_char)))
|
|
45
|
+
blob_out = _Blob()
|
|
46
|
+
# Both functions take (in, description, entropy, reserved, prompt, flags, out).
|
|
47
|
+
if not function(
|
|
48
|
+
ctypes.byref(blob_in),
|
|
49
|
+
None,
|
|
50
|
+
ctypes.byref(blob_entropy),
|
|
51
|
+
None,
|
|
52
|
+
None,
|
|
53
|
+
_UI_FORBIDDEN,
|
|
54
|
+
ctypes.byref(blob_out),
|
|
55
|
+
):
|
|
56
|
+
raise ctypes.WinError(ctypes.get_last_error()) # type: ignore[attr-defined]
|
|
57
|
+
try:
|
|
58
|
+
return ctypes.string_at(blob_out.data, blob_out.size)
|
|
59
|
+
finally:
|
|
60
|
+
_library("kernel32").LocalFree(blob_out.data)
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Exception types raised by this package.
|
|
2
|
+
|
|
3
|
+
Library code raises these and never exits the process. Only the ``cli`` subpackage
|
|
4
|
+
turns them into messages and exit codes, so importing code keeps control of its flow.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class LdoError(Exception):
|
|
11
|
+
"""Base class for every error this package raises.
|
|
12
|
+
|
|
13
|
+
``hint`` is an optional next step for the person running the command.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
exit_code = 1
|
|
17
|
+
|
|
18
|
+
def __init__(self, message: str, *, hint: str | None = None) -> None:
|
|
19
|
+
super().__init__(message)
|
|
20
|
+
self.hint = hint
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ConfigError(LdoError):
|
|
24
|
+
"""The config file is invalid, or a profile cannot be used."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ConfigNotFoundError(ConfigError):
|
|
28
|
+
"""The config file does not exist."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class InputError(LdoError):
|
|
32
|
+
"""A list of names, a file of names, or a duration could not be read."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class CommandError(LdoError):
|
|
36
|
+
"""An external command line tool is missing, or a command it ran failed."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class AuthError(LdoError):
|
|
40
|
+
"""A credential could not produce an access token."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ReauthRequired(AuthError):
|
|
44
|
+
"""A sign-in has lapsed, and only a person signing in again can renew it.
|
|
45
|
+
|
|
46
|
+
Retrying does not help: the refresh token behind the session has expired or been
|
|
47
|
+
revoked, or a policy wants a fresh sign-in. ``reason`` says which, in plain words.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
message: str,
|
|
53
|
+
*,
|
|
54
|
+
hint: str | None = None,
|
|
55
|
+
tenant_id: str | None = None,
|
|
56
|
+
reason: str | None = None,
|
|
57
|
+
) -> None:
|
|
58
|
+
super().__init__(message, hint=hint)
|
|
59
|
+
self.tenant_id = tenant_id
|
|
60
|
+
self.reason = reason
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class TokenError(LdoError):
|
|
64
|
+
"""A token could not be decoded."""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class NotFoundError(LdoError):
|
|
68
|
+
"""A named object does not exist."""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class AmbiguousError(LdoError):
|
|
72
|
+
"""A name matched more than one object where exactly one was needed."""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class ApiError(LdoError):
|
|
76
|
+
"""An HTTP API call failed."""
|
|
77
|
+
|
|
78
|
+
def __init__(
|
|
79
|
+
self,
|
|
80
|
+
message: str,
|
|
81
|
+
*,
|
|
82
|
+
status: int | None = None,
|
|
83
|
+
code: str | None = None,
|
|
84
|
+
request_id: str | None = None,
|
|
85
|
+
hint: str | None = None,
|
|
86
|
+
) -> None:
|
|
87
|
+
super().__init__(message, hint=hint)
|
|
88
|
+
self.status = status
|
|
89
|
+
self.code = code
|
|
90
|
+
self.request_id = request_id
|