l3io-wordpress-database 0.3.5__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.
- l3io/wp/database/__init__.py +192 -0
- l3io/wp/database/__main__.py +10 -0
- l3io/wp/database/_client.py +318 -0
- l3io/wp/database/_observe.py +157 -0
- l3io/wp/database/_passphrase.py +237 -0
- l3io/wp/database/_sql.py +79 -0
- l3io/wp/database/_version.py +24 -0
- l3io/wp/database/_version_map.py +35 -0
- l3io/wp/database/auth.py +64 -0
- l3io/wp/database/cli.py +531 -0
- l3io/wp/database/connection.py +71 -0
- l3io/wp/database/credentials.py +196 -0
- l3io/wp/database/encryption.py +188 -0
- l3io/wp/database/errors.py +186 -0
- l3io/wp/database/facade.py +220 -0
- l3io/wp/database/identifiers.py +95 -0
- l3io/wp/database/inspection.py +254 -0
- l3io/wp/database/provision.py +751 -0
- l3io/wp/database/py.typed +0 -0
- l3io/wp/database/server.py +139 -0
- l3io/wp/database/state.py +82 -0
- l3io/wp/database/tls.py +211 -0
- l3io/wp/database/transfer.py +1165 -0
- l3io/wp/database/verdict.py +193 -0
- l3io/wp/database/versions.py +101 -0
- l3io/wp/database/wpconfig.py +145 -0
- l3io_wordpress_database-0.3.5.dist-info/METADATA +239 -0
- l3io_wordpress_database-0.3.5.dist-info/RECORD +31 -0
- l3io_wordpress_database-0.3.5.dist-info/WHEEL +4 -0
- l3io_wordpress_database-0.3.5.dist-info/entry_points.txt +2 -0
- l3io_wordpress_database-0.3.5.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Database credentials and their sources.
|
|
2
|
+
|
|
3
|
+
AD-9: resolution is an explicit call, never a property read. The original
|
|
4
|
+
exposed ``username`` and ``password`` as property *getters* that performed an
|
|
5
|
+
AWS round trip and mutated ``self`` -- hidden network I/O behind attribute
|
|
6
|
+
access, with failures surfacing deep inside an unrelated database call.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Any, Protocol, runtime_checkable
|
|
15
|
+
|
|
16
|
+
import structlog
|
|
17
|
+
|
|
18
|
+
from .errors import (
|
|
19
|
+
CredentialsAbsentError,
|
|
20
|
+
CredentialsUnobtainableError,
|
|
21
|
+
InvalidArgumentsError,
|
|
22
|
+
MissingDependencyError,
|
|
23
|
+
RegionNotKnownError,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
_log = structlog.get_logger(__name__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True, slots=True)
|
|
30
|
+
class ResolvedCredentials:
|
|
31
|
+
"""A concrete username and password, and which source produced them.
|
|
32
|
+
|
|
33
|
+
The password is redacted in ``repr`` so it cannot reach a log line or a
|
|
34
|
+
traceback frame summary by accident.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
username: str
|
|
38
|
+
password: str
|
|
39
|
+
source: str = "explicit"
|
|
40
|
+
|
|
41
|
+
def __repr__(self) -> str:
|
|
42
|
+
return (
|
|
43
|
+
f"ResolvedCredentials(username={self.username!r}, password='***', "
|
|
44
|
+
f"source={self.source!r})"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@runtime_checkable
|
|
49
|
+
class CredentialSource(Protocol):
|
|
50
|
+
"""Anything that can produce credentials. Nothing needs to subclass a package type."""
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def source(self) -> str:
|
|
54
|
+
"""A label for logs and errors. Never the secret."""
|
|
55
|
+
...
|
|
56
|
+
|
|
57
|
+
def resolve(self) -> ResolvedCredentials:
|
|
58
|
+
"""Obtain the credentials, performing I/O if the source requires it.
|
|
59
|
+
|
|
60
|
+
Raises:
|
|
61
|
+
CredentialsAbsentError: the source has nothing to offer.
|
|
62
|
+
CredentialsUnobtainableError: the source tried and failed.
|
|
63
|
+
"""
|
|
64
|
+
...
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True, slots=True, repr=False)
|
|
68
|
+
class Explicit:
|
|
69
|
+
"""Credentials given directly."""
|
|
70
|
+
|
|
71
|
+
username: str
|
|
72
|
+
password: str
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def source(self) -> str:
|
|
76
|
+
return "explicit"
|
|
77
|
+
|
|
78
|
+
def resolve(self) -> ResolvedCredentials:
|
|
79
|
+
return ResolvedCredentials(self.username, self.password, self.source)
|
|
80
|
+
|
|
81
|
+
def __repr__(self) -> str:
|
|
82
|
+
return f"Explicit(username={self.username!r}, password='***')"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(frozen=True, slots=True)
|
|
86
|
+
class Environment:
|
|
87
|
+
"""Credentials held in two environment variables."""
|
|
88
|
+
|
|
89
|
+
username_var: str = "DB_USER"
|
|
90
|
+
password_var: str = "DB_PASSWORD" # noqa: S105 - name of an environment variable, not a secret
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def source(self) -> str:
|
|
94
|
+
return f"environment:{self.username_var},{self.password_var}"
|
|
95
|
+
|
|
96
|
+
def resolve(self) -> ResolvedCredentials:
|
|
97
|
+
username = os.environ.get(self.username_var)
|
|
98
|
+
password = os.environ.get(self.password_var)
|
|
99
|
+
if username is None and password is None:
|
|
100
|
+
msg = f"neither {self.username_var} nor {self.password_var} is set"
|
|
101
|
+
raise CredentialsAbsentError(msg, sources=(self.source,))
|
|
102
|
+
if username is None or password is None:
|
|
103
|
+
unset = self.username_var if username is None else self.password_var
|
|
104
|
+
msg = f"{unset} is not set, although its partner variable is"
|
|
105
|
+
raise CredentialsUnobtainableError(msg)
|
|
106
|
+
return ResolvedCredentials(username, password, self.source)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass(frozen=True, slots=True)
|
|
110
|
+
class AwsSecretsManager:
|
|
111
|
+
"""Credentials held in AWS Secrets Manager as JSON ``username``/``password``.
|
|
112
|
+
|
|
113
|
+
Needs the ``aws`` extra.
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
secret_id: str
|
|
117
|
+
region: str | None = None
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def source(self) -> str:
|
|
121
|
+
return f"aws-secrets-manager:{self.secret_id}"
|
|
122
|
+
|
|
123
|
+
def resolve(self) -> ResolvedCredentials:
|
|
124
|
+
# AD-8: the optional dependency is imported inside the function that uses it.
|
|
125
|
+
try:
|
|
126
|
+
import boto3
|
|
127
|
+
except ImportError as exc:
|
|
128
|
+
raise MissingDependencyError(extra="aws", distribution="boto3") from exc
|
|
129
|
+
|
|
130
|
+
# botocore's exceptions are not a public type (AD-14): everything that
|
|
131
|
+
# talks to AWS is in one block, and parsing what came back in another,
|
|
132
|
+
# so a client-side ValueError is never reported as a malformed secret.
|
|
133
|
+
try:
|
|
134
|
+
region = self.region or boto3.session.Session().region_name
|
|
135
|
+
except Exception as exc: # noqa: BLE001 - botocore session errors (e.g. ProfileNotFound)
|
|
136
|
+
msg = f"could not read secret {self.secret_id!r}: the AWS session could not be set up"
|
|
137
|
+
raise CredentialsUnobtainableError(msg) from exc
|
|
138
|
+
if not region:
|
|
139
|
+
raise RegionNotKnownError
|
|
140
|
+
|
|
141
|
+
_log.debug("resolving secret", source=self.source, region=region)
|
|
142
|
+
try:
|
|
143
|
+
client = boto3.client("secretsmanager", region_name=region)
|
|
144
|
+
payload: Any = client.get_secret_value(SecretId=self.secret_id)["SecretString"]
|
|
145
|
+
except Exception as exc: # noqa: BLE001 - boto3 errors are not a public type (AD-14)
|
|
146
|
+
msg = f"could not read secret {self.secret_id!r} from AWS Secrets Manager"
|
|
147
|
+
raise CredentialsUnobtainableError(msg) from exc
|
|
148
|
+
|
|
149
|
+
shape = (
|
|
150
|
+
f"secret {self.secret_id!r} is not shaped as expected: it must be a JSON "
|
|
151
|
+
"object whose 'username' and 'password' are strings"
|
|
152
|
+
)
|
|
153
|
+
try:
|
|
154
|
+
document = json.loads(payload)
|
|
155
|
+
except (TypeError, ValueError) as exc:
|
|
156
|
+
raise CredentialsUnobtainableError(shape) from exc
|
|
157
|
+
if not isinstance(document, dict):
|
|
158
|
+
raise CredentialsUnobtainableError(shape)
|
|
159
|
+
username, password = document.get("username"), document.get("password")
|
|
160
|
+
if not isinstance(username, str) or not isinstance(password, str):
|
|
161
|
+
raise CredentialsUnobtainableError(shape)
|
|
162
|
+
return ResolvedCredentials(username, password, self.source)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class CredentialChain:
|
|
166
|
+
"""The first source with an answer.
|
|
167
|
+
|
|
168
|
+
Moves to the next source only when one is *absent*. A source that *failed*
|
|
169
|
+
stops the chain, because falling through to a different credential after a
|
|
170
|
+
failure is a silent substitution.
|
|
171
|
+
"""
|
|
172
|
+
|
|
173
|
+
__slots__ = ("_sources",)
|
|
174
|
+
|
|
175
|
+
def __init__(self, *sources: CredentialSource) -> None:
|
|
176
|
+
if not sources:
|
|
177
|
+
msg = "a CredentialChain needs at least one source"
|
|
178
|
+
raise InvalidArgumentsError(msg)
|
|
179
|
+
self._sources = sources
|
|
180
|
+
|
|
181
|
+
@property
|
|
182
|
+
def source(self) -> str:
|
|
183
|
+
return "chain(" + ", ".join(s.source for s in self._sources) + ")"
|
|
184
|
+
|
|
185
|
+
def resolve(self) -> ResolvedCredentials:
|
|
186
|
+
tried: list[str] = []
|
|
187
|
+
for candidate in self._sources:
|
|
188
|
+
try:
|
|
189
|
+
return candidate.resolve()
|
|
190
|
+
except CredentialsAbsentError:
|
|
191
|
+
tried.append(candidate.source)
|
|
192
|
+
msg = "no credential source had an answer: " + ", ".join(tried)
|
|
193
|
+
raise CredentialsAbsentError(msg, sources=tuple(tried))
|
|
194
|
+
|
|
195
|
+
def __repr__(self) -> str:
|
|
196
|
+
return f"CredentialChain({self.source})"
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Optional age encryption of dumps (extra ``encryption``).
|
|
2
|
+
|
|
3
|
+
Recipients / identities go through ``pyrage``. Passphrase streaming uses
|
|
4
|
+
``l3io-age-passphrase`` (preferred) or stock ``age`` with
|
|
5
|
+
``age-plugin-batchpass`` (see ``L3IO_AGE_PASSPHRASE_BACKEND``). The format is
|
|
6
|
+
standard age, so any dump this package encrypts can be decrypted by the stock
|
|
7
|
+
``age`` command line. Errors are mapped to package types: a wrong identity or
|
|
8
|
+
passphrase is :class:`DecryptionError`; a damaged, truncated or non-age stream
|
|
9
|
+
is :class:`CorruptDumpError` (probe V4). Secret identities and passphrases
|
|
10
|
+
never appear in messages.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from collections.abc import Sequence
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import IO, Any
|
|
18
|
+
|
|
19
|
+
from . import _passphrase
|
|
20
|
+
from .errors import CorruptDumpError, DecryptionError, InvalidArgumentsError, MissingDependencyError
|
|
21
|
+
|
|
22
|
+
AGE_HEADER = b"age-encryption.org/v1"
|
|
23
|
+
_SECRET_PREFIX = "AGE-SECRET-KEY-" # noqa: S105 - the prefix age identities carry, not a secret
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _pyrage() -> Any:
|
|
27
|
+
try:
|
|
28
|
+
import pyrage
|
|
29
|
+
except ImportError as exc:
|
|
30
|
+
raise MissingDependencyError(extra="encryption", distribution="pyrage") from exc
|
|
31
|
+
return pyrage
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def generate_identity() -> tuple[str, str]:
|
|
35
|
+
"""A new age identity and its public recipient: ``(identity, recipient)``."""
|
|
36
|
+
pyrage = _pyrage()
|
|
37
|
+
identity = pyrage.x25519.Identity.generate()
|
|
38
|
+
return str(identity), str(identity.to_public())
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def read_identity(path: str | Path, passphrase: str | None = None) -> str:
|
|
42
|
+
"""The age identity in a file, optionally passphrase-protected (age's own format)."""
|
|
43
|
+
pyrage = _pyrage()
|
|
44
|
+
try:
|
|
45
|
+
data = Path(path).read_bytes()
|
|
46
|
+
except OSError as exc:
|
|
47
|
+
msg = f"cannot read the identity file {str(path)!r}: {exc.strerror}"
|
|
48
|
+
raise InvalidArgumentsError(msg) from exc
|
|
49
|
+
if passphrase is not None:
|
|
50
|
+
try:
|
|
51
|
+
data = pyrage.passphrase.decrypt(data, passphrase)
|
|
52
|
+
except pyrage.DecryptError as exc:
|
|
53
|
+
msg = f"could not unlock the identity file {str(path)!r} with that passphrase"
|
|
54
|
+
raise DecryptionError(msg) from exc
|
|
55
|
+
for line in data.decode("utf-8", errors="replace").splitlines():
|
|
56
|
+
if line.strip().startswith(_SECRET_PREFIX):
|
|
57
|
+
return line.strip()
|
|
58
|
+
msg = f"{str(path)!r} holds no age identity"
|
|
59
|
+
raise InvalidArgumentsError(msg)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _recipients(pyrage: Any, recipients: Sequence[str]) -> list[Any]:
|
|
63
|
+
if not recipients:
|
|
64
|
+
msg = "encryption needs at least one recipient"
|
|
65
|
+
raise InvalidArgumentsError(msg)
|
|
66
|
+
parsed: list[Any] = []
|
|
67
|
+
for recipient in recipients:
|
|
68
|
+
try:
|
|
69
|
+
if recipient.startswith(("ssh-", "ecdsa-")):
|
|
70
|
+
parsed.append(pyrage.ssh.Recipient.from_str(recipient))
|
|
71
|
+
else:
|
|
72
|
+
parsed.append(pyrage.x25519.Recipient.from_str(recipient))
|
|
73
|
+
except Exception as exc: # noqa: BLE001 - pyrage errors are not a public type (AD-14)
|
|
74
|
+
msg = f"not an age or SSH public key: {recipient[:12]}..."
|
|
75
|
+
raise InvalidArgumentsError(msg) from exc
|
|
76
|
+
return parsed
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _identities(pyrage: Any, identities: Sequence[str]) -> list[Any]:
|
|
80
|
+
if not identities:
|
|
81
|
+
msg = "decryption needs at least one identity"
|
|
82
|
+
raise InvalidArgumentsError(msg)
|
|
83
|
+
parsed: list[Any] = []
|
|
84
|
+
for index, identity in enumerate(identities):
|
|
85
|
+
text = identity.strip()
|
|
86
|
+
try:
|
|
87
|
+
if text.startswith(_SECRET_PREFIX):
|
|
88
|
+
parsed.append(pyrage.x25519.Identity.from_str(text))
|
|
89
|
+
else:
|
|
90
|
+
parsed.append(pyrage.ssh.Identity.from_buffer(text.encode()))
|
|
91
|
+
except Exception as exc: # noqa: BLE001 - pyrage errors are not a public type (AD-14)
|
|
92
|
+
msg = f"identity #{index + 1} is not a usable age or SSH private key"
|
|
93
|
+
raise InvalidArgumentsError(msg) from exc
|
|
94
|
+
return parsed
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _mutex_secret(
|
|
98
|
+
named: str,
|
|
99
|
+
sequence: Sequence[str] | None,
|
|
100
|
+
passphrase: str | None,
|
|
101
|
+
*,
|
|
102
|
+
action: str,
|
|
103
|
+
) -> None:
|
|
104
|
+
has_seq = sequence is not None
|
|
105
|
+
has_pass = passphrase is not None
|
|
106
|
+
if has_seq == has_pass:
|
|
107
|
+
if has_seq:
|
|
108
|
+
msg = f"supply {named}= or passphrase=, not both"
|
|
109
|
+
else:
|
|
110
|
+
msg = f"{action} needs {named}= or passphrase="
|
|
111
|
+
raise InvalidArgumentsError(msg)
|
|
112
|
+
if has_pass and passphrase == "":
|
|
113
|
+
msg = "passphrase must not be empty"
|
|
114
|
+
raise InvalidArgumentsError(msg)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def check_recipients(recipients: Sequence[str]) -> None:
|
|
118
|
+
"""Refuse recipients :func:`encrypt` could not use, before any data moves."""
|
|
119
|
+
_recipients(_pyrage(), recipients)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def check_passphrase(passphrase: str) -> None:
|
|
123
|
+
"""Refuse a passphrase :func:`encrypt` could not use, before any data moves."""
|
|
124
|
+
_passphrase.check_ready(passphrase)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def encrypt(
|
|
128
|
+
reader: IO[bytes],
|
|
129
|
+
writer: IO[bytes],
|
|
130
|
+
recipients: Sequence[str] | None = None,
|
|
131
|
+
*,
|
|
132
|
+
passphrase: str | None = None,
|
|
133
|
+
) -> None:
|
|
134
|
+
"""Stream ``reader`` into ``writer`` as age.
|
|
135
|
+
|
|
136
|
+
Exactly one of ``recipients`` or ``passphrase``. Passphrase mode emits a
|
|
137
|
+
sole scrypt recipient (stock ``age -d``).
|
|
138
|
+
"""
|
|
139
|
+
_mutex_secret("recipients", recipients, passphrase, action="encryption")
|
|
140
|
+
if passphrase is not None:
|
|
141
|
+
_passphrase.encrypt_io(reader, writer, passphrase)
|
|
142
|
+
return
|
|
143
|
+
pyrage = _pyrage()
|
|
144
|
+
try:
|
|
145
|
+
keys = _recipients(pyrage, recipients or ())
|
|
146
|
+
pyrage.encrypt_io(reader, writer, keys)
|
|
147
|
+
except pyrage.EncryptError as exc:
|
|
148
|
+
# pyrage flattens the caller's I/O exception into text, e.g. "OSError: [Errno 28] ..."
|
|
149
|
+
if str(exc).startswith("BrokenPipeError"):
|
|
150
|
+
raise BrokenPipeError(str(exc)) from exc
|
|
151
|
+
raise OSError(str(exc)) from exc
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def decrypt(
|
|
155
|
+
reader: IO[bytes],
|
|
156
|
+
writer: IO[bytes],
|
|
157
|
+
identities: Sequence[str] | None = None,
|
|
158
|
+
*,
|
|
159
|
+
passphrase: str | None = None,
|
|
160
|
+
) -> None:
|
|
161
|
+
"""Stream an age ``reader`` into ``writer`` as plaintext.
|
|
162
|
+
|
|
163
|
+
Exactly one of ``identities`` or ``passphrase``.
|
|
164
|
+
"""
|
|
165
|
+
_mutex_secret("identities", identities, passphrase, action="decryption")
|
|
166
|
+
if passphrase is not None:
|
|
167
|
+
_passphrase.decrypt_io(reader, writer, passphrase)
|
|
168
|
+
return
|
|
169
|
+
pyrage = _pyrage()
|
|
170
|
+
try:
|
|
171
|
+
keys = _identities(pyrage, identities or ())
|
|
172
|
+
pyrage.decrypt_io(reader, writer, keys)
|
|
173
|
+
except BrokenPipeError:
|
|
174
|
+
raise # the consumer stopped reading; not a property of the stream
|
|
175
|
+
except pyrage.DecryptError as exc:
|
|
176
|
+
text = str(exc)
|
|
177
|
+
if "No matching keys" in text:
|
|
178
|
+
msg = "none of the supplied identities can decrypt this dump"
|
|
179
|
+
raise DecryptionError(msg) from exc
|
|
180
|
+
msg = "the encrypted dump is damaged or not an age file"
|
|
181
|
+
raise CorruptDumpError(msg) from exc
|
|
182
|
+
except OSError as exc:
|
|
183
|
+
# pyrage's own stream failure is a bare OSError without errno; anything else is
|
|
184
|
+
# the caller's reader or writer failing and keeps its type and errno.
|
|
185
|
+
if type(exc) is not OSError or exc.errno is not None:
|
|
186
|
+
raise
|
|
187
|
+
msg = "the encrypted dump is truncated or damaged"
|
|
188
|
+
raise CorruptDumpError(msg) from exc
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Exception hierarchy owned by this package.
|
|
2
|
+
|
|
3
|
+
AD-14: no dependency's exception type crosses a public boundary. Driver and SDK
|
|
4
|
+
errors are caught and re-raised as one of these, with ``raise ... from``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from .verdict import Verdict
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DatabaseError(Exception):
|
|
16
|
+
"""Base class for every error this package raises."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class InvalidArgumentsError(DatabaseError):
|
|
20
|
+
"""An invalid combination of arguments was supplied."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class InvalidIdentifierError(DatabaseError):
|
|
24
|
+
"""A database or user name is not usable as a MySQL identifier."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, kind: str, value: str, reason: str) -> None:
|
|
27
|
+
self.kind = kind
|
|
28
|
+
self.value = value
|
|
29
|
+
self.reason = reason
|
|
30
|
+
super().__init__(f"{kind} {value!r} is not a valid identifier: {reason}")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class InvalidDatabaseNameError(InvalidIdentifierError):
|
|
34
|
+
"""A database name is not usable as a MySQL identifier."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, value: str, reason: str = "not a valid database name") -> None:
|
|
37
|
+
super().__init__("database name", value, reason)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class MissingDependencyError(DatabaseError):
|
|
41
|
+
"""An optional feature was used without its extra installed.
|
|
42
|
+
|
|
43
|
+
AD-8: optional dependencies are imported inside the function that needs
|
|
44
|
+
them, and their absence is reported by naming the extra to install. A
|
|
45
|
+
version outside the extra's range is reported the same way, with ``found``
|
|
46
|
+
set to the installed version.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
extra: str,
|
|
52
|
+
distribution: str,
|
|
53
|
+
*,
|
|
54
|
+
found: str | None = None,
|
|
55
|
+
required: str | None = None,
|
|
56
|
+
) -> None:
|
|
57
|
+
self.extra = extra
|
|
58
|
+
self.distribution = distribution
|
|
59
|
+
self.found = found
|
|
60
|
+
state = (
|
|
61
|
+
"which is not installed"
|
|
62
|
+
if found is None
|
|
63
|
+
else f"at {required}, and {found} is installed"
|
|
64
|
+
)
|
|
65
|
+
super().__init__(
|
|
66
|
+
f"this feature needs {distribution!r}, {state}. "
|
|
67
|
+
f"Install it with: pip install 'l3io-wordpress-database[{extra}]'"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class CredentialsUnobtainableError(DatabaseError):
|
|
72
|
+
"""Credentials could not be obtained from their source.
|
|
73
|
+
|
|
74
|
+
Distinct from authentication failure: nothing was attempted against the
|
|
75
|
+
database, because there was nothing to attempt it with.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class CredentialsAbsentError(DatabaseError):
|
|
80
|
+
"""A credential source had nothing to offer.
|
|
81
|
+
|
|
82
|
+
Distinct from :class:`CredentialsUnobtainableError`: an unset variable is
|
|
83
|
+
absence, a refused AWS call is failure. A
|
|
84
|
+
:class:`~l3io.wp.database.CredentialChain` moves on only after absence.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
def __init__(self, message: str, sources: tuple[str, ...] = ()) -> None:
|
|
88
|
+
self.sources = sources
|
|
89
|
+
super().__init__(message)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class RegionNotKnownError(CredentialsUnobtainableError):
|
|
93
|
+
"""An AWS region was neither supplied nor discoverable from the session."""
|
|
94
|
+
|
|
95
|
+
def __init__(self) -> None:
|
|
96
|
+
super().__init__(
|
|
97
|
+
"the AWS region was not supplied and could not be determined from the current session"
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class ConfigurationError(DatabaseError):
|
|
102
|
+
"""A WordPress configuration file could not be used."""
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class ConfigUnreadableError(ConfigurationError):
|
|
106
|
+
"""The configuration file could not be read."""
|
|
107
|
+
|
|
108
|
+
def __init__(self, path: str, reason: str) -> None:
|
|
109
|
+
self.path = path
|
|
110
|
+
super().__init__(f"could not read WordPress configuration {path!r}: {reason}")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class MissingConfigValueError(ConfigurationError):
|
|
114
|
+
"""A setting the database needs is not defined in the configuration."""
|
|
115
|
+
|
|
116
|
+
def __init__(self, key: str, path: str) -> None:
|
|
117
|
+
self.key = key
|
|
118
|
+
self.path = path
|
|
119
|
+
super().__init__(f"{key} is not defined in {path!r}")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class DatabaseUnreachableError(DatabaseError):
|
|
123
|
+
"""The database server could not be reached at all."""
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class TlsHandshakeError(DatabaseError):
|
|
127
|
+
"""TLS was required but the server did not offer or complete a handshake."""
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class TlsVerificationError(DatabaseError):
|
|
131
|
+
"""TLS was established but the server certificate did not verify."""
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class AuthenticationFailedError(DatabaseError):
|
|
135
|
+
"""The server was reached but rejected the supplied credentials."""
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class AuthPluginRefusedError(DatabaseError):
|
|
139
|
+
"""The server asked for an authentication plugin this package will not use.
|
|
140
|
+
|
|
141
|
+
``plugin`` is the server-requested name. The message never includes the
|
|
142
|
+
password.
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
def __init__(self, plugin: str, message: str | None = None) -> None:
|
|
146
|
+
self.plugin = plugin
|
|
147
|
+
super().__init__(message or f"authentication plugin {plugin!r} is not allowed")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class AccessDeniedError(DatabaseError):
|
|
151
|
+
"""Authentication succeeded but the account may not use the database."""
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class ProvisioningError(DatabaseError):
|
|
155
|
+
"""A provisioning operation started and failed part-way."""
|
|
156
|
+
|
|
157
|
+
def __init__(self, message: str, completed: tuple[str, ...] = ()) -> None:
|
|
158
|
+
self.completed = completed
|
|
159
|
+
super().__init__(message)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class RefusedError(DatabaseError):
|
|
163
|
+
"""A check found a blocker, so the operation did not run."""
|
|
164
|
+
|
|
165
|
+
def __init__(self, verdict: Verdict) -> None:
|
|
166
|
+
self.verdict = verdict
|
|
167
|
+
reasons = "; ".join(f"{f.code}: {f.message}" for f in verdict.blockers)
|
|
168
|
+
super().__init__(f"{verdict.operation} refused: {reasons}")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class TransferError(DatabaseError):
|
|
172
|
+
"""A dump or load started and failed. ``stderr`` is capped and redacted."""
|
|
173
|
+
|
|
174
|
+
def __init__(self, message: str, verdict: Verdict, exit_code: int | None, stderr: str) -> None:
|
|
175
|
+
self.verdict = verdict
|
|
176
|
+
self.exit_code = exit_code
|
|
177
|
+
self.stderr = stderr
|
|
178
|
+
super().__init__(message)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class DecryptionError(DatabaseError):
|
|
182
|
+
"""None of the supplied identities or the passphrase can decrypt the source."""
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class CorruptDumpError(DatabaseError):
|
|
186
|
+
"""An encrypted source is truncated, damaged, or not an age file."""
|