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,702 @@
|
|
|
1
|
+
"""AD-18.2/AD-18.4 — the one writer of ``wp-config.php`` (ADR-0010, ADR-0023).
|
|
2
|
+
|
|
3
|
+
:class:`WpConfigWriter` implements ``ConfigQuery`` and ``ConfigCommand`` over
|
|
4
|
+
``l3io-wordpress-config`` (a required dependency, owner decision 76, imported
|
|
5
|
+
inside the functions that use it; an installation that lacks it anyway is
|
|
6
|
+
``IncompleteInstallError``). This is the only module in the package that
|
|
7
|
+
imports ``l3io.wp.config``; :func:`read_defined` is the reader
|
|
8
|
+
``adapters/database.py`` uses for ``WP_SITEURL``/``WP_HOME``.
|
|
9
|
+
|
|
10
|
+
``set_constants`` and ``replace_file`` write no config bytes themselves: each
|
|
11
|
+
key is one ``WpConfigFile.set``, and the ``--overwrite-wp-config`` replacement
|
|
12
|
+
(the Archive copy's text, handed over in memory, with the target's ``keep``
|
|
13
|
+
definitions carried in as source through ``WpConfigString.set_source``, see
|
|
14
|
+
:func:`carry_sources`) is one ``WpConfigFile.replace_content`` — the library's
|
|
15
|
+
atomic, read-back-verified write that keeps the file's mode and owner and
|
|
16
|
+
fsyncs the file and its directory (0.1.4, BL-E002-091). The module's one own
|
|
17
|
+
write is ``save_copy`` (a ``0600`` fsynced copy of the target's config), which
|
|
18
|
+
that package has no call for. Nothing translates a newline, so CRLF stays CRLF
|
|
19
|
+
byte for byte.
|
|
20
|
+
|
|
21
|
+
Every ``DB_PASSWORD`` value this module is handed or reads is registered with
|
|
22
|
+
the Run's ``SecretRegistry`` before anything else happens to it; records name
|
|
23
|
+
constant **keys**, never values.
|
|
24
|
+
|
|
25
|
+
**Values from the environment (owner decision 47).** :func:`read_settings`
|
|
26
|
+
and ``read_constants`` read a key through ``get_value``/``get_variable_value``
|
|
27
|
+
(0.1.5): a literal is used as written; an ``EnvLookup``
|
|
28
|
+
(``getenv_docker('NAME', <literal>)`` or ``getenv('NAME')``, as the official
|
|
29
|
+
``wordpress`` image writes them) is resolved by the library's
|
|
30
|
+
``EnvLookup.resolve`` against the Run's ``environ`` mapping, with
|
|
31
|
+
:func:`read_secret_file` for a ``NAME_FILE`` Docker secret; anything else is
|
|
32
|
+
an ``Expression`` and is refused (``UnsupportedConfigValueError``) without
|
|
33
|
+
quoting it. Nothing here parses PHP or reimplements a fallback, and no adapter
|
|
34
|
+
reads ``os.environ`` (BL-E005-007): the composition roots hand ``environ`` in.
|
|
35
|
+
:func:`refuse_environment_lookups` is a database restore's early refusal of an
|
|
36
|
+
environment-driven target (ADR-0027, Proposed).
|
|
37
|
+
|
|
38
|
+
**Definitions carried as source (owner decision 48, BL-E008-032).**
|
|
39
|
+
``--overwrite-wp-config`` carries the target's ``DB_*`` into the Archive's
|
|
40
|
+
text as the source the target wrote (``get_source``/``set_source``, 0.1.6), so
|
|
41
|
+
``getenv_docker('WORDPRESS_DB_HOST', 'mysql')`` stays a lookup. A carried
|
|
42
|
+
``getenv_docker()`` call needs the Archive's text to declare that function
|
|
43
|
+
before the first carried define; the library answers that
|
|
44
|
+
(``declares_function(name, before=key)``, 0.1.7), a lexical query on its own
|
|
45
|
+
scanner whose limits it documents: a declaration in a branch PHP never takes,
|
|
46
|
+
a class method or a nested function still counts; an ``include``/``require``
|
|
47
|
+
is not followed; ``before`` is text order; heredocs are not recognised.
|
|
48
|
+
Nothing here reads the source text.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
from __future__ import annotations
|
|
52
|
+
|
|
53
|
+
import os
|
|
54
|
+
import shutil
|
|
55
|
+
import tempfile
|
|
56
|
+
import time
|
|
57
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
58
|
+
from pathlib import Path
|
|
59
|
+
from types import MappingProxyType
|
|
60
|
+
from typing import TYPE_CHECKING, Final
|
|
61
|
+
|
|
62
|
+
import structlog
|
|
63
|
+
|
|
64
|
+
from l3io.wp.backup.adapters.redaction import SecretRegistry
|
|
65
|
+
from l3io.wp.backup.adapters.workspace import private_directories
|
|
66
|
+
from l3io.wp.backup.domain.config import ConfigWriteResult, ConstantValue
|
|
67
|
+
from l3io.wp.backup.domain.errors import (
|
|
68
|
+
BackupError,
|
|
69
|
+
ConfigWriteFailedError,
|
|
70
|
+
CredentialsError,
|
|
71
|
+
EnvironmentDrivenTargetError,
|
|
72
|
+
IncompleteInstallError,
|
|
73
|
+
InvalidSiteDescriptorError,
|
|
74
|
+
UndefinedConfigFunctionError,
|
|
75
|
+
UnsupportedConfigValueError,
|
|
76
|
+
)
|
|
77
|
+
from l3io.wp.backup.ports.config import WHOLE_FILE
|
|
78
|
+
|
|
79
|
+
if TYPE_CHECKING:
|
|
80
|
+
from l3io.wp.config import EnvLookup, WpConfigFile
|
|
81
|
+
|
|
82
|
+
__all__ = [
|
|
83
|
+
"TABLE_PREFIX",
|
|
84
|
+
"WpConfigWriter",
|
|
85
|
+
"carry_sources",
|
|
86
|
+
"read_defined",
|
|
87
|
+
"read_secret_file",
|
|
88
|
+
"read_settings",
|
|
89
|
+
"refuse_environment_lookups",
|
|
90
|
+
"refuse_uncarriable",
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
_log = structlog.get_logger(__name__)
|
|
94
|
+
|
|
95
|
+
_DISTRIBUTION: Final = "l3io-wordpress-config"
|
|
96
|
+
_NS_PER_MS: Final = 1_000_000
|
|
97
|
+
_PASSWORD_KEY: Final = "DB_PASSWORD" # noqa: S105 - a constant name
|
|
98
|
+
_TMP_SUFFIX: Final = ".l3io-tmp"
|
|
99
|
+
#: The text ``l3io-wordpress-config`` raises with when the owner cannot be kept.
|
|
100
|
+
_OWNER_MARKER: Final = "cannot keep the file's owner"
|
|
101
|
+
_OWNER_REASON: Final = (
|
|
102
|
+
"the file's owner cannot be kept (run as that owner, or as root); "
|
|
103
|
+
"the file was not changed"
|
|
104
|
+
)
|
|
105
|
+
_READ_BACK_REASON: Final = "read-back verification failed; the file was not changed"
|
|
106
|
+
_ABSENT_REASON: Final = "wp-config.php does not exist, and is never created here"
|
|
107
|
+
_UNDEFINED_REASON: Final = "the target does not define it, so it cannot be kept"
|
|
108
|
+
#: A carried source ``set_source`` refused. Fixed: the source is never quoted.
|
|
109
|
+
_UNCARRIABLE_REASON: Final = (
|
|
110
|
+
"its definition cannot be carried into the Archive's wp-config.php as "
|
|
111
|
+
"written; the file was not changed"
|
|
112
|
+
)
|
|
113
|
+
#: The official image's helper, the one lookup function PHP does not define.
|
|
114
|
+
_DOCKER_FUNCTION: Final = "getenv_docker"
|
|
115
|
+
#: ``$table_prefix``, named without its ``$`` as the library names a variable.
|
|
116
|
+
TABLE_PREFIX: Final = "table_prefix"
|
|
117
|
+
#: The keys whose lookup failure is a credentials failure, not a site one.
|
|
118
|
+
_CREDENTIAL_KEYS: Final = frozenset({"DB_USER", _PASSWORD_KEY})
|
|
119
|
+
#: The longest ``NAME_FILE`` this reads, in characters: a Docker secret is one
|
|
120
|
+
#: short value, and a larger file is refused rather than read whole.
|
|
121
|
+
SECRET_FILE_LIMIT: Final = 64 * 1024
|
|
122
|
+
_NO_ENVIRONMENT: Final[Mapping[str, str]] = MappingProxyType({})
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _require_config() -> None:
|
|
126
|
+
try:
|
|
127
|
+
import l3io.wp.config # noqa: F401 - imported for its presence only
|
|
128
|
+
except ImportError as exc:
|
|
129
|
+
raise IncompleteInstallError(_DISTRIBUTION) from exc
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _constant(value: object) -> ConstantValue:
|
|
133
|
+
"""The library's value as a ``ConstantValue``; a float as its literal text."""
|
|
134
|
+
if value is None or isinstance(value, (str, int, bool)):
|
|
135
|
+
return value
|
|
136
|
+
return repr(value)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def read_defined(
|
|
140
|
+
config_file: Path, keys: Sequence[str], *, label: str
|
|
141
|
+
) -> dict[str, ConstantValue]:
|
|
142
|
+
"""``keys``' values in ``config_file``; ``None`` for an undefined one.
|
|
143
|
+
|
|
144
|
+
An unreadable file is ``CredentialsError(label)``: ``wp-config.php`` is
|
|
145
|
+
the credential provider whose file could not be read.
|
|
146
|
+
"""
|
|
147
|
+
_require_config()
|
|
148
|
+
from l3io.wp.config import MISSING, WpConfigError, WpConfigFile
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
config = WpConfigFile(config_file)
|
|
152
|
+
raw = {key: config.get(key) for key in keys}
|
|
153
|
+
except WpConfigError as exc:
|
|
154
|
+
raise CredentialsError(label, reason="wp-config.php is unreadable") from exc
|
|
155
|
+
return {key: None if v is MISSING else _constant(v) for key, v in raw.items()}
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _shown(key: str) -> str:
|
|
159
|
+
"""``key`` as an operator reads it: ``$table_prefix`` for the variable."""
|
|
160
|
+
return f"${key}" if key == TABLE_PREFIX else key
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def read_secret_file(path: str) -> str:
|
|
164
|
+
"""The text of the file a ``NAME_FILE`` variable names (a Docker secret).
|
|
165
|
+
|
|
166
|
+
``EnvLookup.resolve``'s ``read_file``: it strips the trailing newline
|
|
167
|
+
itself. A file over :data:`SECRET_FILE_LIMIT` characters is a
|
|
168
|
+
``ValueError`` carrying no text; the library names the failure by class.
|
|
169
|
+
"""
|
|
170
|
+
with Path(path).open(encoding="utf-8") as handle:
|
|
171
|
+
text = handle.read(SECRET_FILE_LIMIT + 1)
|
|
172
|
+
if len(text) > SECRET_FILE_LIMIT:
|
|
173
|
+
raise ValueError
|
|
174
|
+
return text
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _lookup_failed(
|
|
178
|
+
key: str, variable: str, reason: str, *, config_file: Path, label: str
|
|
179
|
+
) -> BackupError:
|
|
180
|
+
"""A lookup that gave no value: credentials for ``DB_USER``/``DB_PASSWORD``."""
|
|
181
|
+
why = f"{_shown(key)} reads the environment variable {variable}: {reason}"
|
|
182
|
+
if key in _CREDENTIAL_KEYS:
|
|
183
|
+
return CredentialsError(label, reason=why)
|
|
184
|
+
return InvalidSiteDescriptorError(why, paths=(str(config_file),))
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _resolve(
|
|
188
|
+
key: str,
|
|
189
|
+
lookup: EnvLookup,
|
|
190
|
+
*,
|
|
191
|
+
environ: Mapping[str, str],
|
|
192
|
+
config_file: Path,
|
|
193
|
+
label: str,
|
|
194
|
+
) -> str:
|
|
195
|
+
"""``lookup``'s value as PHP would define it, as text; never logged.
|
|
196
|
+
|
|
197
|
+
The library's ``ConfigLookupError`` names the variable only; it is turned
|
|
198
|
+
into this package's error outside its handler, so neither it nor what the
|
|
199
|
+
file reader raised (a ``UnicodeDecodeError`` holds the file's bytes) is
|
|
200
|
+
chained. A lookup PHP resolves to ``false`` (a bare ``getenv('NAME')``
|
|
201
|
+
of an unset variable) defines no usable setting and is refused as unset.
|
|
202
|
+
"""
|
|
203
|
+
from l3io.wp.config import ConfigLookupError
|
|
204
|
+
|
|
205
|
+
failure: str | None = None
|
|
206
|
+
value: object = None
|
|
207
|
+
try:
|
|
208
|
+
value = lookup.resolve(environ, read_file=read_secret_file)
|
|
209
|
+
except ConfigLookupError as exc:
|
|
210
|
+
failure = exc.reason
|
|
211
|
+
if failure is not None:
|
|
212
|
+
raise _lookup_failed(
|
|
213
|
+
key, lookup.name, failure, config_file=config_file, label=label
|
|
214
|
+
)
|
|
215
|
+
if value is False:
|
|
216
|
+
raise _lookup_failed(
|
|
217
|
+
key, lookup.name, "it is not set", config_file=config_file, label=label
|
|
218
|
+
)
|
|
219
|
+
_log.info(
|
|
220
|
+
"config_value_from_environment",
|
|
221
|
+
path=str(config_file),
|
|
222
|
+
key=_shown(key),
|
|
223
|
+
variable=lookup.name,
|
|
224
|
+
function=lookup.function,
|
|
225
|
+
)
|
|
226
|
+
return value if isinstance(value, str) else str(value)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _read_values(
|
|
230
|
+
config_file: Path,
|
|
231
|
+
keys: Sequence[str],
|
|
232
|
+
*,
|
|
233
|
+
environ: Mapping[str, str],
|
|
234
|
+
label: str,
|
|
235
|
+
secrets: SecretRegistry | None,
|
|
236
|
+
) -> dict[str, ConstantValue]:
|
|
237
|
+
"""``keys``' values, an ``EnvLookup`` resolved; ``None`` for an undefined one.
|
|
238
|
+
|
|
239
|
+
:data:`TABLE_PREFIX` is read as the variable. A literal keeps its PHP
|
|
240
|
+
type; a resolved lookup is text. A resolved ``DB_PASSWORD`` is registered
|
|
241
|
+
with ``secrets`` before anything else happens to it.
|
|
242
|
+
"""
|
|
243
|
+
_require_config()
|
|
244
|
+
from l3io.wp.config import (
|
|
245
|
+
MISSING,
|
|
246
|
+
EnvLookup,
|
|
247
|
+
Expression,
|
|
248
|
+
WpConfigError,
|
|
249
|
+
WpConfigFile,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
try:
|
|
253
|
+
config = WpConfigFile(config_file)
|
|
254
|
+
raw = {
|
|
255
|
+
key: config.get_variable_value(key)
|
|
256
|
+
if key == TABLE_PREFIX
|
|
257
|
+
else config.get_value(key)
|
|
258
|
+
for key in keys
|
|
259
|
+
}
|
|
260
|
+
except WpConfigError as exc:
|
|
261
|
+
raise CredentialsError(label, reason="wp-config.php is unreadable") from exc
|
|
262
|
+
for key, value in raw.items():
|
|
263
|
+
if isinstance(value, Expression):
|
|
264
|
+
raise UnsupportedConfigValueError(_shown(key), path=config_file)
|
|
265
|
+
values: dict[str, ConstantValue] = {}
|
|
266
|
+
for key, value in raw.items():
|
|
267
|
+
if value is MISSING:
|
|
268
|
+
values[key] = None
|
|
269
|
+
elif isinstance(value, EnvLookup):
|
|
270
|
+
text = _resolve(
|
|
271
|
+
key, value, environ=environ, config_file=config_file, label=label
|
|
272
|
+
)
|
|
273
|
+
if key == _PASSWORD_KEY and secrets is not None:
|
|
274
|
+
secrets.register(text, source=f"{label}:{value.name}")
|
|
275
|
+
values[key] = text
|
|
276
|
+
else:
|
|
277
|
+
values[key] = _constant(value)
|
|
278
|
+
return values
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def read_settings(
|
|
282
|
+
config_file: Path,
|
|
283
|
+
keys: Sequence[str],
|
|
284
|
+
*,
|
|
285
|
+
environ: Mapping[str, str],
|
|
286
|
+
label: str,
|
|
287
|
+
secrets: SecretRegistry | None = None,
|
|
288
|
+
) -> dict[str, str | None]:
|
|
289
|
+
"""``keys``' values as text, each ``EnvLookup`` resolved against ``environ``.
|
|
290
|
+
|
|
291
|
+
The reader of the Site's ``DB_*`` and ``$table_prefix`` (owner decision
|
|
292
|
+
47); :data:`TABLE_PREFIX` names the variable. ``None`` is an undefined
|
|
293
|
+
key. A literal that is not a string becomes its text, as database's
|
|
294
|
+
reader did. Refused: an ``Expression`` (``UnsupportedConfigValueError``,
|
|
295
|
+
``REFUSED``); a lookup that cannot be resolved -- a ``NAME_FILE`` that
|
|
296
|
+
cannot be read, a bare ``getenv`` of an unset variable -- is
|
|
297
|
+
``CredentialsError`` for ``DB_USER``/``DB_PASSWORD`` and
|
|
298
|
+
``InvalidSiteDescriptorError`` otherwise, naming the constant and the
|
|
299
|
+
variable, never a value. An unreadable file is ``CredentialsError(label)``.
|
|
300
|
+
"""
|
|
301
|
+
values = _read_values(
|
|
302
|
+
config_file, keys, environ=environ, label=label, secrets=secrets
|
|
303
|
+
)
|
|
304
|
+
return {key: None if v is None else str(v) for key, v in values.items()}
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def refuse_environment_lookups(
|
|
308
|
+
config_file: Path, keys: Sequence[str], *, operation: str, label: str
|
|
309
|
+
) -> None:
|
|
310
|
+
"""Refuse when any of ``keys`` is read from the environment (ADR-0027).
|
|
311
|
+
|
|
312
|
+
Nothing is resolved, so no value is read. ``EnvironmentDrivenTargetError``
|
|
313
|
+
names every such key and its variable, and ``operation``; an
|
|
314
|
+
``Expression`` among ``keys`` is ``UnsupportedConfigValueError``.
|
|
315
|
+
"""
|
|
316
|
+
_require_config()
|
|
317
|
+
from l3io.wp.config import EnvLookup, Expression, WpConfigError, WpConfigFile
|
|
318
|
+
|
|
319
|
+
try:
|
|
320
|
+
config = WpConfigFile(config_file)
|
|
321
|
+
raw = {key: config.get_value(key) for key in keys}
|
|
322
|
+
except WpConfigError as exc:
|
|
323
|
+
raise CredentialsError(label, reason="wp-config.php is unreadable") from exc
|
|
324
|
+
for key, value in raw.items():
|
|
325
|
+
if isinstance(value, Expression):
|
|
326
|
+
raise UnsupportedConfigValueError(_shown(key), path=config_file)
|
|
327
|
+
lookups = [(k, v.name) for k, v in raw.items() if isinstance(v, EnvLookup)]
|
|
328
|
+
if lookups:
|
|
329
|
+
raise EnvironmentDrivenTargetError(
|
|
330
|
+
keys=tuple(_shown(k) for k, _ in lookups),
|
|
331
|
+
variables=tuple(name for _, name in lookups),
|
|
332
|
+
path=config_file,
|
|
333
|
+
operation=operation,
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _set_failure(exc: Exception) -> str:
|
|
338
|
+
"""Why a ``set`` failed, from the library error's structured fields."""
|
|
339
|
+
from l3io.wp.config import (
|
|
340
|
+
ConfigKeyError,
|
|
341
|
+
ConfigValueError,
|
|
342
|
+
ConfigWriteError,
|
|
343
|
+
ReadBackError,
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
if isinstance(exc, ConfigValueError):
|
|
347
|
+
return "unrenderable"
|
|
348
|
+
if isinstance(exc, ConfigKeyError):
|
|
349
|
+
return "not a PHP identifier"
|
|
350
|
+
if isinstance(exc, ReadBackError):
|
|
351
|
+
return _READ_BACK_REASON
|
|
352
|
+
if isinstance(exc, ConfigWriteError):
|
|
353
|
+
return _OWNER_REASON if _OWNER_MARKER in exc.reason else exc.reason
|
|
354
|
+
return type(exc).__name__
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _fsync_directory(directory: Path) -> None:
|
|
358
|
+
fd = os.open(directory, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
|
359
|
+
try:
|
|
360
|
+
os.fsync(fd)
|
|
361
|
+
finally:
|
|
362
|
+
os.close(fd)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _sync_directory_after_commit(directory: Path) -> None:
|
|
366
|
+
"""Make a rename durable; logged, not raised, since the file has changed.
|
|
367
|
+
|
|
368
|
+
Raising here would report a replaced file as unchanged, as
|
|
369
|
+
``l3io-wordpress-config`` reasons for its own writes.
|
|
370
|
+
"""
|
|
371
|
+
try:
|
|
372
|
+
_fsync_directory(directory)
|
|
373
|
+
except OSError as exc:
|
|
374
|
+
_log.warning(
|
|
375
|
+
"config_directory_not_synced",
|
|
376
|
+
path=str(directory),
|
|
377
|
+
reason=exc.strerror or type(exc).__name__,
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _carriable(
|
|
382
|
+
config_file: Path, target: WpConfigFile, keep: Sequence[str]
|
|
383
|
+
) -> tuple[dict[str, str], tuple[str, ...]]:
|
|
384
|
+
"""``keep``'s sources in ``target``, and the keys calling ``getenv_docker``.
|
|
385
|
+
|
|
386
|
+
Refused: a key the target does not define (``ConfigWriteFailedError``,
|
|
387
|
+
as before 0.1.6) and an ``Expression`` (``UnsupportedConfigValueError``,
|
|
388
|
+
owner decision 47). The library tells a lookup from a literal; nothing
|
|
389
|
+
here reads the source text.
|
|
390
|
+
"""
|
|
391
|
+
from l3io.wp.config import MISSING, EnvLookup, Expression
|
|
392
|
+
|
|
393
|
+
sources: dict[str, str] = {}
|
|
394
|
+
docker: list[str] = []
|
|
395
|
+
for key in keep:
|
|
396
|
+
value = target.get_value(key)
|
|
397
|
+
source = target.get_source(key)
|
|
398
|
+
if value is MISSING or source is MISSING:
|
|
399
|
+
raise ConfigWriteFailedError(config_file, key, _UNDEFINED_REASON)
|
|
400
|
+
if isinstance(value, Expression):
|
|
401
|
+
raise UnsupportedConfigValueError(_shown(key), path=config_file)
|
|
402
|
+
if isinstance(value, EnvLookup) and value.function == _DOCKER_FUNCTION:
|
|
403
|
+
docker.append(key)
|
|
404
|
+
sources[key] = str(source)
|
|
405
|
+
return sources, tuple(docker)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _open_target(config_file: Path) -> WpConfigFile:
|
|
409
|
+
from l3io.wp.config import ConfigReadError, WpConfigFile
|
|
410
|
+
|
|
411
|
+
try:
|
|
412
|
+
return WpConfigFile(config_file)
|
|
413
|
+
except ConfigReadError as exc:
|
|
414
|
+
unread = _ABSENT_REASON if not config_file.exists() else exc.reason
|
|
415
|
+
raise ConfigWriteFailedError(config_file, WHOLE_FILE, unread) from exc
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _carry(
|
|
419
|
+
config_file: Path, target: WpConfigFile, content: str, keep: Sequence[str]
|
|
420
|
+
) -> tuple[str, tuple[str, ...]]:
|
|
421
|
+
""":func:`carry_sources` over an opened target."""
|
|
422
|
+
from l3io.wp.config import ConfigValueError, WpConfigError, WpConfigString
|
|
423
|
+
|
|
424
|
+
sources, docker = _carriable(config_file, target, keep)
|
|
425
|
+
try:
|
|
426
|
+
working = WpConfigString(content)
|
|
427
|
+
except ValueError as exc:
|
|
428
|
+
unread = f"the replacement could not be read: {type(exc).__name__}"
|
|
429
|
+
raise ConfigWriteFailedError(config_file, WHOLE_FILE, unread) from exc
|
|
430
|
+
changed: list[str] = []
|
|
431
|
+
for key, source in sources.items():
|
|
432
|
+
try:
|
|
433
|
+
if working.set_source(key, source):
|
|
434
|
+
changed.append(key)
|
|
435
|
+
except ConfigValueError:
|
|
436
|
+
# A fixed reason, not the library's: the source is the target's
|
|
437
|
+
# and is never quoted (it may hold a credential).
|
|
438
|
+
raise ConfigWriteFailedError(
|
|
439
|
+
config_file, key, _UNCARRIABLE_REASON
|
|
440
|
+
) from None
|
|
441
|
+
except WpConfigError as exc:
|
|
442
|
+
raise ConfigWriteFailedError(config_file, key, _set_failure(exc)) from exc
|
|
443
|
+
text = working.content
|
|
444
|
+
# Declared before each carried use, so before the first of them; the
|
|
445
|
+
# library's lexical query decides (0.1.7, its limits in the module doc).
|
|
446
|
+
if docker and not all(
|
|
447
|
+
working.declares_function(_DOCKER_FUNCTION, before=key) for key in docker
|
|
448
|
+
):
|
|
449
|
+
raise UndefinedConfigFunctionError(
|
|
450
|
+
function=_DOCKER_FUNCTION,
|
|
451
|
+
keys=tuple(_shown(k) for k in docker),
|
|
452
|
+
path=config_file,
|
|
453
|
+
)
|
|
454
|
+
return text, tuple(changed)
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def carry_sources(
|
|
458
|
+
config_file: Path, content: str, keep: Sequence[str]
|
|
459
|
+
) -> tuple[str, tuple[str, ...]]:
|
|
460
|
+
"""``content`` with ``config_file``'s ``keep`` definitions carried in as source.
|
|
461
|
+
|
|
462
|
+
Owner decision 48 (BL-E008-032): each key's value in the Archive's text
|
|
463
|
+
``content`` is replaced, in memory, by the target's source exactly as
|
|
464
|
+
written (``get_source``/``set_source``), a literal or an ``EnvLookup``
|
|
465
|
+
alike, so a ``getenv_docker()`` definition stays a lookup. Returns the new
|
|
466
|
+
text and the keys whose text changed; writes nothing, so the Dry Run's
|
|
467
|
+
twin calls it too. Refused, before any write: a key the target does not
|
|
468
|
+
define, and a source ``set_source`` will not write verbatim
|
|
469
|
+
(``ConfigWriteFailedError``, fixed reasons, never the source); an
|
|
470
|
+
``Expression`` (``UnsupportedConfigValueError``); a carried
|
|
471
|
+
``getenv_docker()`` the new text does not declare before it is used
|
|
472
|
+
(``UndefinedConfigFunctionError``; the library's ``declares_function``).
|
|
473
|
+
"""
|
|
474
|
+
_require_config()
|
|
475
|
+
return _carry(config_file, _open_target(config_file), content, keep)
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def refuse_uncarriable(
|
|
479
|
+
config_file: Path, keep: Sequence[str], *, archive_content: Callable[[], str]
|
|
480
|
+
) -> None:
|
|
481
|
+
"""``--overwrite-wp-config``'s early refusal, before anything is changed.
|
|
482
|
+
|
|
483
|
+
An ``Expression`` among ``keep`` is ``UnsupportedConfigValueError``. When
|
|
484
|
+
the target reads one of ``keep`` through ``getenv_docker()``, the
|
|
485
|
+
Archive's text (``archive_content``, called only then) is carried into
|
|
486
|
+
in memory as :func:`carry_sources` would, so a missing definition is
|
|
487
|
+
refused here, a Dry Run included, and not at ``REWRITE_CONFIG``.
|
|
488
|
+
"""
|
|
489
|
+
_require_config()
|
|
490
|
+
from l3io.wp.config import EnvLookup, Expression, WpConfigError, WpConfigFile
|
|
491
|
+
|
|
492
|
+
label = f"wp-config:{config_file}"
|
|
493
|
+
try:
|
|
494
|
+
target = WpConfigFile(config_file)
|
|
495
|
+
values = {key: target.get_value(key) for key in keep}
|
|
496
|
+
except WpConfigError as exc:
|
|
497
|
+
raise CredentialsError(label, reason="wp-config.php is unreadable") from exc
|
|
498
|
+
for key, value in values.items():
|
|
499
|
+
if isinstance(value, Expression):
|
|
500
|
+
raise UnsupportedConfigValueError(_shown(key), path=config_file)
|
|
501
|
+
if any(
|
|
502
|
+
isinstance(v, EnvLookup) and v.function == _DOCKER_FUNCTION
|
|
503
|
+
for v in values.values()
|
|
504
|
+
):
|
|
505
|
+
_carry(config_file, target, archive_content(), keep)
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
class WpConfigWriter:
|
|
509
|
+
"""``ConfigQuery`` and ``ConfigCommand`` over ``l3io-wordpress-config``.
|
|
510
|
+
|
|
511
|
+
``environ`` is the Run's environment mapping, which ``read_constants``
|
|
512
|
+
resolves an ``EnvLookup`` against (owner decision 47); empty by default,
|
|
513
|
+
so a lookup then takes its default, as PHP would with nothing set.
|
|
514
|
+
"""
|
|
515
|
+
|
|
516
|
+
__slots__ = ("_environ", "_secrets")
|
|
517
|
+
|
|
518
|
+
def __init__(
|
|
519
|
+
self, secrets: SecretRegistry, *, environ: Mapping[str, str] = _NO_ENVIRONMENT
|
|
520
|
+
) -> None:
|
|
521
|
+
self._secrets = secrets
|
|
522
|
+
self._environ = environ
|
|
523
|
+
|
|
524
|
+
def __repr__(self) -> str:
|
|
525
|
+
return "WpConfigWriter()"
|
|
526
|
+
|
|
527
|
+
def _register(self, values: Mapping[str, object], *, source: str) -> None:
|
|
528
|
+
value = values.get(_PASSWORD_KEY)
|
|
529
|
+
if isinstance(value, str):
|
|
530
|
+
self._secrets.register(value, source=source)
|
|
531
|
+
|
|
532
|
+
# ------------------------------------------------------------------
|
|
533
|
+
# ConfigQuery
|
|
534
|
+
# ------------------------------------------------------------------
|
|
535
|
+
|
|
536
|
+
def read_constants(
|
|
537
|
+
self, config_file: Path, keys: Sequence[str]
|
|
538
|
+
) -> Mapping[str, ConstantValue]:
|
|
539
|
+
"""The restore target's ``keys``; ``None`` for an undefined one.
|
|
540
|
+
|
|
541
|
+
A literal keeps its PHP type, an ``EnvLookup`` is resolved against the
|
|
542
|
+
writer's ``environ`` and an ``Expression`` is refused
|
|
543
|
+
(``UnsupportedConfigValueError``): its source text is never handed on
|
|
544
|
+
as if it were a value (BL-E008-032).
|
|
545
|
+
"""
|
|
546
|
+
label = f"wp-config:{config_file}"
|
|
547
|
+
values = _read_values(
|
|
548
|
+
config_file,
|
|
549
|
+
keys,
|
|
550
|
+
environ=self._environ,
|
|
551
|
+
label=label,
|
|
552
|
+
secrets=self._secrets,
|
|
553
|
+
)
|
|
554
|
+
self._register(values, source=label)
|
|
555
|
+
return values
|
|
556
|
+
|
|
557
|
+
# ------------------------------------------------------------------
|
|
558
|
+
# ConfigCommand
|
|
559
|
+
# ------------------------------------------------------------------
|
|
560
|
+
|
|
561
|
+
def set_constants(
|
|
562
|
+
self, config_file: Path, values: Mapping[str, str | int | bool]
|
|
563
|
+
) -> ConfigWriteResult:
|
|
564
|
+
"""Set ``values`` in ``config_file``, one atomic verified write per key.
|
|
565
|
+
|
|
566
|
+
**Not one transaction.** Each key, in the order given, is its own
|
|
567
|
+
``WpConfigFile.set``. When one fails, ``ConfigWriteFailedError`` names
|
|
568
|
+
it and ``written`` lists the keys whose write had already landed; the
|
|
569
|
+
file holds exactly those changes. A key already at its value is not
|
|
570
|
+
rewritten and is absent from ``changed``, which the caller journals
|
|
571
|
+
per key. The file must exist: it is never created here.
|
|
572
|
+
"""
|
|
573
|
+
_require_config()
|
|
574
|
+
from l3io.wp.config import ConfigReadError, WpConfigError, WpConfigFile
|
|
575
|
+
|
|
576
|
+
self._register(values, source="set_constants")
|
|
577
|
+
started = time.monotonic_ns()
|
|
578
|
+
keys = tuple(values)
|
|
579
|
+
written: list[str] = []
|
|
580
|
+
first = keys[0] if keys else WHOLE_FILE
|
|
581
|
+
try:
|
|
582
|
+
config = WpConfigFile(config_file)
|
|
583
|
+
except ConfigReadError as exc:
|
|
584
|
+
unread = _ABSENT_REASON if not config_file.exists() else exc.reason
|
|
585
|
+
raise ConfigWriteFailedError(config_file, first, unread) from exc
|
|
586
|
+
for key in keys:
|
|
587
|
+
try:
|
|
588
|
+
if config.set(key, values[key]):
|
|
589
|
+
written.append(key)
|
|
590
|
+
except WpConfigError as exc:
|
|
591
|
+
failure = _set_failure(exc)
|
|
592
|
+
raise ConfigWriteFailedError(
|
|
593
|
+
config_file, key, failure, written=tuple(written)
|
|
594
|
+
) from exc
|
|
595
|
+
_log.info(
|
|
596
|
+
"config_written",
|
|
597
|
+
path=str(config_file),
|
|
598
|
+
method="set_constants",
|
|
599
|
+
duration_ms=(time.monotonic_ns() - started) / _NS_PER_MS,
|
|
600
|
+
keys=list(keys),
|
|
601
|
+
changed=list(written),
|
|
602
|
+
)
|
|
603
|
+
return ConfigWriteResult(changed=tuple(written), performed=True)
|
|
604
|
+
|
|
605
|
+
def save_copy(self, config_file: Path, destination: Path) -> ConfigWriteResult:
|
|
606
|
+
"""Copy ``config_file`` to ``destination`` at mode ``0600``, fsynced.
|
|
607
|
+
|
|
608
|
+
Written beside ``destination`` and moved onto it, so ``destination``
|
|
609
|
+
is either absent or complete. A missing ``destination`` directory is
|
|
610
|
+
created ``0700`` first, so a Dry Run (whose twin writes nothing)
|
|
611
|
+
creates none either (AD-4).
|
|
612
|
+
"""
|
|
613
|
+
started = time.monotonic_ns()
|
|
614
|
+
directory = destination.parent
|
|
615
|
+
try:
|
|
616
|
+
private_directories(directory)
|
|
617
|
+
fd, name = tempfile.mkstemp(
|
|
618
|
+
dir=directory, prefix=f".{destination.name}.", suffix=_TMP_SUFFIX
|
|
619
|
+
)
|
|
620
|
+
except OSError as exc:
|
|
621
|
+
reason = exc.strerror or type(exc).__name__
|
|
622
|
+
raise ConfigWriteFailedError(destination, WHOLE_FILE, reason) from exc
|
|
623
|
+
tmp = Path(name)
|
|
624
|
+
committed = False
|
|
625
|
+
try:
|
|
626
|
+
with os.fdopen(fd, "wb") as handle, config_file.open("rb") as src:
|
|
627
|
+
os.fchmod(handle.fileno(), 0o600)
|
|
628
|
+
shutil.copyfileobj(src, handle)
|
|
629
|
+
handle.flush()
|
|
630
|
+
os.fsync(handle.fileno())
|
|
631
|
+
tmp.replace(destination)
|
|
632
|
+
committed = True
|
|
633
|
+
except OSError as exc:
|
|
634
|
+
reason = exc.strerror or type(exc).__name__
|
|
635
|
+
raise ConfigWriteFailedError(destination, WHOLE_FILE, reason) from exc
|
|
636
|
+
finally:
|
|
637
|
+
if not committed:
|
|
638
|
+
tmp.unlink(missing_ok=True)
|
|
639
|
+
_sync_directory_after_commit(directory)
|
|
640
|
+
_log.info(
|
|
641
|
+
"config_written",
|
|
642
|
+
path=str(destination),
|
|
643
|
+
method="save_copy",
|
|
644
|
+
duration_ms=(time.monotonic_ns() - started) / _NS_PER_MS,
|
|
645
|
+
keys=[],
|
|
646
|
+
)
|
|
647
|
+
return ConfigWriteResult(changed=(), performed=True)
|
|
648
|
+
|
|
649
|
+
def replace_file(
|
|
650
|
+
self,
|
|
651
|
+
config_file: Path,
|
|
652
|
+
content: str,
|
|
653
|
+
*,
|
|
654
|
+
keep: Sequence[str],
|
|
655
|
+
) -> ConfigWriteResult:
|
|
656
|
+
"""Replace ``config_file`` with the text ``content``, ``keep`` carried in first.
|
|
657
|
+
|
|
658
|
+
``content`` arrives in memory, so no intermediate copy of the Archive's
|
|
659
|
+
config is ever written (BL-E003-057). Each ``keep`` key's definition
|
|
660
|
+
is carried from ``config_file`` into the text in memory, as the source
|
|
661
|
+
the target wrote (:func:`carry_sources`, owner decision 48), so the
|
|
662
|
+
replacement's values for those keys are never written anywhere and a
|
|
663
|
+
``getenv_docker()`` definition stays a lookup. A key the target does
|
|
664
|
+
not define cannot be kept and is refused, as is a ``getenv_docker()``
|
|
665
|
+
the replacement does not define.
|
|
666
|
+
The write is ``l3io-wordpress-config``'s ``WpConfigFile.replace_content``
|
|
667
|
+
(0.1.4, BL-E002-091): a temp file beside the config takes the
|
|
668
|
+
original's owner and mode before any content — refusing rather than
|
|
669
|
+
changing the owner — is fsynced, read back, and ``os.replace``d over
|
|
670
|
+
``config_file``; the directory is then fsynced. A text that defines a
|
|
671
|
+
key twice is refused. ``config_file`` must exist. On any failure the
|
|
672
|
+
original is byte-identical and no temp file is left behind.
|
|
673
|
+
"""
|
|
674
|
+
_require_config()
|
|
675
|
+
from l3io.wp.config import WpConfigError, WpConfigString
|
|
676
|
+
|
|
677
|
+
started = time.monotonic_ns()
|
|
678
|
+
target = _open_target(config_file)
|
|
679
|
+
self._register(
|
|
680
|
+
{_PASSWORD_KEY: target.get_value(_PASSWORD_KEY)}, source="replace_file"
|
|
681
|
+
)
|
|
682
|
+
try:
|
|
683
|
+
archived = WpConfigString(content).get_value(_PASSWORD_KEY)
|
|
684
|
+
except ValueError:
|
|
685
|
+
archived = None # _carry refuses an unreadable replacement
|
|
686
|
+
self._register({_PASSWORD_KEY: archived}, source="replace_file")
|
|
687
|
+
text, changed = _carry(config_file, target, content, keep)
|
|
688
|
+
try:
|
|
689
|
+
target.replace_content(text)
|
|
690
|
+
except WpConfigError as exc:
|
|
691
|
+
raise ConfigWriteFailedError(
|
|
692
|
+
config_file, WHOLE_FILE, _set_failure(exc)
|
|
693
|
+
) from exc
|
|
694
|
+
_log.info(
|
|
695
|
+
"config_written",
|
|
696
|
+
path=str(config_file),
|
|
697
|
+
method="replace_file",
|
|
698
|
+
duration_ms=(time.monotonic_ns() - started) / _NS_PER_MS,
|
|
699
|
+
keys=list(keep),
|
|
700
|
+
changed=list(changed),
|
|
701
|
+
)
|
|
702
|
+
return ConfigWriteResult(changed=changed, performed=True)
|