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,192 @@
|
|
|
1
|
+
"""WordPress database operations, each checked before it runs.
|
|
2
|
+
|
|
3
|
+
``l3io`` and ``l3io.wp`` are a PEP 420 namespace shared with
|
|
4
|
+
``l3io-wordpress-config`` and ``l3io-wordpress-backup``; this is the only
|
|
5
|
+
``__init__.py`` above this directory (AD-27).
|
|
6
|
+
|
|
7
|
+
The identifier helpers are public because AD-28 requires grant-target escaping
|
|
8
|
+
to go through one shared function: dependents import ``quote_grant_database``
|
|
9
|
+
from here rather than reimplementing it.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from ._client import ClientTool
|
|
15
|
+
from ._version import __version__
|
|
16
|
+
from .auth import ALLOWED_AUTH_PLUGINS, REFUSED_AUTH_PLUGINS
|
|
17
|
+
from .connection import DEFAULT_PORT, Connection
|
|
18
|
+
from .credentials import (
|
|
19
|
+
AwsSecretsManager,
|
|
20
|
+
CredentialChain,
|
|
21
|
+
CredentialSource,
|
|
22
|
+
Environment,
|
|
23
|
+
Explicit,
|
|
24
|
+
ResolvedCredentials,
|
|
25
|
+
)
|
|
26
|
+
from .encryption import (
|
|
27
|
+
check_passphrase,
|
|
28
|
+
check_recipients,
|
|
29
|
+
decrypt,
|
|
30
|
+
encrypt,
|
|
31
|
+
generate_identity,
|
|
32
|
+
)
|
|
33
|
+
from .errors import (
|
|
34
|
+
AccessDeniedError,
|
|
35
|
+
AuthenticationFailedError,
|
|
36
|
+
AuthPluginRefusedError,
|
|
37
|
+
ConfigUnreadableError,
|
|
38
|
+
ConfigurationError,
|
|
39
|
+
CorruptDumpError,
|
|
40
|
+
CredentialsAbsentError,
|
|
41
|
+
CredentialsUnobtainableError,
|
|
42
|
+
DatabaseError,
|
|
43
|
+
DatabaseUnreachableError,
|
|
44
|
+
DecryptionError,
|
|
45
|
+
InvalidArgumentsError,
|
|
46
|
+
InvalidDatabaseNameError,
|
|
47
|
+
InvalidIdentifierError,
|
|
48
|
+
MissingConfigValueError,
|
|
49
|
+
MissingDependencyError,
|
|
50
|
+
ProvisioningError,
|
|
51
|
+
RefusedError,
|
|
52
|
+
RegionNotKnownError,
|
|
53
|
+
TlsHandshakeError,
|
|
54
|
+
TlsVerificationError,
|
|
55
|
+
TransferError,
|
|
56
|
+
)
|
|
57
|
+
from .facade import Database
|
|
58
|
+
from .identifiers import (
|
|
59
|
+
quote_grant_database,
|
|
60
|
+
quote_identifier,
|
|
61
|
+
validate_database_name,
|
|
62
|
+
validate_user_name,
|
|
63
|
+
)
|
|
64
|
+
from .inspection import Survey, inspect, schema_version, survey
|
|
65
|
+
from .provision import (
|
|
66
|
+
EnsureResult,
|
|
67
|
+
OperationResult,
|
|
68
|
+
check_drop_database,
|
|
69
|
+
check_drop_user,
|
|
70
|
+
check_ensure,
|
|
71
|
+
check_revoke,
|
|
72
|
+
check_rotate_password,
|
|
73
|
+
drop_database,
|
|
74
|
+
drop_user,
|
|
75
|
+
ensure,
|
|
76
|
+
revoke,
|
|
77
|
+
rotate_password,
|
|
78
|
+
)
|
|
79
|
+
from .server import ServerFamily, ServerInfo
|
|
80
|
+
from .state import ConnectionState
|
|
81
|
+
from .tls import (
|
|
82
|
+
DEFAULT_LOCAL_HOSTS,
|
|
83
|
+
TlsMode,
|
|
84
|
+
TlsSettings,
|
|
85
|
+
tls_required_unless_local,
|
|
86
|
+
)
|
|
87
|
+
from .transfer import (
|
|
88
|
+
DropTablesResult,
|
|
89
|
+
DumpStream,
|
|
90
|
+
TransferResult,
|
|
91
|
+
check_drop_tables,
|
|
92
|
+
check_dump,
|
|
93
|
+
check_load,
|
|
94
|
+
drop_tables,
|
|
95
|
+
dump,
|
|
96
|
+
iter_dump,
|
|
97
|
+
load,
|
|
98
|
+
validate_table_prefix,
|
|
99
|
+
)
|
|
100
|
+
from .verdict import Code, Finding, Severity, Verdict
|
|
101
|
+
from .versions import SUPPORTED_FROM, SchemaVersion, release_for
|
|
102
|
+
from .wpconfig import DEFAULT_TABLE_PREFIX, WpConfigFile
|
|
103
|
+
|
|
104
|
+
__all__ = [
|
|
105
|
+
"ALLOWED_AUTH_PLUGINS",
|
|
106
|
+
"AccessDeniedError",
|
|
107
|
+
"AuthPluginRefusedError",
|
|
108
|
+
"AuthenticationFailedError",
|
|
109
|
+
"AwsSecretsManager",
|
|
110
|
+
"ClientTool",
|
|
111
|
+
"Code",
|
|
112
|
+
"ConfigUnreadableError",
|
|
113
|
+
"ConfigurationError",
|
|
114
|
+
"Connection",
|
|
115
|
+
"ConnectionState",
|
|
116
|
+
"CorruptDumpError",
|
|
117
|
+
"CredentialChain",
|
|
118
|
+
"CredentialSource",
|
|
119
|
+
"CredentialsAbsentError",
|
|
120
|
+
"CredentialsUnobtainableError",
|
|
121
|
+
"DEFAULT_LOCAL_HOSTS",
|
|
122
|
+
"DEFAULT_PORT",
|
|
123
|
+
"DEFAULT_TABLE_PREFIX",
|
|
124
|
+
"Database",
|
|
125
|
+
"DatabaseError",
|
|
126
|
+
"DatabaseUnreachableError",
|
|
127
|
+
"DecryptionError",
|
|
128
|
+
"DropTablesResult",
|
|
129
|
+
"DumpStream",
|
|
130
|
+
"EnsureResult",
|
|
131
|
+
"Environment",
|
|
132
|
+
"Explicit",
|
|
133
|
+
"Finding",
|
|
134
|
+
"InvalidArgumentsError",
|
|
135
|
+
"InvalidDatabaseNameError",
|
|
136
|
+
"InvalidIdentifierError",
|
|
137
|
+
"MissingConfigValueError",
|
|
138
|
+
"MissingDependencyError",
|
|
139
|
+
"OperationResult",
|
|
140
|
+
"ProvisioningError",
|
|
141
|
+
"REFUSED_AUTH_PLUGINS",
|
|
142
|
+
"RefusedError",
|
|
143
|
+
"RegionNotKnownError",
|
|
144
|
+
"ResolvedCredentials",
|
|
145
|
+
"SUPPORTED_FROM",
|
|
146
|
+
"SchemaVersion",
|
|
147
|
+
"ServerFamily",
|
|
148
|
+
"ServerInfo",
|
|
149
|
+
"Severity",
|
|
150
|
+
"Survey",
|
|
151
|
+
"TlsHandshakeError",
|
|
152
|
+
"TlsMode",
|
|
153
|
+
"TlsSettings",
|
|
154
|
+
"TlsVerificationError",
|
|
155
|
+
"TransferError",
|
|
156
|
+
"TransferResult",
|
|
157
|
+
"Verdict",
|
|
158
|
+
"WpConfigFile",
|
|
159
|
+
"__version__",
|
|
160
|
+
"check_drop_database",
|
|
161
|
+
"check_drop_tables",
|
|
162
|
+
"check_drop_user",
|
|
163
|
+
"check_dump",
|
|
164
|
+
"check_ensure",
|
|
165
|
+
"check_load",
|
|
166
|
+
"check_passphrase",
|
|
167
|
+
"check_recipients",
|
|
168
|
+
"check_revoke",
|
|
169
|
+
"check_rotate_password",
|
|
170
|
+
"decrypt",
|
|
171
|
+
"drop_database",
|
|
172
|
+
"drop_tables",
|
|
173
|
+
"drop_user",
|
|
174
|
+
"dump",
|
|
175
|
+
"encrypt",
|
|
176
|
+
"ensure",
|
|
177
|
+
"generate_identity",
|
|
178
|
+
"inspect",
|
|
179
|
+
"iter_dump",
|
|
180
|
+
"load",
|
|
181
|
+
"quote_grant_database",
|
|
182
|
+
"quote_identifier",
|
|
183
|
+
"release_for",
|
|
184
|
+
"revoke",
|
|
185
|
+
"rotate_password",
|
|
186
|
+
"schema_version",
|
|
187
|
+
"survey",
|
|
188
|
+
"tls_required_unless_local",
|
|
189
|
+
"validate_database_name",
|
|
190
|
+
"validate_table_prefix",
|
|
191
|
+
"validate_user_name",
|
|
192
|
+
]
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
"""The MySQL and MariaDB client programs: finding them, and running them without
|
|
2
|
+
exposing a password. Internal.
|
|
3
|
+
|
|
4
|
+
A password never appears in an argument vector, a child's environment, or a file
|
|
5
|
+
on disk (spec §2.4). The client reads its options from an anonymous pipe named
|
|
6
|
+
``--defaults-file=/dev/fd/N`` and placed first, which also stops it reading
|
|
7
|
+
``~/.my.cnf``. MySQL clients still read ``~/.mylogin.cnf`` after that, so login
|
|
8
|
+
paths are suppressed: ``MYSQL_TEST_LOGIN_FILE`` points at nothing for every
|
|
9
|
+
client, and MySQL 8.4+ also gets ``--no-login-paths`` (probe V2; MariaDB has no
|
|
10
|
+
login-path file). The child environment holds nothing else that could redirect
|
|
11
|
+
or authenticate it.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
import shutil
|
|
19
|
+
import subprocess
|
|
20
|
+
from collections.abc import Iterator, Sequence
|
|
21
|
+
from contextlib import contextmanager
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from .connection import Connection
|
|
26
|
+
from .credentials import ResolvedCredentials
|
|
27
|
+
from .errors import InvalidArgumentsError
|
|
28
|
+
from .server import ServerFamily
|
|
29
|
+
|
|
30
|
+
PURPOSE_DUMP = "dump"
|
|
31
|
+
PURPOSE_LOAD = "load"
|
|
32
|
+
|
|
33
|
+
_CANDIDATES: dict[str, dict[ServerFamily, tuple[str, ...]]] = {
|
|
34
|
+
PURPOSE_DUMP: {
|
|
35
|
+
ServerFamily.MARIADB: ("mariadb-dump", "mysqldump"),
|
|
36
|
+
ServerFamily.MYSQL: ("mysqldump",),
|
|
37
|
+
},
|
|
38
|
+
PURPOSE_LOAD: {
|
|
39
|
+
ServerFamily.MARIADB: ("mariadb", "mysql"),
|
|
40
|
+
ServerFamily.MYSQL: ("mysql",),
|
|
41
|
+
},
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
_MARIADB_VERSION = re.compile(r"(?:Distrib|from) (\d+)\.(\d+)\.(\d+)-MariaDB")
|
|
45
|
+
_MYSQL_VERSION = re.compile(r"\bVer (\d+)\.(\d+)\.(\d+)")
|
|
46
|
+
|
|
47
|
+
#: First MariaDB client release, per series, that reads the sandbox line MariaDB
|
|
48
|
+
#: dumps begin with (MDEV-33727). MySQL clients read it as a comment (probe V1).
|
|
49
|
+
#: A series absent here and older than the newest listed is treated as unable.
|
|
50
|
+
_SANDBOX_SINCE: dict[tuple[int, int], int] = {
|
|
51
|
+
(10, 5): 25,
|
|
52
|
+
(10, 6): 18,
|
|
53
|
+
(10, 11): 8,
|
|
54
|
+
(11, 0): 6,
|
|
55
|
+
(11, 1): 5,
|
|
56
|
+
(11, 2): 4,
|
|
57
|
+
(11, 4): 2,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
_NO_LOGIN_FILE = "/nonexistent/l3io-wordpress-database/no-login-file"
|
|
61
|
+
|
|
62
|
+
_MAX_VALUE_BYTES = 1024
|
|
63
|
+
|
|
64
|
+
#: Options a caller may add. Anything touching credentials, option files, the
|
|
65
|
+
#: host or an output file is refused by omission. ``--enable-cleartext-plugin``
|
|
66
|
+
#: is deliberately absent: the mysql client must keep cleartext auth off.
|
|
67
|
+
DUMP_OPTIONS = frozenset(
|
|
68
|
+
{
|
|
69
|
+
"--routines",
|
|
70
|
+
"--events",
|
|
71
|
+
"--skip-triggers",
|
|
72
|
+
"--ignore-table",
|
|
73
|
+
"--no-data",
|
|
74
|
+
"--skip-extended-insert",
|
|
75
|
+
"--where",
|
|
76
|
+
"--max-allowed-packet",
|
|
77
|
+
}
|
|
78
|
+
)
|
|
79
|
+
LOAD_OPTIONS = frozenset({"--binary-mode", "--max-allowed-packet"})
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True, slots=True)
|
|
83
|
+
class ClientTool:
|
|
84
|
+
"""One client program, and what it is."""
|
|
85
|
+
|
|
86
|
+
name: str
|
|
87
|
+
path: str
|
|
88
|
+
family: ServerFamily
|
|
89
|
+
version: tuple[int, int, int]
|
|
90
|
+
version_text: str
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def label(self) -> str:
|
|
94
|
+
return f"{self.name} {self.version_text}"
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def understands_sandbox_line(self) -> bool:
|
|
98
|
+
if self.family is ServerFamily.MYSQL:
|
|
99
|
+
return True
|
|
100
|
+
series = self.version[:2]
|
|
101
|
+
if series > max(_SANDBOX_SINCE):
|
|
102
|
+
return True
|
|
103
|
+
since = _SANDBOX_SINCE.get(series)
|
|
104
|
+
return since is not None and self.version[2] >= since
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def parse_client_version(name: str, path: str, text: str) -> ClientTool:
|
|
108
|
+
"""Interpret a client program's ``--version`` output."""
|
|
109
|
+
match = _MARIADB_VERSION.search(text)
|
|
110
|
+
family = ServerFamily.MARIADB
|
|
111
|
+
if match is None:
|
|
112
|
+
match = _MYSQL_VERSION.search(text)
|
|
113
|
+
family = ServerFamily.MYSQL
|
|
114
|
+
if match is None:
|
|
115
|
+
msg = f"unrecognised client version {text.strip()!r}"
|
|
116
|
+
raise ValueError(msg)
|
|
117
|
+
major, minor, patch = (int(g) for g in match.groups())
|
|
118
|
+
return ClientTool(name, path, family, (major, minor, patch), text.strip())
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def sanitized_path(raw: str | None = None) -> str:
|
|
122
|
+
"""``PATH`` with empty and relative entries dropped (BL-E004-055).
|
|
123
|
+
|
|
124
|
+
A relative PATH entry would let a child resolve tools against the caller's
|
|
125
|
+
cwd. Only absolute directories remain.
|
|
126
|
+
"""
|
|
127
|
+
source = os.environ.get("PATH", os.defpath) if raw is None else raw
|
|
128
|
+
return os.pathsep.join(
|
|
129
|
+
entry for entry in source.split(os.pathsep) if entry and Path(entry).is_absolute()
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def which_absolute(name: str, *, path: str | None = None) -> str | None:
|
|
134
|
+
"""``shutil.which`` over a sanitized PATH; refuse a non-absolute result."""
|
|
135
|
+
search = sanitized_path() if path is None else sanitized_path(path)
|
|
136
|
+
if not search:
|
|
137
|
+
return None
|
|
138
|
+
found = shutil.which(name, path=search)
|
|
139
|
+
if found is None or not Path(found).is_absolute():
|
|
140
|
+
return None
|
|
141
|
+
return found
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def child_env() -> dict[str, str]:
|
|
145
|
+
"""The entire environment a client program gets.
|
|
146
|
+
|
|
147
|
+
No ``HOME``, ``MYSQL_PWD``, ``MYSQL_HOST`` or ``MYSQL_TCP_PORT``: nothing
|
|
148
|
+
inherited can authenticate or redirect it. Credentials arrive only through
|
|
149
|
+
the option-file pipe. ``PATH`` is sanitized; ``LC_ALL`` is ``C``.
|
|
150
|
+
"""
|
|
151
|
+
return {
|
|
152
|
+
"PATH": sanitized_path(),
|
|
153
|
+
"LC_ALL": "C",
|
|
154
|
+
"MYSQL_TEST_LOGIN_FILE": _NO_LOGIN_FILE,
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def age_child_env(*, passphrase_fd: int) -> dict[str, str]:
|
|
159
|
+
"""Minimal environment for ``age`` and plugins it may exec (BL-E004-055).
|
|
160
|
+
|
|
161
|
+
Absolute ``PATH`` only, ``LC_ALL=C``, an absolute ``HOME`` when the parent
|
|
162
|
+
has one (else a non-existent absolute path), and ``AGE_PASSPHRASE_FD``.
|
|
163
|
+
No other parent variables (cloud keys, ``L3IO_WP_BACKUP_*``, …).
|
|
164
|
+
"""
|
|
165
|
+
home = os.environ.get("HOME", "")
|
|
166
|
+
return {
|
|
167
|
+
"PATH": sanitized_path(),
|
|
168
|
+
"HOME": home if home and Path(home).is_absolute() else "/nonexistent",
|
|
169
|
+
"LC_ALL": "C",
|
|
170
|
+
"AGE_PASSPHRASE_FD": str(passphrase_fd),
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def candidates(purpose: str, family: ServerFamily) -> tuple[str, ...]:
|
|
175
|
+
"""Program names to try for this server: its own family's first."""
|
|
176
|
+
other = ServerFamily.MYSQL if family is ServerFamily.MARIADB else ServerFamily.MARIADB
|
|
177
|
+
names = (*_CANDIDATES[purpose][family], *_CANDIDATES[purpose][other])
|
|
178
|
+
return tuple(dict.fromkeys(names))
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def find_tool(purpose: str, family: ServerFamily) -> ClientTool | None:
|
|
182
|
+
"""The best client program on ``PATH`` for this server, or ``None``."""
|
|
183
|
+
for name in candidates(purpose, family):
|
|
184
|
+
path = which_absolute(name)
|
|
185
|
+
if path is None:
|
|
186
|
+
continue
|
|
187
|
+
try:
|
|
188
|
+
done = subprocess.run( # noqa: S603 - an absolute path found on PATH, fixed argument
|
|
189
|
+
[path, "--version"],
|
|
190
|
+
capture_output=True,
|
|
191
|
+
text=True,
|
|
192
|
+
timeout=15,
|
|
193
|
+
env=child_env(),
|
|
194
|
+
check=False,
|
|
195
|
+
)
|
|
196
|
+
return parse_client_version(name, path, done.stdout or done.stderr)
|
|
197
|
+
except (OSError, subprocess.SubprocessError, ValueError):
|
|
198
|
+
continue
|
|
199
|
+
return None
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _quote(field: str, value: str) -> str:
|
|
203
|
+
"""An option-file value: quoted, with backslash, quote and newline escaped (V5).
|
|
204
|
+
|
|
205
|
+
MariaDB reads an option line longer than its fixed buffer (about 4 KiB) as
|
|
206
|
+
two lines, quoted or not, so an overlong value would smuggle in an option of
|
|
207
|
+
the caller's choosing. Values are capped well below that. No message here
|
|
208
|
+
may carry the value, and no refusal may chain the encoder's exception, whose
|
|
209
|
+
``object`` is the value itself.
|
|
210
|
+
"""
|
|
211
|
+
if "\x00" in value:
|
|
212
|
+
msg = "a connection value cannot contain a NUL character"
|
|
213
|
+
raise InvalidArgumentsError(msg)
|
|
214
|
+
escaped = value.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
|
|
215
|
+
try:
|
|
216
|
+
size = len(escaped.encode())
|
|
217
|
+
except UnicodeEncodeError:
|
|
218
|
+
size = None
|
|
219
|
+
if size is None:
|
|
220
|
+
msg = f"the {field} is not valid UTF-8 text"
|
|
221
|
+
raise InvalidArgumentsError(msg)
|
|
222
|
+
if size > _MAX_VALUE_BYTES:
|
|
223
|
+
msg = f"the {field} is longer than {_MAX_VALUE_BYTES} bytes once escaped"
|
|
224
|
+
raise InvalidArgumentsError(msg)
|
|
225
|
+
return f'"{escaped}"'
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def option_file_text(
|
|
229
|
+
credentials: ResolvedCredentials, connection: Connection, family: ServerFamily
|
|
230
|
+
) -> str:
|
|
231
|
+
"""The ``[client]`` options a program reads from the pipe.
|
|
232
|
+
|
|
233
|
+
``family`` selects TLS option names the client accepts (MySQL ``ssl-mode``
|
|
234
|
+
vs MariaDB ``ssl`` / ``ssl-verify-server-cert``).
|
|
235
|
+
"""
|
|
236
|
+
from .tls import option_file_lines
|
|
237
|
+
|
|
238
|
+
lines = [
|
|
239
|
+
"[client]",
|
|
240
|
+
f"user={_quote('user', credentials.username)}",
|
|
241
|
+
f"password={_quote('password', credentials.password)}",
|
|
242
|
+
f"host={_quote('host', connection.host)}",
|
|
243
|
+
f"port={connection.port}",
|
|
244
|
+
"protocol=TCP",
|
|
245
|
+
*option_file_lines(connection.tls, family),
|
|
246
|
+
]
|
|
247
|
+
return "\n".join(lines) + "\n"
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
@contextmanager
|
|
251
|
+
def option_pipe(
|
|
252
|
+
credentials: ResolvedCredentials, connection: Connection, family: ServerFamily
|
|
253
|
+
) -> Iterator[int]:
|
|
254
|
+
"""A pipe holding the option file; yields its read end, closed on exit.
|
|
255
|
+
|
|
256
|
+
Pass the fd with ``pass_fds`` and name it ``/dev/fd/N``. Nothing touches disk.
|
|
257
|
+
"""
|
|
258
|
+
data = option_file_text(credentials, connection, family).encode()
|
|
259
|
+
read_fd, write_fd = os.pipe()
|
|
260
|
+
try:
|
|
261
|
+
while data:
|
|
262
|
+
data = data[os.write(write_fd, data) :]
|
|
263
|
+
finally:
|
|
264
|
+
os.close(write_fd)
|
|
265
|
+
try:
|
|
266
|
+
yield read_fd
|
|
267
|
+
finally:
|
|
268
|
+
os.close(read_fd)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def disallowed_options(options: Sequence[str], allowed: frozenset[str]) -> list[str]:
|
|
272
|
+
"""Caller options whose name is not on the allow-list."""
|
|
273
|
+
return [o for o in options if o.split("=", 1)[0] not in allowed]
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _login_path_args(tool: ClientTool) -> list[str]:
|
|
277
|
+
if tool.family is ServerFamily.MYSQL and tool.version >= (8, 4, 0):
|
|
278
|
+
return ["--no-login-paths"]
|
|
279
|
+
return []
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def argv(tool: ClientTool, fd: int, args: Sequence[str]) -> list[str]:
|
|
283
|
+
"""The full argument vector: the option file first, then login-path suppression."""
|
|
284
|
+
return [tool.path, f"--defaults-file=/dev/fd/{fd}", *_login_path_args(tool), *args]
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def dump_args(
|
|
288
|
+
tool: ClientTool,
|
|
289
|
+
database: str,
|
|
290
|
+
options: Sequence[str],
|
|
291
|
+
*,
|
|
292
|
+
server_family: ServerFamily | None = None,
|
|
293
|
+
) -> list[str]:
|
|
294
|
+
"""Consistent, streaming, binary-safe, without CREATE DATABASE/USE (V7).
|
|
295
|
+
|
|
296
|
+
``server_family`` is the server being dumped (not the client). MySQL
|
|
297
|
+
mysqldump 8.0+ queries ``information_schema.COLUMN_STATISTICS`` by default;
|
|
298
|
+
MariaDB has no such table, so a MySQL-client dump of a MariaDB server fails
|
|
299
|
+
with error 1109 unless ``--column-statistics=0`` is set. Ubuntu's
|
|
300
|
+
``mysql-client`` package is often the only dump tool on PATH for MariaDB
|
|
301
|
+
hosts (``candidates`` falls back to ``mysqldump``).
|
|
302
|
+
"""
|
|
303
|
+
args = [
|
|
304
|
+
"--single-transaction",
|
|
305
|
+
"--quick",
|
|
306
|
+
"--hex-blob",
|
|
307
|
+
"--default-character-set=utf8mb4",
|
|
308
|
+
"--no-tablespaces",
|
|
309
|
+
]
|
|
310
|
+
if tool.family is ServerFamily.MYSQL:
|
|
311
|
+
args.append("--set-gtid-purged=OFF")
|
|
312
|
+
if server_family is ServerFamily.MARIADB and tool.version >= (8, 0, 0):
|
|
313
|
+
args.append("--column-statistics=0")
|
|
314
|
+
return [*args, *options, database]
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def load_args(database: str, options: Sequence[str]) -> list[str]:
|
|
318
|
+
return ["--default-character-set=utf8mb4", f"--database={database}", *options]
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""One span, one timed completion event, per public operation. Internal.
|
|
2
|
+
|
|
3
|
+
The package never configures logging. A caller that binds a Run ID with
|
|
4
|
+
``structlog.contextvars`` sees it here because the caller's processor chain
|
|
5
|
+
merges it, not because this module does anything.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
from collections.abc import Iterator
|
|
13
|
+
from contextlib import contextmanager
|
|
14
|
+
|
|
15
|
+
import structlog
|
|
16
|
+
from opentelemetry import trace
|
|
17
|
+
from opentelemetry.trace import Status, StatusCode
|
|
18
|
+
|
|
19
|
+
from .connection import Connection
|
|
20
|
+
from .errors import RefusedError
|
|
21
|
+
from .verdict import Verdict
|
|
22
|
+
|
|
23
|
+
_log = structlog.get_logger("l3io.wp.database")
|
|
24
|
+
_tracer = trace.get_tracer("l3io.wp.database")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _mark_error(span: trace.Span, error: BaseException) -> None:
|
|
28
|
+
"""Record failure on a span without message or stack (BL-E006-010).
|
|
29
|
+
|
|
30
|
+
MySQL and package errors can carry usernames, hosts or SQL. Only the
|
|
31
|
+
exception type is an attribute; status is ERROR with no description.
|
|
32
|
+
"""
|
|
33
|
+
span.set_attribute("error.type", type(error).__name__)
|
|
34
|
+
span.set_status(Status(StatusCode.ERROR))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@contextmanager
|
|
38
|
+
def operation(name: str, connection: Connection) -> Iterator[dict[str, object]]:
|
|
39
|
+
"""Wrap one public operation in a span and a completion event."""
|
|
40
|
+
fields: dict[str, object] = {}
|
|
41
|
+
started = time.monotonic()
|
|
42
|
+
with _tracer.start_as_current_span(
|
|
43
|
+
f"l3io.wp.database.{name}",
|
|
44
|
+
record_exception=False,
|
|
45
|
+
set_status_on_exception=False,
|
|
46
|
+
) as span:
|
|
47
|
+
span.set_attribute("db.name", connection.database)
|
|
48
|
+
span.set_attribute("server.address", connection.host)
|
|
49
|
+
span.set_attribute("server.port", connection.port)
|
|
50
|
+
try:
|
|
51
|
+
yield fields
|
|
52
|
+
except RefusedError as exc:
|
|
53
|
+
span.set_attribute("l3io.verdict.codes", [str(f.code) for f in exc.verdict.blockers])
|
|
54
|
+
_mark_error(span, exc)
|
|
55
|
+
fields[VERDICT_KEY] = exc.verdict
|
|
56
|
+
_complete(name, connection, "refused", started, fields)
|
|
57
|
+
raise
|
|
58
|
+
except BaseException as exc:
|
|
59
|
+
_mark_error(span, exc)
|
|
60
|
+
_complete(name, connection, "failed", started, fields)
|
|
61
|
+
raise
|
|
62
|
+
_complete(name, connection, "succeeded", started, fields)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class Running:
|
|
66
|
+
"""A public operation that outlives the call that began it, as a dump's stream does.
|
|
67
|
+
|
|
68
|
+
Its span is current only inside :meth:`current` and while :meth:`end` logs,
|
|
69
|
+
never across calls, so ending it from any thread or a finalizer leaves every
|
|
70
|
+
context as it was. Only the first :meth:`end` counts. Span, attributes,
|
|
71
|
+
completion event and outcomes are those of :func:`operation`.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
def __init__(self, name: str, connection: Connection) -> None:
|
|
75
|
+
self.fields: dict[str, object] = {}
|
|
76
|
+
self._name = name
|
|
77
|
+
self._connection = connection
|
|
78
|
+
self._started = time.monotonic()
|
|
79
|
+
self._lock = threading.Lock()
|
|
80
|
+
self._ended = False
|
|
81
|
+
self._span = _tracer.start_span(f"l3io.wp.database.{name}")
|
|
82
|
+
self._span.set_attribute("db.name", connection.database)
|
|
83
|
+
self._span.set_attribute("server.address", connection.host)
|
|
84
|
+
self._span.set_attribute("server.port", connection.port)
|
|
85
|
+
|
|
86
|
+
@contextmanager
|
|
87
|
+
def current(self) -> Iterator[dict[str, object]]:
|
|
88
|
+
"""Make the span current for a block on the calling thread."""
|
|
89
|
+
with trace.use_span(
|
|
90
|
+
self._span, end_on_exit=False, record_exception=False, set_status_on_exception=False
|
|
91
|
+
):
|
|
92
|
+
yield self.fields
|
|
93
|
+
|
|
94
|
+
def end(self, error: BaseException | None) -> None:
|
|
95
|
+
"""Complete the operation: succeeded, or refused or failed by ``error``."""
|
|
96
|
+
with self._lock:
|
|
97
|
+
if self._ended:
|
|
98
|
+
return
|
|
99
|
+
self._ended = True
|
|
100
|
+
span = self._span
|
|
101
|
+
if isinstance(error, RefusedError):
|
|
102
|
+
span.set_attribute("l3io.verdict.codes", [str(f.code) for f in error.verdict.blockers])
|
|
103
|
+
self.fields[VERDICT_KEY] = error.verdict
|
|
104
|
+
if isinstance(error, Exception):
|
|
105
|
+
_mark_error(span, error)
|
|
106
|
+
outcome = (
|
|
107
|
+
"succeeded"
|
|
108
|
+
if error is None
|
|
109
|
+
else "refused"
|
|
110
|
+
if isinstance(error, RefusedError)
|
|
111
|
+
else "failed"
|
|
112
|
+
)
|
|
113
|
+
try:
|
|
114
|
+
# Current only for the event itself, so a caller's log processor sees this span.
|
|
115
|
+
with trace.use_span(
|
|
116
|
+
span, end_on_exit=False, record_exception=False, set_status_on_exception=False
|
|
117
|
+
):
|
|
118
|
+
_complete(self._name, self._connection, outcome, self._started, self.fields)
|
|
119
|
+
finally:
|
|
120
|
+
span.end()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
#: An operation puts its Verdict in the yielded dict under this key, so the
|
|
124
|
+
#: warnings it found are logged once, at WARNING level, however it ends (§6).
|
|
125
|
+
VERDICT_KEY = "_verdict"
|
|
126
|
+
|
|
127
|
+
#: Parameter names of structlog's call chain (BoundLogger method and _proxy_to_logger).
|
|
128
|
+
#: A caller field with one of these names would collide when passed as a keyword.
|
|
129
|
+
_STRUCTLOG_PARAMETERS = frozenset({"self", "method_name"})
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _complete(
|
|
133
|
+
name: str, connection: Connection, outcome: str, started: float, fields: dict[str, object]
|
|
134
|
+
) -> None:
|
|
135
|
+
# One dict, reserved keys written last: a caller field with a colliding name
|
|
136
|
+
# must never raise here, because this runs inside the exception handlers.
|
|
137
|
+
# The event name is structlog's first positional parameter, so it travels as a
|
|
138
|
+
# keyword too; otherwise a caller field named "event" collides with it.
|
|
139
|
+
verdict = fields.pop(VERDICT_KEY, None)
|
|
140
|
+
if isinstance(verdict, Verdict):
|
|
141
|
+
for finding in verdict.warnings:
|
|
142
|
+
_log.warning(
|
|
143
|
+
event=finding.message,
|
|
144
|
+
code=str(finding.code),
|
|
145
|
+
operation=name,
|
|
146
|
+
database=connection.database,
|
|
147
|
+
)
|
|
148
|
+
caller_fields = {k: v for k, v in fields.items() if k not in _STRUCTLOG_PARAMETERS}
|
|
149
|
+
event: dict[str, object] = {
|
|
150
|
+
**caller_fields,
|
|
151
|
+
"event": "operation complete",
|
|
152
|
+
"operation": name,
|
|
153
|
+
"outcome": outcome,
|
|
154
|
+
"duration_ms": round((time.monotonic() - started) * 1000, 1),
|
|
155
|
+
"database": connection.database,
|
|
156
|
+
}
|
|
157
|
+
_log.info(**event)
|