l3io-wordpress-backup 1.0.0__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/backup/__init__.py +22 -0
- l3io/wp/backup/_version.py +31 -0
- l3io/wp/backup/adapters/__init__.py +9 -0
- l3io/wp/backup/adapters/clock.py +22 -0
- l3io/wp/backup/adapters/config_writer.py +702 -0
- l3io/wp/backup/adapters/credential_providers.py +236 -0
- l3io/wp/backup/adapters/crypto.py +237 -0
- l3io/wp/backup/adapters/database.py +1555 -0
- l3io/wp/backup/adapters/database_load.py +1263 -0
- l3io/wp/backup/adapters/exclusions.py +83 -0
- l3io/wp/backup/adapters/journal.py +507 -0
- l3io/wp/backup/adapters/lock.py +372 -0
- l3io/wp/backup/adapters/noop.py +666 -0
- l3io/wp/backup/adapters/phase.py +218 -0
- l3io/wp/backup/adapters/preflight.py +869 -0
- l3io/wp/backup/adapters/redaction.py +237 -0
- l3io/wp/backup/adapters/restore_extension.py +341 -0
- l3io/wp/backup/adapters/restore_runner.py +1030 -0
- l3io/wp/backup/adapters/site_files.py +191 -0
- l3io/wp/backup/adapters/store.py +227 -0
- l3io/wp/backup/adapters/tar.py +1648 -0
- l3io/wp/backup/adapters/tracing.py +235 -0
- l3io/wp/backup/adapters/workspace.py +299 -0
- l3io/wp/backup/adapters/wpcli.py +584 -0
- l3io/wp/backup/adapters/wpcli_child.py +135 -0
- l3io/wp/backup/api/__init__.py +104 -0
- l3io/wp/backup/api/errors.py +344 -0
- l3io/wp/backup/api/operations.py +427 -0
- l3io/wp/backup/cli/__init__.py +228 -0
- l3io/wp/backup/cli/adapter_sets.py +255 -0
- l3io/wp/backup/cli/backup.py +1118 -0
- l3io/wp/backup/cli/context.py +382 -0
- l3io/wp/backup/cli/credentials.py +310 -0
- l3io/wp/backup/cli/doctor.py +171 -0
- l3io/wp/backup/cli/exit.py +171 -0
- l3io/wp/backup/cli/logging.py +147 -0
- l3io/wp/backup/cli/output.py +111 -0
- l3io/wp/backup/cli/parser.py +853 -0
- l3io/wp/backup/cli/passphrase.py +301 -0
- l3io/wp/backup/cli/preflight.py +227 -0
- l3io/wp/backup/cli/privileges.py +327 -0
- l3io/wp/backup/cli/restore.py +1571 -0
- l3io/wp/backup/cli/restore_report.py +396 -0
- l3io/wp/backup/cli/restore_request.py +560 -0
- l3io/wp/backup/cli/scope.py +31 -0
- l3io/wp/backup/cli/settings.py +524 -0
- l3io/wp/backup/cli/verify.py +286 -0
- l3io/wp/backup/core/__init__.py +9 -0
- l3io/wp/backup/core/archive_choice.py +156 -0
- l3io/wp/backup/core/backup.py +105 -0
- l3io/wp/backup/core/config_location.py +257 -0
- l3io/wp/backup/core/exclusions.py +189 -0
- l3io/wp/backup/core/relocation.py +437 -0
- l3io/wp/backup/core/restore.py +1246 -0
- l3io/wp/backup/core/restore_journal.py +541 -0
- l3io/wp/backup/core/restore_space.py +224 -0
- l3io/wp/backup/core/verify.py +198 -0
- l3io/wp/backup/domain/__init__.py +9 -0
- l3io/wp/backup/domain/archive_format.py +1072 -0
- l3io/wp/backup/domain/backup_scope.py +21 -0
- l3io/wp/backup/domain/config.py +27 -0
- l3io/wp/backup/domain/credentials.py +125 -0
- l3io/wp/backup/domain/database.py +113 -0
- l3io/wp/backup/domain/errors.py +1434 -0
- l3io/wp/backup/domain/exclusion.py +74 -0
- l3io/wp/backup/domain/exit_codes.py +87 -0
- l3io/wp/backup/domain/flag_map.py +246 -0
- l3io/wp/backup/domain/journal.py +142 -0
- l3io/wp/backup/domain/lock.py +61 -0
- l3io/wp/backup/domain/preflight.py +126 -0
- l3io/wp/backup/domain/restore.py +181 -0
- l3io/wp/backup/domain/search_replace.py +59 -0
- l3io/wp/backup/domain/secret.py +61 -0
- l3io/wp/backup/domain/site.py +60 -0
- l3io/wp/backup/domain/storage_target.py +60 -0
- l3io/wp/backup/ports/__init__.py +33 -0
- l3io/wp/backup/ports/adapter_set.py +66 -0
- l3io/wp/backup/ports/archive.py +136 -0
- l3io/wp/backup/ports/clock.py +23 -0
- l3io/wp/backup/ports/config.py +71 -0
- l3io/wp/backup/ports/crypto.py +30 -0
- l3io/wp/backup/ports/database.py +129 -0
- l3io/wp/backup/ports/exclusion.py +33 -0
- l3io/wp/backup/ports/journal.py +53 -0
- l3io/wp/backup/ports/lock.py +48 -0
- l3io/wp/backup/ports/preflight.py +23 -0
- l3io/wp/backup/ports/restore.py +59 -0
- l3io/wp/backup/ports/search_replace.py +40 -0
- l3io/wp/backup/ports/site_files.py +95 -0
- l3io/wp/backup/ports/tracing.py +80 -0
- l3io/wp/backup/py.typed +0 -0
- l3io_wordpress_backup-1.0.0.dist-info/METADATA +593 -0
- l3io_wordpress_backup-1.0.0.dist-info/RECORD +98 -0
- l3io_wordpress_backup-1.0.0.dist-info/WHEEL +4 -0
- l3io_wordpress_backup-1.0.0.dist-info/entry_points.txt +2 -0
- l3io_wordpress_backup-1.0.0.dist-info/licenses/LICENSE +21 -0
- wpbackup2/__init__.py +7 -0
- wpbackup2/__main__.py +8 -0
|
@@ -0,0 +1,1555 @@
|
|
|
1
|
+
"""ADR-0015 — the one module that calls ``l3io-wp-database``, both halves.
|
|
2
|
+
|
|
3
|
+
Locates ``wp-config.php``, builds the Site Descriptor from it (AD-3), resolves
|
|
4
|
+
the WordPress credentials from it when no Credential Provider is given, checks
|
|
5
|
+
dump readiness and produces the Dump through database's ``dump``; on restore
|
|
6
|
+
it surveys, ensures, loads into and drops a database through
|
|
7
|
+
database's functions of those names (E002-S02-009). This package issues no
|
|
8
|
+
SQL and quotes no identifier: AD-28's grant escaping is database's. Nothing
|
|
9
|
+
else in the package builds a Site Descriptor from ``wp-config.php`` (AD-3);
|
|
10
|
+
``WP_SITEURL``/``WP_HOME`` and a restore target's constants are read through
|
|
11
|
+
``adapters/config_writer``. This package invokes no MySQL client tool and
|
|
12
|
+
writes no option file.
|
|
13
|
+
|
|
14
|
+
``l3io.wp.database`` is a required dependency (owner decision 76), still
|
|
15
|
+
imported **inside the functions that use it**; an installation that lacks it
|
|
16
|
+
anyway raises ``IncompleteInstallError`` at the first call, before any
|
|
17
|
+
write. Every database exception reaching a public boundary is re-raised as a
|
|
18
|
+
package error with ``raise ... from`` (AD-14). Credentials are passed straight
|
|
19
|
+
from database's sources to database's functions and are never stored, logged
|
|
20
|
+
or put in an exception message.
|
|
21
|
+
|
|
22
|
+
This module and ``adapters/credential_providers.py`` are the only ones that
|
|
23
|
+
construct database's credential classes (ADR-0015 amendment):
|
|
24
|
+
:func:`wordpress_source` and :func:`admin_source` turn a backup-owned
|
|
25
|
+
``CredentialSpec`` into a source through that module's builders (FR-14).
|
|
26
|
+
Every source they return registers each password it resolves with the Run's
|
|
27
|
+
``SecretRegistry`` immediately after ``resolve()`` (ADR-0018), and an explicit
|
|
28
|
+
spec's password is registered when the source is built. Usernames are not
|
|
29
|
+
secrets.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import re
|
|
35
|
+
import time
|
|
36
|
+
from collections.abc import Callable, Iterator, Mapping
|
|
37
|
+
from contextlib import contextmanager
|
|
38
|
+
from dataclasses import dataclass
|
|
39
|
+
from pathlib import Path, PurePosixPath
|
|
40
|
+
from typing import IO, TYPE_CHECKING, Final
|
|
41
|
+
|
|
42
|
+
import structlog
|
|
43
|
+
|
|
44
|
+
from l3io.wp.backup.adapters.config_writer import (
|
|
45
|
+
TABLE_PREFIX,
|
|
46
|
+
read_defined,
|
|
47
|
+
read_settings,
|
|
48
|
+
refuse_environment_lookups,
|
|
49
|
+
)
|
|
50
|
+
from l3io.wp.backup.adapters.credential_providers import (
|
|
51
|
+
ADMIN_PASSWORD_VAR,
|
|
52
|
+
ADMIN_USER_VAR,
|
|
53
|
+
WORDPRESS_PASSWORD_VAR,
|
|
54
|
+
WORDPRESS_USER_VAR,
|
|
55
|
+
admin_credentials,
|
|
56
|
+
resolved_credentials,
|
|
57
|
+
unset_partner,
|
|
58
|
+
wordpress_credentials,
|
|
59
|
+
)
|
|
60
|
+
from l3io.wp.backup.adapters.redaction import (
|
|
61
|
+
MIN_SECRET_LENGTH,
|
|
62
|
+
SecretRegistry,
|
|
63
|
+
note_below_redaction_floor,
|
|
64
|
+
)
|
|
65
|
+
from l3io.wp.backup.domain.archive_format import (
|
|
66
|
+
WP_CONFIG_NAME,
|
|
67
|
+
WP_SETTINGS_NAME,
|
|
68
|
+
ConfigLocation,
|
|
69
|
+
)
|
|
70
|
+
from l3io.wp.backup.domain.credentials import CredentialSpec, ResolvedCredentialSpec
|
|
71
|
+
from l3io.wp.backup.domain.database import (
|
|
72
|
+
DatabaseLocation,
|
|
73
|
+
DatabaseTargetState,
|
|
74
|
+
DumpResult,
|
|
75
|
+
EnsureOutcome,
|
|
76
|
+
LoadResult,
|
|
77
|
+
Readiness,
|
|
78
|
+
)
|
|
79
|
+
from l3io.wp.backup.domain.errors import (
|
|
80
|
+
AdminCredentialsRequiredError,
|
|
81
|
+
BackupError,
|
|
82
|
+
CredentialsError,
|
|
83
|
+
DatabaseRefusedError,
|
|
84
|
+
DatabaseUnreachableError,
|
|
85
|
+
DumpFailedError,
|
|
86
|
+
IncompleteInstallError,
|
|
87
|
+
InvalidSiteDescriptorError,
|
|
88
|
+
LoadFailedError,
|
|
89
|
+
LoadReadinessError,
|
|
90
|
+
MissingExtraError,
|
|
91
|
+
ProvisioningFailedError,
|
|
92
|
+
RefusedError,
|
|
93
|
+
UnreadableSiteRootError,
|
|
94
|
+
UsageError,
|
|
95
|
+
)
|
|
96
|
+
from l3io.wp.backup.domain.site import SiteDescriptor
|
|
97
|
+
from l3io.wp.backup.ports.database import DbTls
|
|
98
|
+
|
|
99
|
+
if TYPE_CHECKING:
|
|
100
|
+
from l3io.wp.database import (
|
|
101
|
+
Connection,
|
|
102
|
+
CredentialSource,
|
|
103
|
+
MissingDependencyError,
|
|
104
|
+
ResolvedCredentials,
|
|
105
|
+
Survey,
|
|
106
|
+
TlsSettings,
|
|
107
|
+
Verdict,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
__all__ = [
|
|
111
|
+
"ADMIN_FLAGS",
|
|
112
|
+
"ADMIN_SOURCE",
|
|
113
|
+
"DB_TLS_MODES",
|
|
114
|
+
"INVALID_DATABASE_NAME",
|
|
115
|
+
"INVALID_DB_HOST",
|
|
116
|
+
"TLS_SETTINGS_UNUSABLE",
|
|
117
|
+
"WORDPRESS_OBSERVER",
|
|
118
|
+
"DatabaseAuthPluginRefusedError",
|
|
119
|
+
"DatabaseTlsHandshakeError",
|
|
120
|
+
"DatabaseTlsVerificationError",
|
|
121
|
+
"LocatedConfig",
|
|
122
|
+
"WpConfigCredentials",
|
|
123
|
+
"WpDatabase",
|
|
124
|
+
"admin_source",
|
|
125
|
+
"blocked_dump_error",
|
|
126
|
+
"check_db_tls",
|
|
127
|
+
"database_host_is_local",
|
|
128
|
+
"database_location_from_db_host",
|
|
129
|
+
"database_name_is_valid",
|
|
130
|
+
"locate_wp_config",
|
|
131
|
+
"password_at_call_time",
|
|
132
|
+
"require_wp_config_credentials",
|
|
133
|
+
"resolve_admin_now",
|
|
134
|
+
"resolve_wordpress_now",
|
|
135
|
+
"site_descriptor_from_archive_config",
|
|
136
|
+
"site_descriptor_from_wp_config",
|
|
137
|
+
"wordpress_source",
|
|
138
|
+
]
|
|
139
|
+
|
|
140
|
+
_log = structlog.get_logger(__name__)
|
|
141
|
+
|
|
142
|
+
_DISTRIBUTION: Final = "l3io-wordpress-database"
|
|
143
|
+
#: Database's extras that this package also offers under the same name: still
|
|
144
|
+
#: optional, so their absence is ``MissingExtraError`` naming the extra. What
|
|
145
|
+
#: every other extra database names (``wpconfig``, ``encryption``) brings is a
|
|
146
|
+
#: required dependency of this package (owner decision 76), so its absence is
|
|
147
|
+
#: ``IncompleteInstallError`` (test_credential_providers proves the split).
|
|
148
|
+
_SHARED_EXTRAS: Final = frozenset({"aws"})
|
|
149
|
+
_CONFIG_NAME: Final = WP_CONFIG_NAME
|
|
150
|
+
_SETTINGS_NAME: Final = WP_SETTINGS_NAME
|
|
151
|
+
|
|
152
|
+
#: Finding codes that mean the credentials, not the server, were the problem.
|
|
153
|
+
_CREDENTIAL_CODES = frozenset({"authentication_failed", "credentials_unobtainable"})
|
|
154
|
+
_UNREACHABLE_CODE = "unreachable"
|
|
155
|
+
_MISSING_PRIVILEGE = "missing_privilege"
|
|
156
|
+
|
|
157
|
+
#: What binds the administrator, named by ``AdminCredentialsRequiredError``:
|
|
158
|
+
#: the flags, the AWS secret flag and the two environment variables.
|
|
159
|
+
ADMIN_FLAGS: Final = (
|
|
160
|
+
"--admin-username",
|
|
161
|
+
"--admin-password",
|
|
162
|
+
"--admin-credentials-aws-secret-id",
|
|
163
|
+
ADMIN_USER_VAR,
|
|
164
|
+
ADMIN_PASSWORD_VAR,
|
|
165
|
+
)
|
|
166
|
+
#: The ``source`` label of that error: the half that was not given.
|
|
167
|
+
ADMIN_SOURCE: Final = "administrator"
|
|
168
|
+
#: ``database_surveyed``'s ``observed_by`` when the WordPress account
|
|
169
|
+
#: answered (owner decision 55).
|
|
170
|
+
WORDPRESS_OBSERVER: Final = "wordpress"
|
|
171
|
+
#: The flag that supplies each account's AWS region, named when none is known.
|
|
172
|
+
_DB_REGION_FLAG: Final = "--db-credentials-aws-region"
|
|
173
|
+
_ADMIN_REGION_FLAG: Final = "--admin-credentials-aws-region"
|
|
174
|
+
#: Every restore operation names the WordPress account at any host.
|
|
175
|
+
_ANY_HOST: Final = "%"
|
|
176
|
+
#: The Dump is untrusted Archive content. Without ``--binary-mode`` the mysql
|
|
177
|
+
#: client executes client commands in it (a backslash-bang line runs a shell
|
|
178
|
+
#: command as the operator), so every load passes it (RT-S02-001).
|
|
179
|
+
_LOAD_OPTIONS: Final = ("--binary-mode",)
|
|
180
|
+
_NS_PER_MS: Final = 1_000_000
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# --------------------------------------------------------------------------
|
|
184
|
+
# TLS for the database connections (owner decision 56, amending BL-E002-114)
|
|
185
|
+
# --------------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
#: ``--db-tls`` values, weakest first; each is a database ``TlsMode`` value
|
|
188
|
+
#: with ``-`` for ``_``. Without one, no TLS is required, as WordPress connects.
|
|
189
|
+
DB_TLS_MODES: Final = ("required", "verify-ca", "verify-identity")
|
|
190
|
+
#: The modes ``--db-tls-ca`` is used by.
|
|
191
|
+
_VERIFYING: Final = frozenset({"verify-ca", "verify-identity"})
|
|
192
|
+
#: ADV-S02-11: the ``UsageError`` texts that replace database's messages.
|
|
193
|
+
TLS_SETTINGS_UNUSABLE: Final = (
|
|
194
|
+
"the TLS settings for the database connection could not be built from "
|
|
195
|
+
"--db-tls and --db-tls-ca; nothing was changed"
|
|
196
|
+
)
|
|
197
|
+
INVALID_DB_HOST: Final = (
|
|
198
|
+
"the database host is not HOST or HOST:PORT with a port from 1 to 65535; "
|
|
199
|
+
"nothing was changed"
|
|
200
|
+
)
|
|
201
|
+
UNUSABLE_CONNECTION: Final = (
|
|
202
|
+
"the database connection settings (host, port, name or TLS) are not usable"
|
|
203
|
+
)
|
|
204
|
+
INVALID_DATABASE_NAME: Final = (
|
|
205
|
+
"the database name is not a valid database name; nothing was changed"
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class DatabaseTlsHandshakeError(DatabaseUnreachableError):
|
|
210
|
+
"""TLS was required and the server did not negotiate it. ``UNREACHABLE``."""
|
|
211
|
+
|
|
212
|
+
def __init__(self, host: str, port: int) -> None:
|
|
213
|
+
super().__init__(host, port)
|
|
214
|
+
self.args = (
|
|
215
|
+
f"database server {host}:{port} did not complete the required TLS "
|
|
216
|
+
"handshake; nothing was changed",
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
#: What of a server-sent plugin name may be shown: the server is untrusted.
|
|
221
|
+
_PLUGIN_NAME: Final = re.compile(r"[A-Za-z0-9_.-]{1,64}")
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
class DatabaseAuthPluginRefusedError(RefusedError):
|
|
225
|
+
"""The server asked for an authentication plugin that would expose the
|
|
226
|
+
password (cleartext, old-password or dialog). ``REFUSED``.
|
|
227
|
+
|
|
228
|
+
RT-S02-003: database (>= 0.3.2) refuses such a plugin before sending any
|
|
229
|
+
password. The plugin name comes from the server, so only a plain name is
|
|
230
|
+
shown; anything else is reported as ``unnamed``.
|
|
231
|
+
"""
|
|
232
|
+
|
|
233
|
+
def __init__(self, host: str, port: int, plugin: str) -> None:
|
|
234
|
+
self.host = host
|
|
235
|
+
self.port = port
|
|
236
|
+
self.plugin = plugin if _PLUGIN_NAME.fullmatch(plugin) else "unnamed"
|
|
237
|
+
super().__init__(
|
|
238
|
+
f"database server {host}:{port} asked for the {self.plugin} "
|
|
239
|
+
"authentication plugin, which would expose the password; refused "
|
|
240
|
+
"before any password was sent, and nothing was changed"
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
class DatabaseTlsVerificationError(RefusedError):
|
|
245
|
+
"""The server's TLS certificate did not verify. ``REFUSED``."""
|
|
246
|
+
|
|
247
|
+
def __init__(self, host: str, port: int) -> None:
|
|
248
|
+
self.host = host
|
|
249
|
+
self.port = port
|
|
250
|
+
super().__init__(
|
|
251
|
+
f"the TLS certificate of database server {host}:{port} did not verify "
|
|
252
|
+
"(pass --db-tls-ca FILE for a private CA); nothing was changed"
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _tls_settings(db_tls: DbTls) -> TlsSettings:
|
|
257
|
+
"""The ``TlsSettings`` ``db_tls`` asks for; no requirement without a mode.
|
|
258
|
+
|
|
259
|
+
Owner decision 56: no TLS requirement by default, for any host and either
|
|
260
|
+
account, as WordPress connects; ``--db-tls`` opts in. ``--db-tls-ca``
|
|
261
|
+
without a verifying mode is ``UsageError``, before any connection.
|
|
262
|
+
"""
|
|
263
|
+
_require_database()
|
|
264
|
+
from l3io.wp import database as wpdb
|
|
265
|
+
|
|
266
|
+
if db_tls.mode is not None and db_tls.mode not in DB_TLS_MODES:
|
|
267
|
+
msg = f"--db-tls {db_tls.mode!r}: choose one of {DB_TLS_MODES}"
|
|
268
|
+
raise UsageError(msg, arguments=("db_tls",))
|
|
269
|
+
if db_tls.ca_file is not None and db_tls.mode not in _VERIFYING:
|
|
270
|
+
msg = "--db-tls-ca needs --db-tls verify-ca or verify-identity"
|
|
271
|
+
raise UsageError(msg, arguments=("db_tls.ca_file", "db_tls"))
|
|
272
|
+
if db_tls.mode is None:
|
|
273
|
+
return wpdb.TlsSettings()
|
|
274
|
+
try:
|
|
275
|
+
return wpdb.TlsSettings(
|
|
276
|
+
mode=wpdb.TlsMode(db_tls.mode.replace("-", "_")), ca_file=db_tls.ca_file
|
|
277
|
+
)
|
|
278
|
+
except wpdb.InvalidArgumentsError as exc:
|
|
279
|
+
# ADV-S02-11: a fixed text; database's message is never forwarded.
|
|
280
|
+
raise UsageError(
|
|
281
|
+
TLS_SETTINGS_UNUSABLE, arguments=("db_tls", "db_tls.ca_file")
|
|
282
|
+
) from exc
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def check_db_tls(db_tls: DbTls | None) -> None:
|
|
286
|
+
"""Raise ``UsageError`` now for TLS settings no connection could use."""
|
|
287
|
+
if db_tls is not None:
|
|
288
|
+
_tls_settings(db_tls)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _tls_error(exc: BaseException, location: DatabaseLocation) -> BackupError:
|
|
292
|
+
"""database's connection-security errors as the package's; never its text.
|
|
293
|
+
|
|
294
|
+
TLS handshake and verification, and a refused authentication plugin.
|
|
295
|
+
"""
|
|
296
|
+
from l3io.wp import database as wpdb
|
|
297
|
+
|
|
298
|
+
if isinstance(exc, wpdb.AuthPluginRefusedError):
|
|
299
|
+
return DatabaseAuthPluginRefusedError(
|
|
300
|
+
location.host, location.port, str(exc.plugin)
|
|
301
|
+
)
|
|
302
|
+
if isinstance(exc, wpdb.TlsVerificationError):
|
|
303
|
+
return DatabaseTlsVerificationError(location.host, location.port)
|
|
304
|
+
return DatabaseTlsHandshakeError(location.host, location.port)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _missing_dependency(exc: MissingDependencyError) -> BackupError:
|
|
308
|
+
"""Database's ``MissingDependencyError`` as this package's (review F-2).
|
|
309
|
+
|
|
310
|
+
An extra this package also offers is ``MissingExtraError`` naming it;
|
|
311
|
+
anything else is a required dependency, so ``IncompleteInstallError``.
|
|
312
|
+
"""
|
|
313
|
+
if exc.extra in _SHARED_EXTRAS:
|
|
314
|
+
return MissingExtraError(extra=exc.extra)
|
|
315
|
+
return IncompleteInstallError(exc.distribution)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def database_name_is_valid(name: str) -> bool:
|
|
319
|
+
"""Whether database's ``validate_database_name`` accepts ``name``.
|
|
320
|
+
|
|
321
|
+
The one door for that check (ADR-0015): ``adapters/database_load.py`` asks here
|
|
322
|
+
instead of importing ``l3io.wp.database``. ``IncompleteInstallError`` when
|
|
323
|
+
database is not installed.
|
|
324
|
+
"""
|
|
325
|
+
_require_database()
|
|
326
|
+
from l3io.wp import database as wpdb
|
|
327
|
+
|
|
328
|
+
try:
|
|
329
|
+
wpdb.validate_database_name(name)
|
|
330
|
+
except wpdb.InvalidIdentifierError:
|
|
331
|
+
return False
|
|
332
|
+
return True
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def database_host_is_local(host: str) -> bool:
|
|
336
|
+
"""Whether ``host`` is database's default local set, or a socket path.
|
|
337
|
+
|
|
338
|
+
The set is database's ``DEFAULT_LOCAL_HOSTS`` (``localhost``,
|
|
339
|
+
``127.0.0.1`` and the IPv6 loopback), the one the TLS policy uses; a host
|
|
340
|
+
that is an absolute path is a Unix socket. Case-insensitive, as host names
|
|
341
|
+
are. ``IncompleteInstallError`` when database is not installed.
|
|
342
|
+
"""
|
|
343
|
+
_require_database()
|
|
344
|
+
from l3io.wp import database as wpdb
|
|
345
|
+
|
|
346
|
+
return host.startswith("/") or host.lower() in wpdb.DEFAULT_LOCAL_HOSTS
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _require_database() -> None:
|
|
350
|
+
"""Raise ``IncompleteInstallError`` when ``l3io.wp.database`` is absent."""
|
|
351
|
+
try:
|
|
352
|
+
import l3io.wp.database # noqa: F401 - imported for its presence only
|
|
353
|
+
except ImportError as exc:
|
|
354
|
+
raise IncompleteInstallError(_DISTRIBUTION) from exc
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
# --------------------------------------------------------------------------
|
|
358
|
+
# Locating wp-config.php
|
|
359
|
+
# --------------------------------------------------------------------------
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
@dataclass(frozen=True, slots=True)
|
|
363
|
+
class LocatedConfig:
|
|
364
|
+
"""The ``wp-config.php`` a Run reads, and which search location it is."""
|
|
365
|
+
|
|
366
|
+
path: Path
|
|
367
|
+
location: ConfigLocation | None
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _site_root(site_root: Path) -> Path:
|
|
371
|
+
try:
|
|
372
|
+
resolved = site_root.resolve(strict=True)
|
|
373
|
+
except (OSError, RuntimeError) as exc:
|
|
374
|
+
msg = "site root does not exist"
|
|
375
|
+
raise InvalidSiteDescriptorError(
|
|
376
|
+
msg, paths=(str(Path(site_root).absolute()),)
|
|
377
|
+
) from exc
|
|
378
|
+
if not resolved.is_dir():
|
|
379
|
+
msg = "site root is not a directory"
|
|
380
|
+
raise InvalidSiteDescriptorError(msg, paths=(str(site_root),))
|
|
381
|
+
return resolved
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def locate_wp_config(site_root: Path, *, stated: Path | None) -> LocatedConfig:
|
|
385
|
+
"""Find the ``wp-config.php`` for ``site_root``, the way ``wp-load.php`` does.
|
|
386
|
+
|
|
387
|
+
In order: ``stated`` is used as given (canonicalised, never re-derived);
|
|
388
|
+
else ``<root>/wp-config.php``; else ``<root>/../wp-config.php`` when the
|
|
389
|
+
parent holds no ``wp-settings.php``; else ``InvalidSiteDescriptorError``
|
|
390
|
+
naming both paths searched. A Site Root that exists and cannot be
|
|
391
|
+
searched (``EACCES``) is :class:`UnreadableSiteRootError`, a refusal
|
|
392
|
+
(BL-E003-034).
|
|
393
|
+
"""
|
|
394
|
+
root = _site_root(site_root)
|
|
395
|
+
try:
|
|
396
|
+
return _locate(root, stated)
|
|
397
|
+
except OSError as exc:
|
|
398
|
+
raise UnreadableSiteRootError(root, type(exc).__name__) from exc
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _locate(root: Path, stated: Path | None) -> LocatedConfig:
|
|
402
|
+
in_root = root / _CONFIG_NAME
|
|
403
|
+
in_parent = root.parent / _CONFIG_NAME
|
|
404
|
+
if stated is not None:
|
|
405
|
+
try:
|
|
406
|
+
path = stated.resolve(strict=True)
|
|
407
|
+
except (OSError, RuntimeError) as exc:
|
|
408
|
+
msg = "the stated wp-config.php does not exist"
|
|
409
|
+
raise InvalidSiteDescriptorError(msg, paths=(str(stated),)) from exc
|
|
410
|
+
if not path.is_file():
|
|
411
|
+
msg = "the stated wp-config.php is not a regular file"
|
|
412
|
+
raise InvalidSiteDescriptorError(msg, paths=(str(stated),))
|
|
413
|
+
location = _location_of(path, in_root, in_parent)
|
|
414
|
+
if location is None:
|
|
415
|
+
_log.warning("wp_config_outside_search_path", path=str(path))
|
|
416
|
+
elif in_root.is_file():
|
|
417
|
+
path, location = in_root, ConfigLocation.SITE_ROOT
|
|
418
|
+
elif in_parent.is_file() and not (root.parent / _SETTINGS_NAME).exists():
|
|
419
|
+
path, location = in_parent, ConfigLocation.PARENT
|
|
420
|
+
else:
|
|
421
|
+
msg = "no usable wp-config.php"
|
|
422
|
+
raise InvalidSiteDescriptorError(msg, paths=(str(in_root), str(in_parent)))
|
|
423
|
+
_log.info(
|
|
424
|
+
"wp_config_located",
|
|
425
|
+
path=str(path),
|
|
426
|
+
location=None if location is None else location.value,
|
|
427
|
+
)
|
|
428
|
+
return LocatedConfig(path=path, location=location)
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _location_of(path: Path, in_root: Path, in_parent: Path) -> ConfigLocation | None:
|
|
432
|
+
for candidate, location in (
|
|
433
|
+
(in_root, ConfigLocation.SITE_ROOT),
|
|
434
|
+
(in_parent, ConfigLocation.PARENT),
|
|
435
|
+
):
|
|
436
|
+
if candidate.is_file() and candidate.resolve() == path:
|
|
437
|
+
return location
|
|
438
|
+
return None
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
# --------------------------------------------------------------------------
|
|
442
|
+
# The Site Descriptor
|
|
443
|
+
# --------------------------------------------------------------------------
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
#: The ``DB_*`` a restore rewrites at its cutover, and ``--overwrite-wp-config``
|
|
447
|
+
#: carries over; ADR-0027 refuses a relocation that would rewrite one a
|
|
448
|
+
#: target reads from the environment.
|
|
449
|
+
_TARGET_KEYS: Final = ("DB_HOST", "DB_USER", "DB_PASSWORD", "DB_NAME")
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def site_descriptor_from_wp_config(
|
|
453
|
+
site_root: Path,
|
|
454
|
+
*,
|
|
455
|
+
config_file: Path,
|
|
456
|
+
environ: Mapping[str, str],
|
|
457
|
+
secrets: SecretRegistry,
|
|
458
|
+
refuse_environment: str | None = None,
|
|
459
|
+
refuse_keys: tuple[str, ...] = _TARGET_KEYS,
|
|
460
|
+
) -> tuple[SiteDescriptor, CredentialSource]:
|
|
461
|
+
"""The descriptor for a live Site, and its ``wp-config.php`` credential source.
|
|
462
|
+
|
|
463
|
+
``environ`` is the Run's environment mapping: a ``DB_*`` or
|
|
464
|
+
``$table_prefix`` defined with ``getenv_docker()``/``getenv()`` is
|
|
465
|
+
resolved against it (owner decision 47); this adapter never reads
|
|
466
|
+
``os.environ`` (BL-E005-007). ``refuse_environment`` names an operation
|
|
467
|
+
that cannot keep such a definition (a relocation's cutover, which
|
|
468
|
+
rewrites ``refuse_keys``): when given, any of ``refuse_keys`` read from
|
|
469
|
+
the environment is ``EnvironmentDrivenTargetError`` before anything is
|
|
470
|
+
resolved (ADR-0027). A database restore that relocates nothing writes no
|
|
471
|
+
``DB_*`` and passes none (owner decision 49).
|
|
472
|
+
"""
|
|
473
|
+
root = _site_root(site_root)
|
|
474
|
+
if not config_file.is_file():
|
|
475
|
+
msg = "wp-config.php is not a readable regular file"
|
|
476
|
+
raise InvalidSiteDescriptorError(msg, paths=(str(config_file),))
|
|
477
|
+
return _parse(
|
|
478
|
+
PurePosixPath(root),
|
|
479
|
+
config_file,
|
|
480
|
+
environ=environ,
|
|
481
|
+
secrets=secrets,
|
|
482
|
+
refuse_environment=refuse_environment,
|
|
483
|
+
refuse_keys=refuse_keys,
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def site_descriptor_from_archive_config(
|
|
488
|
+
site_root: Path,
|
|
489
|
+
*,
|
|
490
|
+
config_file: Path | None,
|
|
491
|
+
environ: Mapping[str, str],
|
|
492
|
+
secrets: SecretRegistry,
|
|
493
|
+
refuse_environment: str | None = None,
|
|
494
|
+
refuse_keys: tuple[str, ...] = _TARGET_KEYS,
|
|
495
|
+
) -> tuple[SiteDescriptor, CredentialSource]:
|
|
496
|
+
"""The descriptor for a restore target with no ``wp-config.php`` (AD-3).
|
|
497
|
+
|
|
498
|
+
``config_file`` is the Archive's config member, already extracted into the
|
|
499
|
+
Run workspace; ``site_root`` is the target's canonical Site Root.
|
|
500
|
+
``environ``, ``secrets``, ``refuse_environment`` and ``refuse_keys`` are
|
|
501
|
+
as for
|
|
502
|
+
:func:`site_descriptor_from_wp_config`.
|
|
503
|
+
"""
|
|
504
|
+
if config_file is None or not config_file.is_file():
|
|
505
|
+
msg = "archive has no wp-config.php"
|
|
506
|
+
raise InvalidSiteDescriptorError(
|
|
507
|
+
msg, paths=() if config_file is None else (str(config_file),)
|
|
508
|
+
)
|
|
509
|
+
if not site_root.is_absolute():
|
|
510
|
+
msg = "site root must be absolute"
|
|
511
|
+
raise InvalidSiteDescriptorError(msg, paths=(str(site_root),))
|
|
512
|
+
return _parse(
|
|
513
|
+
PurePosixPath(site_root),
|
|
514
|
+
config_file,
|
|
515
|
+
environ=environ,
|
|
516
|
+
secrets=secrets,
|
|
517
|
+
refuse_environment=refuse_environment,
|
|
518
|
+
refuse_keys=refuse_keys,
|
|
519
|
+
)
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def _parse(
|
|
523
|
+
site_root: PurePosixPath,
|
|
524
|
+
config_file: Path,
|
|
525
|
+
*,
|
|
526
|
+
environ: Mapping[str, str],
|
|
527
|
+
secrets: SecretRegistry,
|
|
528
|
+
refuse_environment: str | None,
|
|
529
|
+
refuse_keys: tuple[str, ...] = _TARGET_KEYS,
|
|
530
|
+
) -> tuple[SiteDescriptor, CredentialSource]:
|
|
531
|
+
"""The one parsing path both descriptor constructors share.
|
|
532
|
+
|
|
533
|
+
Credential-free: connection, table prefix and URLs only. The descriptor
|
|
534
|
+
carries no credential, so a config whose credentials come from elsewhere
|
|
535
|
+
(an explicit spec, the environment, AWS) still yields one; resolving the
|
|
536
|
+
pair is ``require_wp_config_credentials``'s job. ``DB_HOST``, ``DB_NAME``
|
|
537
|
+
and ``$table_prefix`` are read through ``config_writer.read_settings``
|
|
538
|
+
(owner decision 47); the location is database's ``Connection``.
|
|
539
|
+
"""
|
|
540
|
+
_require_database()
|
|
541
|
+
from l3io.wp import database as wpdb
|
|
542
|
+
|
|
543
|
+
label = f"wp-config:{config_file}"
|
|
544
|
+
if refuse_environment is not None and refuse_keys:
|
|
545
|
+
refuse_environment_lookups(
|
|
546
|
+
config_file, refuse_keys, operation=refuse_environment, label=label
|
|
547
|
+
)
|
|
548
|
+
values = read_settings(
|
|
549
|
+
config_file,
|
|
550
|
+
("DB_HOST", "DB_NAME", TABLE_PREFIX),
|
|
551
|
+
environ=environ,
|
|
552
|
+
label=label,
|
|
553
|
+
secrets=secrets,
|
|
554
|
+
)
|
|
555
|
+
for key in ("DB_HOST", "DB_NAME"):
|
|
556
|
+
if values[key] is None:
|
|
557
|
+
msg = f"{key} is not defined"
|
|
558
|
+
raise InvalidSiteDescriptorError(msg, paths=(str(config_file),))
|
|
559
|
+
try:
|
|
560
|
+
connection = wpdb.Connection.from_db_host(
|
|
561
|
+
str(values["DB_HOST"]), str(values["DB_NAME"])
|
|
562
|
+
)
|
|
563
|
+
except (wpdb.InvalidArgumentsError, wpdb.InvalidIdentifierError) as exc:
|
|
564
|
+
raise InvalidSiteDescriptorError(
|
|
565
|
+
_unusable_connection(exc), paths=(str(config_file),)
|
|
566
|
+
) from exc
|
|
567
|
+
prefix = values[TABLE_PREFIX]
|
|
568
|
+
site_url, home_url = _url_constants(config_file, label)
|
|
569
|
+
descriptor = SiteDescriptor(
|
|
570
|
+
site_root=site_root,
|
|
571
|
+
db_host=connection.host,
|
|
572
|
+
db_port=connection.port,
|
|
573
|
+
db_name=connection.database,
|
|
574
|
+
table_prefix=wpdb.DEFAULT_TABLE_PREFIX if prefix is None else prefix,
|
|
575
|
+
site_url=site_url,
|
|
576
|
+
home_url=home_url,
|
|
577
|
+
credential_source=label,
|
|
578
|
+
)
|
|
579
|
+
source = WpConfigCredentials(config_file, environ=environ, secrets=secrets)
|
|
580
|
+
return descriptor, source
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
class WpConfigCredentials:
|
|
584
|
+
"""``wp-config.php``'s ``DB_USER``/``DB_PASSWORD`` as a credential source.
|
|
585
|
+
|
|
586
|
+
Owner decision 47: read through ``config_writer.read_settings``, so a
|
|
587
|
+
pair defined with ``getenv_docker()``/``getenv()`` resolves against the
|
|
588
|
+
Run's ``environ`` (a ``NAME_FILE`` Docker secret included) and the
|
|
589
|
+
password is registered with ``secrets`` as it is read. Database's
|
|
590
|
+
``WpConfigFile`` read them with ``get()``, which hands an expression back
|
|
591
|
+
as its source text. Absence and half a pair keep database's semantics, so
|
|
592
|
+
a ``CredentialChain`` treats this source as it treated that one: neither
|
|
593
|
+
defined is ``CredentialsAbsentError``; one alone is
|
|
594
|
+
``CredentialsUnobtainableError`` caused by ``MissingConfigValueError``
|
|
595
|
+
naming the other.
|
|
596
|
+
"""
|
|
597
|
+
|
|
598
|
+
__slots__ = ("_environ", "_path", "_secrets")
|
|
599
|
+
|
|
600
|
+
def __init__(
|
|
601
|
+
self, path: Path, *, environ: Mapping[str, str], secrets: SecretRegistry
|
|
602
|
+
) -> None:
|
|
603
|
+
self._path = path
|
|
604
|
+
self._environ = environ
|
|
605
|
+
self._secrets = secrets
|
|
606
|
+
|
|
607
|
+
def __repr__(self) -> str:
|
|
608
|
+
return f"WpConfigCredentials({str(self._path)!r})"
|
|
609
|
+
|
|
610
|
+
@property
|
|
611
|
+
def source(self) -> str:
|
|
612
|
+
return f"wp-config:{self._path}"
|
|
613
|
+
|
|
614
|
+
def resolve(self) -> ResolvedCredentials:
|
|
615
|
+
from l3io.wp import database as wpdb
|
|
616
|
+
|
|
617
|
+
values = read_settings(
|
|
618
|
+
self._path,
|
|
619
|
+
("DB_USER", "DB_PASSWORD"),
|
|
620
|
+
environ=self._environ,
|
|
621
|
+
label=self.source,
|
|
622
|
+
secrets=self._secrets,
|
|
623
|
+
)
|
|
624
|
+
user, password = values["DB_USER"], values["DB_PASSWORD"]
|
|
625
|
+
if user is None and password is None:
|
|
626
|
+
msg = f"{self._path} defines neither DB_USER nor DB_PASSWORD"
|
|
627
|
+
raise wpdb.CredentialsAbsentError(msg, sources=(self.source,))
|
|
628
|
+
if user is None or password is None:
|
|
629
|
+
missing = wpdb.MissingConfigValueError(
|
|
630
|
+
"DB_USER" if user is None else "DB_PASSWORD", str(self._path)
|
|
631
|
+
)
|
|
632
|
+
# Half a pair is a failure, not an absence: a chain must not fall
|
|
633
|
+
# through to another credential after it.
|
|
634
|
+
raise wpdb.CredentialsUnobtainableError(str(missing)) from missing
|
|
635
|
+
return wpdb.ResolvedCredentials(user, password, self.source)
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def _unusable_connection(exc: Exception) -> str:
|
|
639
|
+
"""Why database refused the connection settings, from its fields only.
|
|
640
|
+
|
|
641
|
+
An identifier refusal names the kind and database's fixed reason, never
|
|
642
|
+
the value; any other is a fixed text (AD-7: no dependency text forwarded).
|
|
643
|
+
"""
|
|
644
|
+
kind = getattr(exc, "kind", None)
|
|
645
|
+
if isinstance(kind, str):
|
|
646
|
+
return f"the {kind} is not a valid identifier: {getattr(exc, 'reason', '')}"
|
|
647
|
+
return UNUSABLE_CONNECTION
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
#: The failure classes :meth:`WpDatabase._mapped` builds for a generic error.
|
|
651
|
+
_Failed = (
|
|
652
|
+
type[ProvisioningFailedError] | type[LoadFailedError] | type[LoadReadinessError]
|
|
653
|
+
)
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def _failure(
|
|
657
|
+
failed: _Failed, reason: str, location: DatabaseLocation, source: str
|
|
658
|
+
) -> ProvisioningFailedError | LoadFailedError | LoadReadinessError:
|
|
659
|
+
"""``failed`` built for ``reason``; a load names the database it loaded into.
|
|
660
|
+
|
|
661
|
+
A load-readiness failure also names the credential ``source`` it ran as.
|
|
662
|
+
"""
|
|
663
|
+
if failed is LoadFailedError:
|
|
664
|
+
return LoadFailedError(reason, database=location.name)
|
|
665
|
+
if failed is LoadReadinessError:
|
|
666
|
+
return LoadReadinessError(reason, database=location.name, source=source)
|
|
667
|
+
return ProvisioningFailedError(reason)
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def database_location_from_db_host(db_host: str, name: str) -> DatabaseLocation:
|
|
671
|
+
"""``DB_HOST`` (``host`` or ``host:port``) and ``name`` as a location.
|
|
672
|
+
|
|
673
|
+
The same parsing ``wp-config.php``'s ``DB_HOST`` gets (database's
|
|
674
|
+
``Connection.from_db_host``), exposed so ``--archive-database`` is not
|
|
675
|
+
parsed a second way (BL-E002-114). An empty host, a port that is not an
|
|
676
|
+
integer in range, or an invalid database name is a ``UsageError``.
|
|
677
|
+
"""
|
|
678
|
+
_require_database()
|
|
679
|
+
from l3io.wp import database as wpdb
|
|
680
|
+
|
|
681
|
+
# ADV-S02-11: fixed texts that echo neither database's message nor the
|
|
682
|
+
# value (an operator's --archive-database is never echoed, L-4).
|
|
683
|
+
try:
|
|
684
|
+
connection = wpdb.Connection.from_db_host(db_host, name)
|
|
685
|
+
except wpdb.InvalidIdentifierError as exc:
|
|
686
|
+
raise UsageError(INVALID_DATABASE_NAME, arguments=("name",)) from exc
|
|
687
|
+
except wpdb.InvalidArgumentsError as exc:
|
|
688
|
+
raise UsageError(INVALID_DB_HOST, arguments=("db_host",)) from exc
|
|
689
|
+
return DatabaseLocation(
|
|
690
|
+
host=connection.host, port=connection.port, name=connection.database
|
|
691
|
+
)
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
def password_at_call_time(source: CredentialSource) -> Callable[[], str]:
|
|
695
|
+
"""A callable resolving ``source``'s password when called, never before.
|
|
696
|
+
|
|
697
|
+
``RestorePorts.cutover_credentials`` for a relocated account: the value is
|
|
698
|
+
read at the cutover's write and registered by ``source`` (a source this
|
|
699
|
+
module built) as it resolves; nothing here keeps it.
|
|
700
|
+
"""
|
|
701
|
+
|
|
702
|
+
def resolve() -> str:
|
|
703
|
+
return source.resolve().password
|
|
704
|
+
|
|
705
|
+
return resolve
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def require_wp_config_credentials(
|
|
709
|
+
source: CredentialSource, *, environ: Mapping[str, str]
|
|
710
|
+
) -> None:
|
|
711
|
+
"""Resolve ``source`` once, so a half-defined pair fails before anything runs.
|
|
712
|
+
|
|
713
|
+
Called only where no explicit spec was given (FR-1), so ``source`` is the
|
|
714
|
+
environment-then-``wp-config.php`` chain. The result is discarded;
|
|
715
|
+
``dump`` resolves again from the source. Raises ``CredentialsError``
|
|
716
|
+
naming the source label and, for a half pair, the missing key or
|
|
717
|
+
variable — never a value. ``environ`` is the mapping the chain was built
|
|
718
|
+
from, which names the unset variable of a half pair; this adapter never
|
|
719
|
+
reads ``os.environ`` itself (BL-E005-007).
|
|
720
|
+
"""
|
|
721
|
+
_resolve_mapped(source, environ=environ)
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def resolve_wordpress_now(
|
|
725
|
+
source: CredentialSource, *, environ: Mapping[str, str]
|
|
726
|
+
) -> ResolvedCredentialSpec:
|
|
727
|
+
"""Owner decision 67: resolve the WordPress chain now, before a switch of user.
|
|
728
|
+
|
|
729
|
+
A backup the CLI started as root resolves its credentials while it can
|
|
730
|
+
still read what only root may (an AWS profile in root's home, a
|
|
731
|
+
``wp-config.php`` only root reads) and carries the answer into the Run,
|
|
732
|
+
which then reads none of it again (:func:`wordpress_source`). Failures
|
|
733
|
+
are ``CredentialsError`` naming the label, as
|
|
734
|
+
:func:`require_wp_config_credentials` raises them; never a value.
|
|
735
|
+
"""
|
|
736
|
+
resolved = _resolve_mapped(source, environ=environ)
|
|
737
|
+
return ResolvedCredentialSpec(
|
|
738
|
+
username=resolved.username,
|
|
739
|
+
password=resolved.password,
|
|
740
|
+
source=resolved.source,
|
|
741
|
+
chain=source.source,
|
|
742
|
+
)
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
def resolve_admin_now(source: CredentialSource) -> ResolvedCredentialSpec:
|
|
746
|
+
"""Owner decision 71: resolve the administrator's chain before a switch of user.
|
|
747
|
+
|
|
748
|
+
A restore the CLI started as root resolves a configured administrator
|
|
749
|
+
while it can still read what only root may (an AWS profile in root's
|
|
750
|
+
home) and carries the answer into the Run (:func:`admin_source`), which
|
|
751
|
+
uses it only where an administrator is needed (owner decision 55).
|
|
752
|
+
Failures are ``CredentialsError`` naming the source's label; never a
|
|
753
|
+
value.
|
|
754
|
+
"""
|
|
755
|
+
_require_database()
|
|
756
|
+
from l3io.wp import database as wpdb
|
|
757
|
+
|
|
758
|
+
label = source.source
|
|
759
|
+
try:
|
|
760
|
+
resolved = source.resolve()
|
|
761
|
+
except wpdb.MissingDependencyError as exc:
|
|
762
|
+
raise _missing_dependency(exc) from exc
|
|
763
|
+
except (
|
|
764
|
+
wpdb.CredentialsUnobtainableError,
|
|
765
|
+
wpdb.CredentialsAbsentError,
|
|
766
|
+
wpdb.ConfigurationError,
|
|
767
|
+
) as exc:
|
|
768
|
+
raise CredentialsError(label, reason=type(exc).__name__) from exc
|
|
769
|
+
return ResolvedCredentialSpec(
|
|
770
|
+
username=resolved.username,
|
|
771
|
+
password=resolved.password,
|
|
772
|
+
source=resolved.source,
|
|
773
|
+
chain=label,
|
|
774
|
+
)
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
def _resolve_mapped(
|
|
778
|
+
source: CredentialSource, *, environ: Mapping[str, str]
|
|
779
|
+
) -> ResolvedCredentials:
|
|
780
|
+
_require_database()
|
|
781
|
+
from l3io.wp import database as wpdb
|
|
782
|
+
|
|
783
|
+
label = source.source
|
|
784
|
+
try:
|
|
785
|
+
return source.resolve()
|
|
786
|
+
except wpdb.MissingDependencyError as exc:
|
|
787
|
+
raise _missing_dependency(exc) from exc
|
|
788
|
+
except wpdb.ConfigUnreadableError as exc:
|
|
789
|
+
raise CredentialsError(label, reason="wp-config.php is unreadable") from exc
|
|
790
|
+
except wpdb.CredentialsUnobtainableError as exc:
|
|
791
|
+
# A half-defined wp-config.php pair names its key; a half-set
|
|
792
|
+
# environment pair (which stops the chain before wp-config.php) names
|
|
793
|
+
# the unset variable. Names only, never a value.
|
|
794
|
+
cause = exc.__cause__
|
|
795
|
+
key = (
|
|
796
|
+
cause.key
|
|
797
|
+
if isinstance(cause, wpdb.MissingConfigValueError)
|
|
798
|
+
else unset_partner(environ, "wordpress")
|
|
799
|
+
)
|
|
800
|
+
raise CredentialsError(label, missing_key=key) from exc
|
|
801
|
+
except wpdb.CredentialsAbsentError as exc:
|
|
802
|
+
raise CredentialsError(
|
|
803
|
+
label,
|
|
804
|
+
reason=(
|
|
805
|
+
"defines neither DB_USER nor DB_PASSWORD, and neither "
|
|
806
|
+
f"{WORDPRESS_USER_VAR} nor {WORDPRESS_PASSWORD_VAR} is set"
|
|
807
|
+
),
|
|
808
|
+
) from exc
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
def _url_constants(config_file: Path, label: str) -> tuple[str | None, str | None]:
|
|
812
|
+
"""``WP_SITEURL`` and ``WP_HOME``, or ``None`` where not defined.
|
|
813
|
+
|
|
814
|
+
Database's ``WpConfigFile`` exposes neither, so they are read through
|
|
815
|
+
``adapters/config_writer.read_defined`` — the package's one importer of
|
|
816
|
+
``l3io-wordpress-config``, the reader database itself delegates to.
|
|
817
|
+
"""
|
|
818
|
+
values = read_defined(config_file, ("WP_SITEURL", "WP_HOME"), label=label)
|
|
819
|
+
site_url, home_url = (
|
|
820
|
+
None if v is None else str(v) for v in (values["WP_SITEURL"], values["WP_HOME"])
|
|
821
|
+
)
|
|
822
|
+
return site_url, home_url
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
# --------------------------------------------------------------------------
|
|
826
|
+
# Credential sources — built by adapters/credential_providers.py's builders,
|
|
827
|
+
# registered here; the two modules that construct database's credential classes
|
|
828
|
+
# --------------------------------------------------------------------------
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
class _RegisteringSource:
|
|
832
|
+
"""A ``CredentialSource`` that registers each password it resolves.
|
|
833
|
+
|
|
834
|
+
Registration happens immediately after the wrapped ``resolve()``, so a
|
|
835
|
+
password is known to the Run's registry before anything can emit it, and
|
|
836
|
+
the winning source's label (never its value) is logged as
|
|
837
|
+
``credential_source_selected``. A missing extra (``boto3`` for AWS) is
|
|
838
|
+
``MissingExtraError`` naming it, and an AWS secret with no known region is
|
|
839
|
+
``CredentialsError`` naming ``region_flag``: database folds both into a
|
|
840
|
+
finding that keeps neither, so they are mapped here, where every source
|
|
841
|
+
resolves. A password shorter than ``MIN_SECRET_LENGTH`` is accepted with
|
|
842
|
+
WARNING ``credential_below_redaction_floor`` (owner decision 61, amending
|
|
843
|
+
ADR-0018), after it is offered to the registry. A failure that is not
|
|
844
|
+
one of database's own credential errors is re-raised as
|
|
845
|
+
``CredentialsError`` naming the label and the failure class; its text is
|
|
846
|
+
untrusted, so it is not chained.
|
|
847
|
+
"""
|
|
848
|
+
|
|
849
|
+
__slots__ = ("_inner", "_region_flag", "_secrets", "_selected")
|
|
850
|
+
|
|
851
|
+
def __init__(
|
|
852
|
+
self, inner: CredentialSource, secrets: SecretRegistry, region_flag: str
|
|
853
|
+
) -> None:
|
|
854
|
+
self._inner = inner
|
|
855
|
+
self._secrets = secrets
|
|
856
|
+
self._region_flag = region_flag
|
|
857
|
+
#: The source last logged: a Run resolves one source several times
|
|
858
|
+
#: (check, dump, ensure), and logs its choice once (BL-E005-008).
|
|
859
|
+
self._selected: str | None = None
|
|
860
|
+
|
|
861
|
+
def __repr__(self) -> str:
|
|
862
|
+
return f"RegisteringSource({self.source!r})"
|
|
863
|
+
|
|
864
|
+
@property
|
|
865
|
+
def source(self) -> str:
|
|
866
|
+
return self._inner.source
|
|
867
|
+
|
|
868
|
+
@property
|
|
869
|
+
def secrets(self) -> SecretRegistry:
|
|
870
|
+
return self._secrets
|
|
871
|
+
|
|
872
|
+
def resolve(self) -> ResolvedCredentials:
|
|
873
|
+
from l3io.wp import database as wpdb
|
|
874
|
+
|
|
875
|
+
label = self.source
|
|
876
|
+
failure: str | None = None
|
|
877
|
+
unobtainable: Exception | None = None
|
|
878
|
+
try:
|
|
879
|
+
resolved = self._inner.resolve()
|
|
880
|
+
except wpdb.MissingDependencyError as exc:
|
|
881
|
+
raise _missing_dependency(exc) from exc
|
|
882
|
+
except wpdb.RegionNotKnownError as exc:
|
|
883
|
+
reason = (
|
|
884
|
+
f"no AWS region: pass {self._region_flag} "
|
|
885
|
+
"(AwsSecretCredentialSpec.region) or set AWS_DEFAULT_REGION"
|
|
886
|
+
)
|
|
887
|
+
raise CredentialsError(label, reason=reason) from exc
|
|
888
|
+
except wpdb.CredentialsUnobtainableError as exc:
|
|
889
|
+
if isinstance(exc.__cause__, wpdb.DatabaseError | None):
|
|
890
|
+
raise
|
|
891
|
+
# BL-E005-006: a foreign cause (an AWS secret that is not JSON) is
|
|
892
|
+
# untrusted: a JSONDecodeError's ``doc`` is the whole SecretString.
|
|
893
|
+
# The error keeps its type and text; its chain and frames go.
|
|
894
|
+
unobtainable = exc
|
|
895
|
+
except (wpdb.DatabaseError, BackupError):
|
|
896
|
+
raise
|
|
897
|
+
except Exception as exc:
|
|
898
|
+
failure = type(exc).__name__
|
|
899
|
+
if unobtainable is not None:
|
|
900
|
+
unobtainable.__cause__ = None
|
|
901
|
+
unobtainable.__context__ = None
|
|
902
|
+
raise unobtainable.with_traceback(None)
|
|
903
|
+
if failure is not None:
|
|
904
|
+
# Raised outside the handler, so not even __context__ holds the
|
|
905
|
+
# untrusted exception.
|
|
906
|
+
raise CredentialsError(label, reason=failure)
|
|
907
|
+
self._secrets.register(resolved.password, source=label)
|
|
908
|
+
if resolved.source != self._selected:
|
|
909
|
+
self._selected = resolved.source
|
|
910
|
+
_log.info("credential_source_selected", credential_source=resolved.source)
|
|
911
|
+
if len(resolved.password) < MIN_SECRET_LENGTH:
|
|
912
|
+
# Owner decision 61 (amending ADR-0018): accepted, warned once.
|
|
913
|
+
note_below_redaction_floor(resolved.source)
|
|
914
|
+
return resolved
|
|
915
|
+
|
|
916
|
+
|
|
917
|
+
def _registering(
|
|
918
|
+
source: CredentialSource, secrets: SecretRegistry, *, region_flag: str
|
|
919
|
+
) -> CredentialSource:
|
|
920
|
+
"""``source`` registering into ``secrets``; re-wrapped if bound to another.
|
|
921
|
+
|
|
922
|
+
A re-wrapped source keeps its own ``region_flag``: the inner wrapper maps
|
|
923
|
+
the region error before the outer one sees it.
|
|
924
|
+
"""
|
|
925
|
+
if isinstance(source, _RegisteringSource) and source.secrets is secrets:
|
|
926
|
+
return source
|
|
927
|
+
return _RegisteringSource(source, secrets, region_flag)
|
|
928
|
+
|
|
929
|
+
|
|
930
|
+
def wordpress_source(
|
|
931
|
+
spec: CredentialSpec | ResolvedCredentialSpec | None,
|
|
932
|
+
*,
|
|
933
|
+
wp_config: CredentialSource,
|
|
934
|
+
environ: Mapping[str, str],
|
|
935
|
+
secrets: SecretRegistry,
|
|
936
|
+
) -> CredentialSource:
|
|
937
|
+
"""The WordPress account's source, in ``WORDPRESS_CREDENTIAL_PRECEDENCE`` order.
|
|
938
|
+
|
|
939
|
+
The spec (explicit or AWS), then the environment, then ``wp-config.php``
|
|
940
|
+
(``credential_providers.wordpress_credentials``). A
|
|
941
|
+
:class:`ResolvedCredentialSpec` (owner decision 67) is the chain's answer
|
|
942
|
+
already: its source answers with it under the chain's label, and nothing
|
|
943
|
+
is read again (it was registered, and warned of, when it was resolved).
|
|
944
|
+
"""
|
|
945
|
+
_require_database()
|
|
946
|
+
if isinstance(spec, ResolvedCredentialSpec):
|
|
947
|
+
if len(spec.secret) >= MIN_SECRET_LENGTH:
|
|
948
|
+
secrets.register(spec.secret, source=spec.source)
|
|
949
|
+
return resolved_credentials(spec)
|
|
950
|
+
chain = wordpress_credentials(
|
|
951
|
+
spec, wp_config=wp_config, environ=environ, secrets=secrets
|
|
952
|
+
)
|
|
953
|
+
return _registering(chain, secrets, region_flag=_DB_REGION_FLAG)
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
def admin_source(
|
|
957
|
+
spec: CredentialSpec | ResolvedCredentialSpec | None,
|
|
958
|
+
*,
|
|
959
|
+
environ: Mapping[str, str],
|
|
960
|
+
secrets: SecretRegistry,
|
|
961
|
+
) -> CredentialSource | None:
|
|
962
|
+
"""The administrator's source, or ``None``: never ``wp-config.php``.
|
|
963
|
+
|
|
964
|
+
The spec (explicit or AWS), then the environment
|
|
965
|
+
(``credential_providers.admin_credentials``). A
|
|
966
|
+
:class:`ResolvedCredentialSpec` is that chain's answer already, resolved
|
|
967
|
+
by a restore the CLI started as root before it switched to the Site
|
|
968
|
+
Root's owner (owner decision 71): nothing is read again.
|
|
969
|
+
"""
|
|
970
|
+
_require_database()
|
|
971
|
+
if isinstance(spec, ResolvedCredentialSpec):
|
|
972
|
+
if len(spec.secret) >= MIN_SECRET_LENGTH:
|
|
973
|
+
secrets.register(spec.secret, source=spec.source)
|
|
974
|
+
return resolved_credentials(spec)
|
|
975
|
+
chain = admin_credentials(spec, environ=environ, secrets=secrets)
|
|
976
|
+
if chain is None:
|
|
977
|
+
return None
|
|
978
|
+
return _registering(chain, secrets, region_flag=_ADMIN_REGION_FLAG)
|
|
979
|
+
|
|
980
|
+
|
|
981
|
+
# --------------------------------------------------------------------------
|
|
982
|
+
# The database ports, backup half
|
|
983
|
+
# --------------------------------------------------------------------------
|
|
984
|
+
|
|
985
|
+
|
|
986
|
+
def blocked_dump_error(
|
|
987
|
+
location: DatabaseLocation, blockers: tuple[str, ...], *, credential_source: str
|
|
988
|
+
) -> BackupError | None:
|
|
989
|
+
"""What a dump blocked by ``blockers`` fails with when the cause is the
|
|
990
|
+
server (``DatabaseUnreachableError``) or the credentials
|
|
991
|
+
(``CredentialsError``); ``None`` for any other finding."""
|
|
992
|
+
if _UNREACHABLE_CODE in blockers:
|
|
993
|
+
return DatabaseUnreachableError(location.host, location.port)
|
|
994
|
+
if _CREDENTIAL_CODES.intersection(blockers):
|
|
995
|
+
return CredentialsError(credential_source, reason=", ".join(blockers))
|
|
996
|
+
return None
|
|
997
|
+
|
|
998
|
+
|
|
999
|
+
def _codes(verdict: Verdict) -> tuple[tuple[str, ...], tuple[str, ...]]:
|
|
1000
|
+
blockers = tuple(str(f.code) for f in verdict.blockers)
|
|
1001
|
+
warnings = tuple(str(f.code) for f in verdict.warnings)
|
|
1002
|
+
return blockers, warnings
|
|
1003
|
+
|
|
1004
|
+
|
|
1005
|
+
def _readiness(verdict: Verdict) -> Readiness:
|
|
1006
|
+
blockers, warnings = _codes(verdict)
|
|
1007
|
+
return Readiness(ready=verdict.ok, blockers=blockers, warnings=warnings)
|
|
1008
|
+
|
|
1009
|
+
|
|
1010
|
+
def _completed(
|
|
1011
|
+
event: str, location: DatabaseLocation, started: int, **outcome: object
|
|
1012
|
+
) -> None:
|
|
1013
|
+
"""One ``phase_completed``-shaped record for a restore command (AD-10)."""
|
|
1014
|
+
_log.info(
|
|
1015
|
+
event,
|
|
1016
|
+
db_host=location.host,
|
|
1017
|
+
db_port=location.port,
|
|
1018
|
+
db_name=location.name,
|
|
1019
|
+
duration_ms=(time.monotonic_ns() - started) / _NS_PER_MS,
|
|
1020
|
+
**outcome,
|
|
1021
|
+
)
|
|
1022
|
+
|
|
1023
|
+
|
|
1024
|
+
class WpDatabase:
|
|
1025
|
+
"""``DatabaseQuery`` and ``DatabaseCommand``.
|
|
1026
|
+
|
|
1027
|
+
The WordPress ``CredentialSource``, the administrator's when one was given,
|
|
1028
|
+
and the Run's ``SecretRegistry`` are bound at construction; no method
|
|
1029
|
+
takes a credential. A source not built by :func:`wordpress_source` or
|
|
1030
|
+
:func:`admin_source` is wrapped so that what database resolves from it is
|
|
1031
|
+
registered all the same.
|
|
1032
|
+
|
|
1033
|
+
``check_load``, ``load_dump`` and ``drop_tables`` answer as the WordPress
|
|
1034
|
+
account (owner decision 55: a restore loads straight into the live
|
|
1035
|
+
database with the site's own account). ``survey_target`` observes as that
|
|
1036
|
+
account and turns to the administrator only when the account cannot see
|
|
1037
|
+
the database (AD-29 as amended). The provisioning methods
|
|
1038
|
+
(``check_ensure``, ``ensure_database``, ``drop_database``) answer as
|
|
1039
|
+
the bound administrator and raise ``AdminCredentialsRequiredError`` before
|
|
1040
|
+
any connection when none is bound. Every real command result carries
|
|
1041
|
+
``performed=True`` (ADR-0022).
|
|
1042
|
+
"""
|
|
1043
|
+
|
|
1044
|
+
__slots__ = ("_admin", "_credentials", "_db_tls")
|
|
1045
|
+
|
|
1046
|
+
def __init__(
|
|
1047
|
+
self,
|
|
1048
|
+
credentials: CredentialSource,
|
|
1049
|
+
*,
|
|
1050
|
+
secrets: SecretRegistry,
|
|
1051
|
+
admin: CredentialSource | None = None,
|
|
1052
|
+
db_tls: DbTls | None = None,
|
|
1053
|
+
) -> None:
|
|
1054
|
+
self._credentials = _registering(
|
|
1055
|
+
credentials, secrets, region_flag=_DB_REGION_FLAG
|
|
1056
|
+
)
|
|
1057
|
+
self._admin = (
|
|
1058
|
+
None
|
|
1059
|
+
if admin is None
|
|
1060
|
+
else _registering(admin, secrets, region_flag=_ADMIN_REGION_FLAG)
|
|
1061
|
+
)
|
|
1062
|
+
# Owner decision 56: ``None`` or a ``DbTls`` with no mode requires no
|
|
1063
|
+
# TLS, as WordPress connects; a mode applies to every connection of
|
|
1064
|
+
# this instance, backup and restore alike.
|
|
1065
|
+
self._db_tls = db_tls
|
|
1066
|
+
|
|
1067
|
+
def __repr__(self) -> str:
|
|
1068
|
+
admin = None if self._admin is None else self._admin.source
|
|
1069
|
+
return f"WpDatabase(credentials={self._credentials.source!r}, admin={admin!r})"
|
|
1070
|
+
|
|
1071
|
+
@property
|
|
1072
|
+
def administrator_bound(self) -> bool:
|
|
1073
|
+
"""Whether an administrator source was bound (AD-29)."""
|
|
1074
|
+
return self._admin is not None
|
|
1075
|
+
|
|
1076
|
+
def check_dump(self, location: DatabaseLocation) -> Readiness:
|
|
1077
|
+
"""Database's ``check_dump``, its verdict copied as finding codes."""
|
|
1078
|
+
_require_database()
|
|
1079
|
+
from l3io.wp import database as wpdb
|
|
1080
|
+
|
|
1081
|
+
connection = self._connection(location)
|
|
1082
|
+
try:
|
|
1083
|
+
verdict = wpdb.check_dump(connection, self._credentials)
|
|
1084
|
+
except (
|
|
1085
|
+
wpdb.TlsHandshakeError,
|
|
1086
|
+
wpdb.TlsVerificationError,
|
|
1087
|
+
wpdb.AuthPluginRefusedError,
|
|
1088
|
+
) as exc:
|
|
1089
|
+
raise _tls_error(exc, location) from exc
|
|
1090
|
+
except wpdb.DatabaseUnreachableError as exc:
|
|
1091
|
+
raise DatabaseUnreachableError(location.host, location.port) from exc
|
|
1092
|
+
except wpdb.AuthenticationFailedError as exc:
|
|
1093
|
+
raise CredentialsError(self._credentials.source) from exc
|
|
1094
|
+
except wpdb.AccessDeniedError as exc:
|
|
1095
|
+
# An authenticated account lacking a privilege mid-session.
|
|
1096
|
+
raise DatabaseRefusedError(findings=(_MISSING_PRIVILEGE,)) from exc
|
|
1097
|
+
except wpdb.MissingDependencyError as exc:
|
|
1098
|
+
raise _missing_dependency(exc) from exc
|
|
1099
|
+
except wpdb.DatabaseError as exc:
|
|
1100
|
+
raise DumpFailedError(type(exc).__name__, database=location.name) from exc
|
|
1101
|
+
blockers, warnings = _codes(verdict)
|
|
1102
|
+
_log.info(
|
|
1103
|
+
"dump_readiness",
|
|
1104
|
+
db_host=location.host,
|
|
1105
|
+
db_port=location.port,
|
|
1106
|
+
db_name=location.name,
|
|
1107
|
+
credential_source=self._credentials.source,
|
|
1108
|
+
blockers=blockers,
|
|
1109
|
+
warnings=warnings,
|
|
1110
|
+
)
|
|
1111
|
+
return Readiness(ready=verdict.ok, blockers=blockers, warnings=warnings)
|
|
1112
|
+
|
|
1113
|
+
def dump_to(self, location: DatabaseLocation, destination: Path) -> DumpResult:
|
|
1114
|
+
"""Database's ``dump``, written plain and atomically at mode ``0600``.
|
|
1115
|
+
|
|
1116
|
+
No key argument is passed: the Dump is encrypted, if at all, inside the
|
|
1117
|
+
payload (ADR-0024).
|
|
1118
|
+
"""
|
|
1119
|
+
_require_database()
|
|
1120
|
+
from l3io.wp import database as wpdb
|
|
1121
|
+
|
|
1122
|
+
connection = self._connection(location)
|
|
1123
|
+
source = self._credentials.source
|
|
1124
|
+
try:
|
|
1125
|
+
result = wpdb.dump(connection, self._credentials, destination)
|
|
1126
|
+
except wpdb.RefusedError as exc:
|
|
1127
|
+
raise self._refusal(location, exc.verdict) from exc
|
|
1128
|
+
except wpdb.TransferError as exc:
|
|
1129
|
+
# Structured fields only, never the dependency's text (AD-7), and
|
|
1130
|
+
# no chain: database builds the TransferError's message from the
|
|
1131
|
+
# tool's first stderr line, untrusted text (N-01).
|
|
1132
|
+
code = exc.exit_code
|
|
1133
|
+
reason = "the dump stopped" if code is None else f"the dump exited {code}"
|
|
1134
|
+
raise DumpFailedError(
|
|
1135
|
+
reason, database=location.name, exit_status=code
|
|
1136
|
+
) from None
|
|
1137
|
+
except (
|
|
1138
|
+
wpdb.TlsHandshakeError,
|
|
1139
|
+
wpdb.TlsVerificationError,
|
|
1140
|
+
wpdb.AuthPluginRefusedError,
|
|
1141
|
+
) as exc:
|
|
1142
|
+
raise _tls_error(exc, location) from exc
|
|
1143
|
+
except wpdb.DatabaseUnreachableError as exc:
|
|
1144
|
+
raise DatabaseUnreachableError(location.host, location.port) from exc
|
|
1145
|
+
except (
|
|
1146
|
+
wpdb.AuthenticationFailedError,
|
|
1147
|
+
wpdb.AccessDeniedError,
|
|
1148
|
+
wpdb.CredentialsUnobtainableError,
|
|
1149
|
+
wpdb.CredentialsAbsentError,
|
|
1150
|
+
) as exc:
|
|
1151
|
+
raise CredentialsError(source) from exc
|
|
1152
|
+
except wpdb.MissingDependencyError as exc:
|
|
1153
|
+
raise _missing_dependency(exc) from exc
|
|
1154
|
+
except wpdb.DatabaseError as exc:
|
|
1155
|
+
raise DumpFailedError(type(exc).__name__, database=location.name) from exc
|
|
1156
|
+
except OSError as exc:
|
|
1157
|
+
raise DumpFailedError(
|
|
1158
|
+
exc.strerror or type(exc).__name__, database=location.name
|
|
1159
|
+
) from exc
|
|
1160
|
+
return DumpResult(bytes=result.bytes, duration_ms=result.duration_ms)
|
|
1161
|
+
|
|
1162
|
+
# ----------------------------------------------------------------------
|
|
1163
|
+
# Restore halves (E002-S02-009)
|
|
1164
|
+
# ----------------------------------------------------------------------
|
|
1165
|
+
|
|
1166
|
+
def _require_admin(self) -> CredentialSource:
|
|
1167
|
+
"""AD-29: ``AdminCredentialsRequiredError`` before any connection."""
|
|
1168
|
+
if self._admin is None:
|
|
1169
|
+
raise AdminCredentialsRequiredError(ADMIN_SOURCE, flags=ADMIN_FLAGS)
|
|
1170
|
+
return self._admin
|
|
1171
|
+
|
|
1172
|
+
def survey_target(
|
|
1173
|
+
self,
|
|
1174
|
+
location: DatabaseLocation,
|
|
1175
|
+
*,
|
|
1176
|
+
table_prefix: str,
|
|
1177
|
+
wordpress_user: str | None,
|
|
1178
|
+
) -> DatabaseTargetState:
|
|
1179
|
+
"""Database's ``survey``, copied field by field, with the prefixed tables.
|
|
1180
|
+
|
|
1181
|
+
Owner decision 55, AD-29 as amended: observed as the WordPress
|
|
1182
|
+
account first. When it logs in and sees the database, that is the
|
|
1183
|
+
answer, and ``check_drop_tables`` lists the tables carrying
|
|
1184
|
+
``table_prefix`` (``None`` when the account may not drop them).
|
|
1185
|
+
A failed login, or a database the account cannot see, is no fact
|
|
1186
|
+
about the database: the administrator observes it instead when one is
|
|
1187
|
+
bound, else ``AdminCredentialsRequiredError`` names what is missing,
|
|
1188
|
+
before anything changes.
|
|
1189
|
+
"""
|
|
1190
|
+
_require_database()
|
|
1191
|
+
from l3io.wp import database as wpdb
|
|
1192
|
+
|
|
1193
|
+
connection = self._connection(location)
|
|
1194
|
+
started = time.monotonic_ns()
|
|
1195
|
+
observer: CredentialSource = self._credentials
|
|
1196
|
+
survey = self._survey_as_wordpress(
|
|
1197
|
+
connection, location, wordpress_user, table_prefix
|
|
1198
|
+
)
|
|
1199
|
+
if survey is None or not survey.database_exists:
|
|
1200
|
+
missing = (
|
|
1201
|
+
"authentication_failed" if survey is None else "database_not_visible"
|
|
1202
|
+
)
|
|
1203
|
+
if self._admin is None:
|
|
1204
|
+
raise AdminCredentialsRequiredError(
|
|
1205
|
+
ADMIN_SOURCE,
|
|
1206
|
+
flags=ADMIN_FLAGS,
|
|
1207
|
+
missing=(missing,),
|
|
1208
|
+
database=location.name,
|
|
1209
|
+
account=self._credentials.source,
|
|
1210
|
+
)
|
|
1211
|
+
observer = self._admin
|
|
1212
|
+
with self._mapped(location, observer.source, ProvisioningFailedError):
|
|
1213
|
+
survey = wpdb.survey(
|
|
1214
|
+
connection,
|
|
1215
|
+
observer,
|
|
1216
|
+
wordpress_user=wordpress_user,
|
|
1217
|
+
user_host=_ANY_HOST,
|
|
1218
|
+
table_prefix=table_prefix,
|
|
1219
|
+
)
|
|
1220
|
+
prefixed = self._prefixed_tables(connection, location, observer, table_prefix)
|
|
1221
|
+
version = survey.schema_version
|
|
1222
|
+
state = DatabaseTargetState(
|
|
1223
|
+
database_exists=survey.database_exists,
|
|
1224
|
+
table_count=survey.table_count,
|
|
1225
|
+
is_wordpress=survey.is_wordpress,
|
|
1226
|
+
schema_version=None if version is None else str(version.db_version),
|
|
1227
|
+
user_exists=survey.user_exists,
|
|
1228
|
+
routine_count=survey.routine_count,
|
|
1229
|
+
event_count=survey.event_count,
|
|
1230
|
+
prefixed_tables=prefixed,
|
|
1231
|
+
)
|
|
1232
|
+
_completed(
|
|
1233
|
+
"database_surveyed",
|
|
1234
|
+
location,
|
|
1235
|
+
started,
|
|
1236
|
+
observed_by=ADMIN_SOURCE if observer is self._admin else WORDPRESS_OBSERVER,
|
|
1237
|
+
database_exists=state.database_exists,
|
|
1238
|
+
table_count=state.table_count,
|
|
1239
|
+
routine_count=state.routine_count,
|
|
1240
|
+
event_count=state.event_count,
|
|
1241
|
+
is_wordpress=state.is_wordpress,
|
|
1242
|
+
user_exists=state.user_exists,
|
|
1243
|
+
prefixed_tables=None if prefixed is None else len(prefixed),
|
|
1244
|
+
)
|
|
1245
|
+
return state
|
|
1246
|
+
|
|
1247
|
+
def _survey_as_wordpress(
|
|
1248
|
+
self,
|
|
1249
|
+
connection: Connection,
|
|
1250
|
+
location: DatabaseLocation,
|
|
1251
|
+
wordpress_user: str | None,
|
|
1252
|
+
table_prefix: str,
|
|
1253
|
+
) -> Survey | None:
|
|
1254
|
+
"""The WordPress account's survey; ``None`` when it cannot log in.
|
|
1255
|
+
|
|
1256
|
+
A login or access failure says nothing about the database (AD-29):
|
|
1257
|
+
``None`` here is "not observed", never "absent".
|
|
1258
|
+
"""
|
|
1259
|
+
from l3io.wp import database as wpdb
|
|
1260
|
+
|
|
1261
|
+
try:
|
|
1262
|
+
with self._mapped(
|
|
1263
|
+
location, self._credentials.source, ProvisioningFailedError
|
|
1264
|
+
):
|
|
1265
|
+
return wpdb.survey(
|
|
1266
|
+
connection,
|
|
1267
|
+
self._credentials,
|
|
1268
|
+
wordpress_user=wordpress_user,
|
|
1269
|
+
user_host=_ANY_HOST,
|
|
1270
|
+
table_prefix=table_prefix,
|
|
1271
|
+
)
|
|
1272
|
+
except CredentialsError:
|
|
1273
|
+
return None
|
|
1274
|
+
|
|
1275
|
+
def _prefixed_tables(
|
|
1276
|
+
self,
|
|
1277
|
+
connection: Connection,
|
|
1278
|
+
location: DatabaseLocation,
|
|
1279
|
+
observer: CredentialSource,
|
|
1280
|
+
table_prefix: str,
|
|
1281
|
+
) -> tuple[str, ...] | None:
|
|
1282
|
+
"""Database's ``check_drop_tables`` list; ``None`` when it is refused."""
|
|
1283
|
+
from l3io.wp import database as wpdb
|
|
1284
|
+
|
|
1285
|
+
with self._mapped(location, observer.source, ProvisioningFailedError):
|
|
1286
|
+
result = wpdb.check_drop_tables(connection, observer, prefix=table_prefix)
|
|
1287
|
+
return tuple(result.tables) if result.verdict.ok else None
|
|
1288
|
+
|
|
1289
|
+
def check_ensure(self, location: DatabaseLocation) -> Readiness:
|
|
1290
|
+
"""Database's ``check_ensure``, as finding codes."""
|
|
1291
|
+
admin = self._require_admin()
|
|
1292
|
+
_require_database()
|
|
1293
|
+
from l3io.wp import database as wpdb
|
|
1294
|
+
|
|
1295
|
+
connection = self._connection(location)
|
|
1296
|
+
with self._mapped(location, admin.source, ProvisioningFailedError):
|
|
1297
|
+
verdict = wpdb.check_ensure(
|
|
1298
|
+
connection, admin, self._credentials, user_host=_ANY_HOST
|
|
1299
|
+
)
|
|
1300
|
+
return _readiness(verdict)
|
|
1301
|
+
|
|
1302
|
+
def check_load(
|
|
1303
|
+
self,
|
|
1304
|
+
location: DatabaseLocation,
|
|
1305
|
+
*,
|
|
1306
|
+
source: Path | None,
|
|
1307
|
+
replace: bool = False,
|
|
1308
|
+
prune_prefix: str | None = None,
|
|
1309
|
+
) -> Readiness:
|
|
1310
|
+
"""Database's ``check_load`` for the WordPress account.
|
|
1311
|
+
|
|
1312
|
+
``replace`` passes database's ``replace=<the database's name>``;
|
|
1313
|
+
``prune_prefix`` its ``prune_prefix`` (owner decision 53). No
|
|
1314
|
+
administrator: the check answers as the account the load runs as
|
|
1315
|
+
(BL-E002-104), so a failure to run it is :class:`LoadReadinessError`
|
|
1316
|
+
(``NOT_READY``) naming that account's credential source.
|
|
1317
|
+
"""
|
|
1318
|
+
_require_database()
|
|
1319
|
+
from l3io.wp import database as wpdb
|
|
1320
|
+
|
|
1321
|
+
connection = self._connection(location)
|
|
1322
|
+
with self._mapped(location, self._credentials.source, LoadReadinessError):
|
|
1323
|
+
verdict = wpdb.check_load(
|
|
1324
|
+
connection,
|
|
1325
|
+
self._credentials,
|
|
1326
|
+
source=source,
|
|
1327
|
+
replace=location.name if replace else None,
|
|
1328
|
+
prune_prefix=prune_prefix,
|
|
1329
|
+
)
|
|
1330
|
+
return _readiness(verdict)
|
|
1331
|
+
|
|
1332
|
+
def ensure_database(self, location: DatabaseLocation) -> EnsureOutcome:
|
|
1333
|
+
"""Database's ``ensure``: the database, the account, its one grant.
|
|
1334
|
+
|
|
1335
|
+
The grant's database name is escaped by database (AD-28); this
|
|
1336
|
+
package writes no ``GRANT`` and quotes no identifier.
|
|
1337
|
+
"""
|
|
1338
|
+
admin = self._require_admin()
|
|
1339
|
+
_require_database()
|
|
1340
|
+
from l3io.wp import database as wpdb
|
|
1341
|
+
|
|
1342
|
+
connection = self._connection(location)
|
|
1343
|
+
started = time.monotonic_ns()
|
|
1344
|
+
# Database folds an unobtainable WordPress credential into a refusal
|
|
1345
|
+
# that names no source; resolving it first lets the error name the
|
|
1346
|
+
# WordPress source, not the administrator's: a credential error always
|
|
1347
|
+
# names the source that failed.
|
|
1348
|
+
with self._mapped(location, self._credentials.source, ProvisioningFailedError):
|
|
1349
|
+
self._credentials.resolve()
|
|
1350
|
+
with self._mapped(location, admin.source, ProvisioningFailedError):
|
|
1351
|
+
result = wpdb.ensure(
|
|
1352
|
+
connection, admin, self._credentials, user_host=_ANY_HOST
|
|
1353
|
+
)
|
|
1354
|
+
_, warnings = _codes(result.verdict)
|
|
1355
|
+
outcome = EnsureOutcome(
|
|
1356
|
+
created_database=result.created_database,
|
|
1357
|
+
created_user=result.created_user,
|
|
1358
|
+
password_set=result.password_set,
|
|
1359
|
+
granted=result.granted,
|
|
1360
|
+
warnings=warnings,
|
|
1361
|
+
performed=True,
|
|
1362
|
+
)
|
|
1363
|
+
_completed(
|
|
1364
|
+
"database_ensured",
|
|
1365
|
+
location,
|
|
1366
|
+
started,
|
|
1367
|
+
created_database=outcome.created_database,
|
|
1368
|
+
created_user=outcome.created_user,
|
|
1369
|
+
password_set=outcome.password_set,
|
|
1370
|
+
granted=outcome.granted,
|
|
1371
|
+
warnings=warnings,
|
|
1372
|
+
)
|
|
1373
|
+
return outcome
|
|
1374
|
+
|
|
1375
|
+
def load_dump(
|
|
1376
|
+
self,
|
|
1377
|
+
location: DatabaseLocation,
|
|
1378
|
+
source: IO[bytes] | Path,
|
|
1379
|
+
*,
|
|
1380
|
+
replace: bool = False,
|
|
1381
|
+
prune_prefix: str | None = None,
|
|
1382
|
+
) -> LoadResult:
|
|
1383
|
+
"""Database's ``load`` as the WordPress account.
|
|
1384
|
+
|
|
1385
|
+
No decryption argument: the Dump is plain inside the payload
|
|
1386
|
+
(ADR-0024). Without ``replace`` a non-empty target is refused by
|
|
1387
|
+
database (``database_not_empty``) and raised as
|
|
1388
|
+
``DatabaseRefusedError``. With it (owner decisions 49 and 53) the Dump
|
|
1389
|
+
loads over the database, after database dropped every table and view
|
|
1390
|
+
whose name starts with ``prune_prefix``; their names are
|
|
1391
|
+
``LoadResult.dropped``.
|
|
1392
|
+
"""
|
|
1393
|
+
_require_database()
|
|
1394
|
+
from l3io.wp import database as wpdb
|
|
1395
|
+
|
|
1396
|
+
connection = self._connection(location)
|
|
1397
|
+
started = time.monotonic_ns()
|
|
1398
|
+
with self._mapped(location, self._credentials.source, LoadFailedError):
|
|
1399
|
+
result = wpdb.load(
|
|
1400
|
+
connection,
|
|
1401
|
+
self._credentials,
|
|
1402
|
+
source,
|
|
1403
|
+
replace=location.name if replace else None,
|
|
1404
|
+
prune_prefix=prune_prefix,
|
|
1405
|
+
options=_LOAD_OPTIONS,
|
|
1406
|
+
)
|
|
1407
|
+
dropped = tuple(result.dropped)
|
|
1408
|
+
_completed(
|
|
1409
|
+
"dump_loaded", location, started, bytes=result.bytes, dropped=len(dropped)
|
|
1410
|
+
)
|
|
1411
|
+
return LoadResult(
|
|
1412
|
+
bytes=result.bytes,
|
|
1413
|
+
duration_ms=result.duration_ms,
|
|
1414
|
+
performed=True,
|
|
1415
|
+
dropped=dropped,
|
|
1416
|
+
)
|
|
1417
|
+
|
|
1418
|
+
def drop_tables(
|
|
1419
|
+
self, location: DatabaseLocation, *, prefix: str
|
|
1420
|
+
) -> tuple[str, ...]:
|
|
1421
|
+
"""Database's ``drop_tables`` as the WordPress account; the names dropped."""
|
|
1422
|
+
_require_database()
|
|
1423
|
+
from l3io.wp import database as wpdb
|
|
1424
|
+
|
|
1425
|
+
connection = self._connection(location)
|
|
1426
|
+
started = time.monotonic_ns()
|
|
1427
|
+
with self._mapped(location, self._credentials.source, LoadFailedError):
|
|
1428
|
+
result = wpdb.drop_tables(connection, self._credentials, prefix=prefix)
|
|
1429
|
+
dropped = tuple(result.tables)
|
|
1430
|
+
_completed("tables_dropped", location, started, dropped=len(dropped))
|
|
1431
|
+
return dropped
|
|
1432
|
+
|
|
1433
|
+
def drop_database(self, location: DatabaseLocation, *, confirm: str) -> bool:
|
|
1434
|
+
"""Database's ``drop_database``; ``False`` for an absent database.
|
|
1435
|
+
|
|
1436
|
+
Database's warnings (``live_wordpress_site``) are logged, not dropped.
|
|
1437
|
+
"""
|
|
1438
|
+
admin = self._require_admin()
|
|
1439
|
+
_require_database()
|
|
1440
|
+
from l3io.wp import database as wpdb
|
|
1441
|
+
|
|
1442
|
+
connection = self._connection(location)
|
|
1443
|
+
started = time.monotonic_ns()
|
|
1444
|
+
with self._mapped(location, admin.source, ProvisioningFailedError):
|
|
1445
|
+
result = wpdb.drop_database(connection, admin, confirm=confirm)
|
|
1446
|
+
_, warnings = _codes(result.verdict)
|
|
1447
|
+
if warnings:
|
|
1448
|
+
_log.warning(
|
|
1449
|
+
"database_drop_warnings",
|
|
1450
|
+
db_host=location.host,
|
|
1451
|
+
db_port=location.port,
|
|
1452
|
+
db_name=location.name,
|
|
1453
|
+
warnings=warnings,
|
|
1454
|
+
)
|
|
1455
|
+
_completed(
|
|
1456
|
+
"database_dropped",
|
|
1457
|
+
location,
|
|
1458
|
+
started,
|
|
1459
|
+
changed=result.changed,
|
|
1460
|
+
warnings=warnings,
|
|
1461
|
+
)
|
|
1462
|
+
return result.changed
|
|
1463
|
+
|
|
1464
|
+
@contextmanager
|
|
1465
|
+
def _mapped(
|
|
1466
|
+
self,
|
|
1467
|
+
location: DatabaseLocation,
|
|
1468
|
+
credential_source: str,
|
|
1469
|
+
failed: _Failed,
|
|
1470
|
+
) -> Iterator[None]:
|
|
1471
|
+
"""Re-raise database's errors as the package's own (AD-14).
|
|
1472
|
+
|
|
1473
|
+
Structured fields only, never the dependency's text (AD-7):
|
|
1474
|
+
``completed`` steps, an exit code, or the failure class.
|
|
1475
|
+
"""
|
|
1476
|
+
from l3io.wp import database as wpdb
|
|
1477
|
+
|
|
1478
|
+
try:
|
|
1479
|
+
yield
|
|
1480
|
+
except wpdb.RefusedError as exc:
|
|
1481
|
+
raise self._refusal(
|
|
1482
|
+
location, exc.verdict, credential_source=credential_source
|
|
1483
|
+
) from exc
|
|
1484
|
+
except wpdb.ProvisioningError as exc:
|
|
1485
|
+
raise ProvisioningFailedError(
|
|
1486
|
+
type(exc).__name__, completed=exc.completed
|
|
1487
|
+
) from exc
|
|
1488
|
+
except wpdb.TransferError as exc:
|
|
1489
|
+
code = exc.exit_code
|
|
1490
|
+
raise LoadFailedError(
|
|
1491
|
+
"the load stopped" if code is None else f"the load exited {code}",
|
|
1492
|
+
database=location.name,
|
|
1493
|
+
exit_status=code,
|
|
1494
|
+
) from exc
|
|
1495
|
+
except (wpdb.CorruptDumpError, wpdb.DecryptionError) as exc:
|
|
1496
|
+
raise LoadFailedError(type(exc).__name__, database=location.name) from exc
|
|
1497
|
+
except (
|
|
1498
|
+
wpdb.TlsHandshakeError,
|
|
1499
|
+
wpdb.TlsVerificationError,
|
|
1500
|
+
wpdb.AuthPluginRefusedError,
|
|
1501
|
+
) as exc:
|
|
1502
|
+
raise _tls_error(exc, location) from exc
|
|
1503
|
+
except wpdb.DatabaseUnreachableError as exc:
|
|
1504
|
+
raise DatabaseUnreachableError(location.host, location.port) from exc
|
|
1505
|
+
except (
|
|
1506
|
+
wpdb.AuthenticationFailedError,
|
|
1507
|
+
wpdb.AccessDeniedError,
|
|
1508
|
+
wpdb.CredentialsUnobtainableError,
|
|
1509
|
+
wpdb.CredentialsAbsentError,
|
|
1510
|
+
) as exc:
|
|
1511
|
+
raise CredentialsError(credential_source) from exc
|
|
1512
|
+
except wpdb.MissingDependencyError as exc:
|
|
1513
|
+
raise _missing_dependency(exc) from exc
|
|
1514
|
+
except wpdb.InvalidIdentifierError as exc:
|
|
1515
|
+
raise InvalidSiteDescriptorError(_unusable_connection(exc)) from exc
|
|
1516
|
+
except wpdb.DatabaseError as exc:
|
|
1517
|
+
raise _failure(
|
|
1518
|
+
failed, type(exc).__name__, location, credential_source
|
|
1519
|
+
) from exc
|
|
1520
|
+
except OSError as exc:
|
|
1521
|
+
raise _failure(
|
|
1522
|
+
failed, exc.strerror or type(exc).__name__, location, credential_source
|
|
1523
|
+
) from exc
|
|
1524
|
+
|
|
1525
|
+
def _refusal(
|
|
1526
|
+
self,
|
|
1527
|
+
location: DatabaseLocation,
|
|
1528
|
+
verdict: Verdict,
|
|
1529
|
+
*,
|
|
1530
|
+
credential_source: str | None = None,
|
|
1531
|
+
) -> Exception:
|
|
1532
|
+
blockers, _ = _codes(verdict)
|
|
1533
|
+
label = credential_source or self._credentials.source
|
|
1534
|
+
error = blocked_dump_error(location, blockers, credential_source=label)
|
|
1535
|
+
return DatabaseRefusedError(findings=blockers) if error is None else error
|
|
1536
|
+
|
|
1537
|
+
def _connection(self, location: DatabaseLocation) -> Connection:
|
|
1538
|
+
"""The one place a database ``Connection`` is built from a location.
|
|
1539
|
+
|
|
1540
|
+
TLS is what the bound ``db_tls`` asks for (``UsageError`` for settings
|
|
1541
|
+
no connection could use, before any connection); with none bound, or
|
|
1542
|
+
one with no mode, no TLS is required (owner decision 56).
|
|
1543
|
+
"""
|
|
1544
|
+
from l3io.wp import database as wpdb
|
|
1545
|
+
|
|
1546
|
+
tls = None if self._db_tls is None else _tls_settings(self._db_tls)
|
|
1547
|
+
try:
|
|
1548
|
+
return wpdb.Connection(
|
|
1549
|
+
host=location.host,
|
|
1550
|
+
database=location.name,
|
|
1551
|
+
port=location.port,
|
|
1552
|
+
tls=wpdb.TlsSettings() if tls is None else tls,
|
|
1553
|
+
)
|
|
1554
|
+
except (wpdb.InvalidArgumentsError, wpdb.InvalidIdentifierError) as exc:
|
|
1555
|
+
raise InvalidSiteDescriptorError(_unusable_connection(exc)) from exc
|