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.
Files changed (98) hide show
  1. l3io/wp/backup/__init__.py +22 -0
  2. l3io/wp/backup/_version.py +31 -0
  3. l3io/wp/backup/adapters/__init__.py +9 -0
  4. l3io/wp/backup/adapters/clock.py +22 -0
  5. l3io/wp/backup/adapters/config_writer.py +702 -0
  6. l3io/wp/backup/adapters/credential_providers.py +236 -0
  7. l3io/wp/backup/adapters/crypto.py +237 -0
  8. l3io/wp/backup/adapters/database.py +1555 -0
  9. l3io/wp/backup/adapters/database_load.py +1263 -0
  10. l3io/wp/backup/adapters/exclusions.py +83 -0
  11. l3io/wp/backup/adapters/journal.py +507 -0
  12. l3io/wp/backup/adapters/lock.py +372 -0
  13. l3io/wp/backup/adapters/noop.py +666 -0
  14. l3io/wp/backup/adapters/phase.py +218 -0
  15. l3io/wp/backup/adapters/preflight.py +869 -0
  16. l3io/wp/backup/adapters/redaction.py +237 -0
  17. l3io/wp/backup/adapters/restore_extension.py +341 -0
  18. l3io/wp/backup/adapters/restore_runner.py +1030 -0
  19. l3io/wp/backup/adapters/site_files.py +191 -0
  20. l3io/wp/backup/adapters/store.py +227 -0
  21. l3io/wp/backup/adapters/tar.py +1648 -0
  22. l3io/wp/backup/adapters/tracing.py +235 -0
  23. l3io/wp/backup/adapters/workspace.py +299 -0
  24. l3io/wp/backup/adapters/wpcli.py +584 -0
  25. l3io/wp/backup/adapters/wpcli_child.py +135 -0
  26. l3io/wp/backup/api/__init__.py +104 -0
  27. l3io/wp/backup/api/errors.py +344 -0
  28. l3io/wp/backup/api/operations.py +427 -0
  29. l3io/wp/backup/cli/__init__.py +228 -0
  30. l3io/wp/backup/cli/adapter_sets.py +255 -0
  31. l3io/wp/backup/cli/backup.py +1118 -0
  32. l3io/wp/backup/cli/context.py +382 -0
  33. l3io/wp/backup/cli/credentials.py +310 -0
  34. l3io/wp/backup/cli/doctor.py +171 -0
  35. l3io/wp/backup/cli/exit.py +171 -0
  36. l3io/wp/backup/cli/logging.py +147 -0
  37. l3io/wp/backup/cli/output.py +111 -0
  38. l3io/wp/backup/cli/parser.py +853 -0
  39. l3io/wp/backup/cli/passphrase.py +301 -0
  40. l3io/wp/backup/cli/preflight.py +227 -0
  41. l3io/wp/backup/cli/privileges.py +327 -0
  42. l3io/wp/backup/cli/restore.py +1571 -0
  43. l3io/wp/backup/cli/restore_report.py +396 -0
  44. l3io/wp/backup/cli/restore_request.py +560 -0
  45. l3io/wp/backup/cli/scope.py +31 -0
  46. l3io/wp/backup/cli/settings.py +524 -0
  47. l3io/wp/backup/cli/verify.py +286 -0
  48. l3io/wp/backup/core/__init__.py +9 -0
  49. l3io/wp/backup/core/archive_choice.py +156 -0
  50. l3io/wp/backup/core/backup.py +105 -0
  51. l3io/wp/backup/core/config_location.py +257 -0
  52. l3io/wp/backup/core/exclusions.py +189 -0
  53. l3io/wp/backup/core/relocation.py +437 -0
  54. l3io/wp/backup/core/restore.py +1246 -0
  55. l3io/wp/backup/core/restore_journal.py +541 -0
  56. l3io/wp/backup/core/restore_space.py +224 -0
  57. l3io/wp/backup/core/verify.py +198 -0
  58. l3io/wp/backup/domain/__init__.py +9 -0
  59. l3io/wp/backup/domain/archive_format.py +1072 -0
  60. l3io/wp/backup/domain/backup_scope.py +21 -0
  61. l3io/wp/backup/domain/config.py +27 -0
  62. l3io/wp/backup/domain/credentials.py +125 -0
  63. l3io/wp/backup/domain/database.py +113 -0
  64. l3io/wp/backup/domain/errors.py +1434 -0
  65. l3io/wp/backup/domain/exclusion.py +74 -0
  66. l3io/wp/backup/domain/exit_codes.py +87 -0
  67. l3io/wp/backup/domain/flag_map.py +246 -0
  68. l3io/wp/backup/domain/journal.py +142 -0
  69. l3io/wp/backup/domain/lock.py +61 -0
  70. l3io/wp/backup/domain/preflight.py +126 -0
  71. l3io/wp/backup/domain/restore.py +181 -0
  72. l3io/wp/backup/domain/search_replace.py +59 -0
  73. l3io/wp/backup/domain/secret.py +61 -0
  74. l3io/wp/backup/domain/site.py +60 -0
  75. l3io/wp/backup/domain/storage_target.py +60 -0
  76. l3io/wp/backup/ports/__init__.py +33 -0
  77. l3io/wp/backup/ports/adapter_set.py +66 -0
  78. l3io/wp/backup/ports/archive.py +136 -0
  79. l3io/wp/backup/ports/clock.py +23 -0
  80. l3io/wp/backup/ports/config.py +71 -0
  81. l3io/wp/backup/ports/crypto.py +30 -0
  82. l3io/wp/backup/ports/database.py +129 -0
  83. l3io/wp/backup/ports/exclusion.py +33 -0
  84. l3io/wp/backup/ports/journal.py +53 -0
  85. l3io/wp/backup/ports/lock.py +48 -0
  86. l3io/wp/backup/ports/preflight.py +23 -0
  87. l3io/wp/backup/ports/restore.py +59 -0
  88. l3io/wp/backup/ports/search_replace.py +40 -0
  89. l3io/wp/backup/ports/site_files.py +95 -0
  90. l3io/wp/backup/ports/tracing.py +80 -0
  91. l3io/wp/backup/py.typed +0 -0
  92. l3io_wordpress_backup-1.0.0.dist-info/METADATA +593 -0
  93. l3io_wordpress_backup-1.0.0.dist-info/RECORD +98 -0
  94. l3io_wordpress_backup-1.0.0.dist-info/WHEEL +4 -0
  95. l3io_wordpress_backup-1.0.0.dist-info/entry_points.txt +2 -0
  96. l3io_wordpress_backup-1.0.0.dist-info/licenses/LICENSE +21 -0
  97. wpbackup2/__init__.py +7 -0
  98. 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