l3io-wordpress-database 0.3.5__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- l3io/wp/database/__init__.py +192 -0
- l3io/wp/database/__main__.py +10 -0
- l3io/wp/database/_client.py +318 -0
- l3io/wp/database/_observe.py +157 -0
- l3io/wp/database/_passphrase.py +237 -0
- l3io/wp/database/_sql.py +79 -0
- l3io/wp/database/_version.py +24 -0
- l3io/wp/database/_version_map.py +35 -0
- l3io/wp/database/auth.py +64 -0
- l3io/wp/database/cli.py +531 -0
- l3io/wp/database/connection.py +71 -0
- l3io/wp/database/credentials.py +196 -0
- l3io/wp/database/encryption.py +188 -0
- l3io/wp/database/errors.py +186 -0
- l3io/wp/database/facade.py +220 -0
- l3io/wp/database/identifiers.py +95 -0
- l3io/wp/database/inspection.py +254 -0
- l3io/wp/database/provision.py +751 -0
- l3io/wp/database/py.typed +0 -0
- l3io/wp/database/server.py +139 -0
- l3io/wp/database/state.py +82 -0
- l3io/wp/database/tls.py +211 -0
- l3io/wp/database/transfer.py +1165 -0
- l3io/wp/database/verdict.py +193 -0
- l3io/wp/database/versions.py +101 -0
- l3io/wp/database/wpconfig.py +145 -0
- l3io_wordpress_database-0.3.5.dist-info/METADATA +239 -0
- l3io_wordpress_database-0.3.5.dist-info/RECORD +31 -0
- l3io_wordpress_database-0.3.5.dist-info/WHEEL +4 -0
- l3io_wordpress_database-0.3.5.dist-info/entry_points.txt +2 -0
- l3io_wordpress_database-0.3.5.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""What a check found, as data a caller can branch on.
|
|
2
|
+
|
|
3
|
+
Every check returns a :class:`Verdict`. An operation runs its check first and
|
|
4
|
+
refuses with :class:`~l3io.wp.database.errors.RefusedError` carrying the same
|
|
5
|
+
verdict when it holds a blocker; on success the verdict's warnings travel on the
|
|
6
|
+
result. A caller therefore branches on :class:`Code` values and never parses a
|
|
7
|
+
message.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import Mapping
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from enum import StrEnum
|
|
15
|
+
from types import MappingProxyType
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Severity(StrEnum):
|
|
19
|
+
"""Whether a finding stops the operation."""
|
|
20
|
+
|
|
21
|
+
BLOCKER = "blocker"
|
|
22
|
+
WARNING = "warning"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Code(StrEnum):
|
|
26
|
+
"""Every finding a check can report. Each code has exactly one severity."""
|
|
27
|
+
|
|
28
|
+
UNREACHABLE = "unreachable"
|
|
29
|
+
CREDENTIALS_UNOBTAINABLE = "credentials_unobtainable"
|
|
30
|
+
AUTHENTICATION_FAILED = "authentication_failed"
|
|
31
|
+
SERVER_UNSUPPORTED = "server_unsupported"
|
|
32
|
+
MISSING_PRIVILEGE = "missing_privilege"
|
|
33
|
+
PRIVILEGES_UNVERIFIED = "privileges_unverified"
|
|
34
|
+
DATABASE_ABSENT = "database_absent"
|
|
35
|
+
USER_ABSENT = "user_absent"
|
|
36
|
+
CONFIRMATION_MISMATCH = "confirmation_mismatch"
|
|
37
|
+
LIVE_WORDPRESS_SITE = "live_wordpress_site"
|
|
38
|
+
END_OF_LIFE_SERVER = "end_of_life_server"
|
|
39
|
+
MISSING_DEPENDENCY = "missing_dependency"
|
|
40
|
+
PASSWORD_UNVERIFIED = "password_unverified" # noqa: S105 - a finding code, not a password
|
|
41
|
+
CLIENT_TOOL_MISSING = "client_tool_missing"
|
|
42
|
+
CLIENT_SERVER_SKEW = "client_server_skew"
|
|
43
|
+
PLATFORM_UNSUPPORTED = "platform_unsupported"
|
|
44
|
+
OPTION_NOT_ALLOWED = "option_not_allowed"
|
|
45
|
+
DATABASE_NOT_EMPTY = "database_not_empty"
|
|
46
|
+
INCOMPATIBLE_DUMP_SOURCE = "incompatible_dump_source"
|
|
47
|
+
CROSS_FAMILY_DUMP = "cross_family_dump"
|
|
48
|
+
STORED_OBJECTS_PRESENT = "stored_objects_present"
|
|
49
|
+
IDENTITY_REQUIRED = "identity_required"
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def severity(self) -> Severity:
|
|
53
|
+
"""The fixed severity of this code."""
|
|
54
|
+
return _SEVERITY[self]
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def description(self) -> str:
|
|
58
|
+
"""When this code is raised."""
|
|
59
|
+
return _DESCRIPTION[self]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
_B, _W = Severity.BLOCKER, Severity.WARNING
|
|
63
|
+
|
|
64
|
+
_SEVERITY: Mapping[Code, Severity] = MappingProxyType(
|
|
65
|
+
{
|
|
66
|
+
Code.UNREACHABLE: _B,
|
|
67
|
+
Code.CREDENTIALS_UNOBTAINABLE: _B,
|
|
68
|
+
Code.AUTHENTICATION_FAILED: _B,
|
|
69
|
+
Code.SERVER_UNSUPPORTED: _B,
|
|
70
|
+
Code.MISSING_PRIVILEGE: _B,
|
|
71
|
+
Code.PRIVILEGES_UNVERIFIED: _W,
|
|
72
|
+
Code.DATABASE_ABSENT: _B,
|
|
73
|
+
Code.USER_ABSENT: _B,
|
|
74
|
+
Code.CONFIRMATION_MISMATCH: _B,
|
|
75
|
+
Code.LIVE_WORDPRESS_SITE: _W,
|
|
76
|
+
Code.END_OF_LIFE_SERVER: _W,
|
|
77
|
+
Code.MISSING_DEPENDENCY: _B,
|
|
78
|
+
Code.PASSWORD_UNVERIFIED: _W,
|
|
79
|
+
Code.CLIENT_TOOL_MISSING: _B,
|
|
80
|
+
Code.CLIENT_SERVER_SKEW: _W,
|
|
81
|
+
Code.PLATFORM_UNSUPPORTED: _B,
|
|
82
|
+
Code.OPTION_NOT_ALLOWED: _B,
|
|
83
|
+
Code.DATABASE_NOT_EMPTY: _B,
|
|
84
|
+
Code.INCOMPATIBLE_DUMP_SOURCE: _B,
|
|
85
|
+
Code.CROSS_FAMILY_DUMP: _W,
|
|
86
|
+
Code.STORED_OBJECTS_PRESENT: _W,
|
|
87
|
+
Code.IDENTITY_REQUIRED: _B,
|
|
88
|
+
}
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
_DESCRIPTION: Mapping[Code, str] = MappingProxyType(
|
|
92
|
+
{
|
|
93
|
+
Code.UNREACHABLE: "the server could not be reached",
|
|
94
|
+
Code.CREDENTIALS_UNOBTAINABLE: "a credential source failed to supply credentials",
|
|
95
|
+
Code.AUTHENTICATION_FAILED: "the server rejected the supplied account",
|
|
96
|
+
Code.SERVER_UNSUPPORTED: "the server is below MySQL 8.0 or MariaDB 10.2",
|
|
97
|
+
Code.MISSING_PRIVILEGE: (
|
|
98
|
+
"the account lacks a privilege the operation needs; fields.privilege names it"
|
|
99
|
+
),
|
|
100
|
+
Code.PRIVILEGES_UNVERIFIED: (
|
|
101
|
+
"the account holds an active role, whose privileges information_schema does "
|
|
102
|
+
"not show, so privilege checks were skipped"
|
|
103
|
+
),
|
|
104
|
+
Code.DATABASE_ABSENT: "the operation needs an existing database",
|
|
105
|
+
Code.USER_ABSENT: "the operation needs an existing account",
|
|
106
|
+
Code.CONFIRMATION_MISMATCH: "confirm= does not equal the operation's target",
|
|
107
|
+
Code.LIVE_WORDPRESS_SITE: (
|
|
108
|
+
"a destructive target has a WordPress options table and schema version"
|
|
109
|
+
),
|
|
110
|
+
Code.END_OF_LIFE_SERVER: "the server version is past upstream end of life",
|
|
111
|
+
Code.PASSWORD_UNVERIFIED: (
|
|
112
|
+
"the account's host does not match this machine, so its password could not be "
|
|
113
|
+
"checked by logging in and was left unchanged; use rotate_password to set it"
|
|
114
|
+
),
|
|
115
|
+
Code.MISSING_DEPENDENCY: (
|
|
116
|
+
"a credential source needs an extra that is not installed; fields.extra names it"
|
|
117
|
+
),
|
|
118
|
+
Code.CLIENT_TOOL_MISSING: (
|
|
119
|
+
"no suitable dump or client program is on PATH; fields.tools names those tried"
|
|
120
|
+
),
|
|
121
|
+
Code.CLIENT_SERVER_SKEW: (
|
|
122
|
+
"the client program's family, or its major.minor version, is older than or differs "
|
|
123
|
+
"from the server's"
|
|
124
|
+
),
|
|
125
|
+
Code.PLATFORM_UNSUPPORTED: "export and import need /dev/fd, which this platform lacks",
|
|
126
|
+
Code.OPTION_NOT_ALLOWED: (
|
|
127
|
+
"a caller-supplied client option is not on the allow-list; fields.option names it"
|
|
128
|
+
),
|
|
129
|
+
Code.DATABASE_NOT_EMPTY: "the load target holds tables and replace= was not given",
|
|
130
|
+
Code.INCOMPATIBLE_DUMP_SOURCE: (
|
|
131
|
+
"the dump begins with MariaDB's sandbox line and this client cannot read it; "
|
|
132
|
+
"allow_unsandboxed=True strips that line"
|
|
133
|
+
),
|
|
134
|
+
Code.CROSS_FAMILY_DUMP: (
|
|
135
|
+
"the dump comes from the other server family; collations it uses may not exist here"
|
|
136
|
+
),
|
|
137
|
+
Code.STORED_OBJECTS_PRESENT: (
|
|
138
|
+
"the database has views or triggers: they carry DEFINER clauses that loading needs "
|
|
139
|
+
"privilege for, and MariaDB keeps their original text, which may name this schema"
|
|
140
|
+
),
|
|
141
|
+
Code.IDENTITY_REQUIRED: "the source is age-encrypted and no identity was supplied",
|
|
142
|
+
}
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@dataclass(frozen=True, slots=True)
|
|
147
|
+
class Finding:
|
|
148
|
+
"""One thing a check found."""
|
|
149
|
+
|
|
150
|
+
code: Code
|
|
151
|
+
message: str
|
|
152
|
+
fields: Mapping[str, str | int | bool] = field(default_factory=dict)
|
|
153
|
+
|
|
154
|
+
def __post_init__(self) -> None:
|
|
155
|
+
# Frozen dataclasses still hold a mutable dict; freeze the view too.
|
|
156
|
+
object.__setattr__(self, "fields", MappingProxyType(dict(self.fields)))
|
|
157
|
+
|
|
158
|
+
@property
|
|
159
|
+
def severity(self) -> Severity:
|
|
160
|
+
"""The code's fixed severity."""
|
|
161
|
+
return self.code.severity
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@dataclass(frozen=True, slots=True)
|
|
165
|
+
class Verdict:
|
|
166
|
+
"""Everything one check found about one operation."""
|
|
167
|
+
|
|
168
|
+
operation: str
|
|
169
|
+
findings: tuple[Finding, ...] = ()
|
|
170
|
+
|
|
171
|
+
@property
|
|
172
|
+
def blockers(self) -> tuple[Finding, ...]:
|
|
173
|
+
"""Findings that stop the operation."""
|
|
174
|
+
return tuple(f for f in self.findings if f.severity is Severity.BLOCKER)
|
|
175
|
+
|
|
176
|
+
@property
|
|
177
|
+
def warnings(self) -> tuple[Finding, ...]:
|
|
178
|
+
"""Findings that let the operation run but describe a risk."""
|
|
179
|
+
return tuple(f for f in self.findings if f.severity is Severity.WARNING)
|
|
180
|
+
|
|
181
|
+
@property
|
|
182
|
+
def ok(self) -> bool:
|
|
183
|
+
"""Whether nothing blocks the operation."""
|
|
184
|
+
return not self.blockers
|
|
185
|
+
|
|
186
|
+
@property
|
|
187
|
+
def codes(self) -> frozenset[Code]:
|
|
188
|
+
"""Every code present."""
|
|
189
|
+
return frozenset(f.code for f in self.findings)
|
|
190
|
+
|
|
191
|
+
def with_findings(self, *findings: Finding) -> Verdict:
|
|
192
|
+
"""A new verdict with ``findings`` appended."""
|
|
193
|
+
return Verdict(self.operation, self.findings + findings)
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Interpreting WordPress's ``db_version``.
|
|
2
|
+
|
|
3
|
+
WordPress stores a schema revision in its options table. A hand-maintained
|
|
4
|
+
dictionary mapping it to a release number stops at whatever release its author
|
|
5
|
+
last added, and then returns ``None`` for every WordPress after it: it quietly
|
|
6
|
+
stops working rather than merely ageing.
|
|
7
|
+
|
|
8
|
+
The map is now derived from the source of truth and committed as generated
|
|
9
|
+
data, so the runtime needs no network. See ``tools/generate_version_map.py``;
|
|
10
|
+
regenerating is a maintenance task, not a deployment step.
|
|
11
|
+
|
|
12
|
+
Two properties worth stating:
|
|
13
|
+
|
|
14
|
+
* **The floor is WordPress 5.0**, which is a support decision rather than a
|
|
15
|
+
limit of the derivation -- ``$wp_db_version`` is readable back to 2.0 if the
|
|
16
|
+
floor ever needs lowering. Every one of the 328 releases at or above 5.0
|
|
17
|
+
yields a value, so the map has no gaps within its supported range. Anything
|
|
18
|
+
below the floor reports ``None`` and is distinguishable via
|
|
19
|
+
:attr:`SchemaVersion.is_older_than_supported`.
|
|
20
|
+
* **The mapping is many-to-one.** Several releases share a ``db_version``
|
|
21
|
+
(7.0 and 7.1 both report 61833), so a value identifies the release that
|
|
22
|
+
*introduced* that schema, not the exact release installed.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import bisect
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
|
|
30
|
+
from ._version_map import DB_VERSION_TO_RELEASE, MIN_RELEASE
|
|
31
|
+
|
|
32
|
+
#: Sorted db_version values, for the bisecting lookup below.
|
|
33
|
+
_KNOWN: list[int] = sorted(DB_VERSION_TO_RELEASE)
|
|
34
|
+
|
|
35
|
+
#: The db_version of the oldest supported release.
|
|
36
|
+
FLOOR_DB_VERSION: int = _KNOWN[0]
|
|
37
|
+
|
|
38
|
+
#: The db_version of the newest release the committed map knows about.
|
|
39
|
+
CEILING_DB_VERSION: int = _KNOWN[-1]
|
|
40
|
+
|
|
41
|
+
#: The oldest WordPress release this package reports on.
|
|
42
|
+
SUPPORTED_FROM: str = MIN_RELEASE
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def release_for(db_version: int) -> str | None:
|
|
46
|
+
"""The WordPress release that introduced this schema revision.
|
|
47
|
+
|
|
48
|
+
Returns the highest known release whose ``db_version`` is less than or
|
|
49
|
+
equal to the one observed -- not an exact-match lookup, because a site can
|
|
50
|
+
report a value between two releases (a development build), and because the
|
|
51
|
+
map is many-to-one.
|
|
52
|
+
|
|
53
|
+
Comparison is numeric. The original stored these values as strings, where
|
|
54
|
+
``'9872' <= '48748'`` is ``False``, so an ordered lookup over the old map
|
|
55
|
+
would have been wrong even where its data was right.
|
|
56
|
+
|
|
57
|
+
Returns ``None`` below :data:`FLOOR_DB_VERSION`.
|
|
58
|
+
"""
|
|
59
|
+
if db_version < FLOOR_DB_VERSION:
|
|
60
|
+
return None
|
|
61
|
+
index = bisect.bisect_right(_KNOWN, db_version) - 1
|
|
62
|
+
return DB_VERSION_TO_RELEASE[_KNOWN[index]]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True, slots=True)
|
|
66
|
+
class SchemaVersion:
|
|
67
|
+
"""A WordPress schema revision read from a site's options table."""
|
|
68
|
+
|
|
69
|
+
db_version: int
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def release(self) -> str | None:
|
|
73
|
+
"""The WordPress release that introduced this schema, if supported.
|
|
74
|
+
|
|
75
|
+
A plain property, not cached: `slots=True` leaves no `__dict__` for
|
|
76
|
+
functools to cache into, and a bisect over a handful of entries is not
|
|
77
|
+
worth caching anyway.
|
|
78
|
+
"""
|
|
79
|
+
return release_for(self.db_version)
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def is_newer_than_known(self) -> bool:
|
|
83
|
+
"""Whether this schema postdates the committed map.
|
|
84
|
+
|
|
85
|
+
Distinct from unsupported: the map needs regenerating, rather than the
|
|
86
|
+
site being too old to report on.
|
|
87
|
+
"""
|
|
88
|
+
return self.db_version > CEILING_DB_VERSION
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def is_older_than_supported(self) -> bool:
|
|
92
|
+
"""Whether this site predates the oldest supported release."""
|
|
93
|
+
return self.db_version < FLOOR_DB_VERSION
|
|
94
|
+
|
|
95
|
+
def __str__(self) -> str:
|
|
96
|
+
if self.is_older_than_supported:
|
|
97
|
+
return f"db_version {self.db_version} (older than WordPress {SUPPORTED_FROM})"
|
|
98
|
+
if self.release is None: # pragma: no cover - unreachable above the floor
|
|
99
|
+
return f"db_version {self.db_version} (no known release)"
|
|
100
|
+
suffix = " or newer" if self.is_newer_than_known else ""
|
|
101
|
+
return f"WordPress {self.release}{suffix} (db_version {self.db_version})"
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Read connection details out of a WordPress ``wp-config.php``.
|
|
2
|
+
|
|
3
|
+
An adapter over ``l3io-wordpress-config``, which lives behind the ``wpconfig`` extra.
|
|
4
|
+
Per AD-1 the adapter *holds* the config object and exposes only what this
|
|
5
|
+
package needs, rather than subclassing it -- subclassing would make every
|
|
6
|
+
method of the underlying type public here automatically, so the declared
|
|
7
|
+
surface would leak by construction and ``__all__`` could not bound it.
|
|
8
|
+
|
|
9
|
+
Three properties of the upstream API matter enough to state:
|
|
10
|
+
|
|
11
|
+
* An absent key returns a ``MISSING`` sentinel, not ``None``. ``MISSING`` is
|
|
12
|
+
falsy, so ``if config.get("DB_PASSWORD"):`` cannot distinguish "not defined"
|
|
13
|
+
from a legitimately empty value. Every read here tests ``is MISSING``.
|
|
14
|
+
* PHP types are preserved, so ``define('DB_PORT', 3306)`` is an ``int`` while
|
|
15
|
+
``define('DB_PASSWORD', '3306')`` stays a ``str``. Values are coerced
|
|
16
|
+
deliberately rather than assumed to be strings.
|
|
17
|
+
* ``$table_prefix`` is a PHP *variable*, not a ``define()``, and lives in a
|
|
18
|
+
separate namespace: ``get_variable("table_prefix")``. ``get("table_prefix")``
|
|
19
|
+
correctly returns ``MISSING``.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import re
|
|
25
|
+
from importlib import metadata
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from .connection import Connection
|
|
30
|
+
from .credentials import ResolvedCredentials
|
|
31
|
+
from .errors import (
|
|
32
|
+
ConfigUnreadableError,
|
|
33
|
+
CredentialsAbsentError,
|
|
34
|
+
CredentialsUnobtainableError,
|
|
35
|
+
MissingConfigValueError,
|
|
36
|
+
MissingDependencyError,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
#: WordPress's own default when ``$table_prefix`` is not set.
|
|
40
|
+
DEFAULT_TABLE_PREFIX = "wp_"
|
|
41
|
+
|
|
42
|
+
#: The ``l3io-wordpress-config`` releases this adapter accepts: the ``wpconfig``
|
|
43
|
+
#: extra's specifier, which tests/test_wpconfig.py holds equal. An earlier release
|
|
44
|
+
#: truncates a quoted value containing ``);``, and ``ensure`` would then set the
|
|
45
|
+
#: account's password to the truncated value. Only an install through the extra
|
|
46
|
+
#: enforces the range, so it is checked again here.
|
|
47
|
+
REQUIRED_CONFIG = ">=0.1.2,<0.2"
|
|
48
|
+
_CONFIG_FLOOR, _CONFIG_CEILING = (0, 1, 2), (0, 2, 0)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _accepts(version: str) -> bool:
|
|
52
|
+
match = re.match(r"\d+(?:\.\d+)*", version)
|
|
53
|
+
release = tuple(int(part) for part in match.group().split(".")) if match else ()
|
|
54
|
+
release += (0,) * (3 - len(release))
|
|
55
|
+
return _CONFIG_FLOOR <= release < _CONFIG_CEILING
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _installed_config_version() -> str | None:
|
|
59
|
+
try:
|
|
60
|
+
return metadata.version("l3io-wordpress-config")
|
|
61
|
+
except metadata.PackageNotFoundError:
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class WpConfigFile:
|
|
66
|
+
"""A site's ``wp-config.php``: a credential source, and where its database lives.
|
|
67
|
+
|
|
68
|
+
Needs the ``wpconfig`` extra.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
__slots__ = ("_config", "_missing", "_path")
|
|
72
|
+
|
|
73
|
+
def __init__(self, path: str | Path) -> None:
|
|
74
|
+
self._path = str(path)
|
|
75
|
+
try:
|
|
76
|
+
from l3io.wp.config import MISSING, WpConfigError
|
|
77
|
+
from l3io.wp.config import WpConfigFile as _Upstream
|
|
78
|
+
except ImportError as exc:
|
|
79
|
+
raise MissingDependencyError(
|
|
80
|
+
extra="wpconfig", distribution="l3io-wordpress-config"
|
|
81
|
+
) from exc
|
|
82
|
+
installed = _installed_config_version()
|
|
83
|
+
if installed is not None and not _accepts(installed):
|
|
84
|
+
raise MissingDependencyError(
|
|
85
|
+
extra="wpconfig",
|
|
86
|
+
distribution="l3io-wordpress-config",
|
|
87
|
+
found=installed,
|
|
88
|
+
required=REQUIRED_CONFIG,
|
|
89
|
+
)
|
|
90
|
+
self._missing = MISSING
|
|
91
|
+
try:
|
|
92
|
+
self._config: Any = _Upstream(filename=self._path)
|
|
93
|
+
except WpConfigError as exc:
|
|
94
|
+
raise ConfigUnreadableError(self._path, str(exc)) from exc
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def path(self) -> str:
|
|
98
|
+
return self._path
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def source(self) -> str:
|
|
102
|
+
return f"wp-config:{self._path}"
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def table_prefix(self) -> str:
|
|
106
|
+
"""WordPress's ``$table_prefix``, or ``wp_`` when unset."""
|
|
107
|
+
value = self._config.get_variable("table_prefix")
|
|
108
|
+
return DEFAULT_TABLE_PREFIX if value is self._missing else str(value)
|
|
109
|
+
|
|
110
|
+
def resolve(self) -> ResolvedCredentials:
|
|
111
|
+
user, password = self._get("DB_USER"), self._get("DB_PASSWORD")
|
|
112
|
+
if user is None and password is None:
|
|
113
|
+
msg = f"{self._path} defines neither DB_USER nor DB_PASSWORD"
|
|
114
|
+
raise CredentialsAbsentError(msg, sources=(self.source,))
|
|
115
|
+
try:
|
|
116
|
+
return ResolvedCredentials(
|
|
117
|
+
self._require("DB_USER", user),
|
|
118
|
+
self._require("DB_PASSWORD", password),
|
|
119
|
+
self.source,
|
|
120
|
+
)
|
|
121
|
+
except MissingConfigValueError as exc:
|
|
122
|
+
# Half a pair is a failure, not an absence: a chain must not fall
|
|
123
|
+
# through to another credential after it (spec §5).
|
|
124
|
+
raise CredentialsUnobtainableError(str(exc)) from exc
|
|
125
|
+
|
|
126
|
+
def connection(self) -> Connection:
|
|
127
|
+
"""Where the configured database lives."""
|
|
128
|
+
host = self._require("DB_HOST", self._get("DB_HOST"))
|
|
129
|
+
name = self._require("DB_NAME", self._get("DB_NAME"))
|
|
130
|
+
return Connection.from_db_host(host, name)
|
|
131
|
+
|
|
132
|
+
def _require(self, key: str, value: str | None) -> str:
|
|
133
|
+
"""Return a value read by ``_get``, or raise naming the missing key."""
|
|
134
|
+
if value is None:
|
|
135
|
+
raise MissingConfigValueError(key, self._path)
|
|
136
|
+
return value
|
|
137
|
+
|
|
138
|
+
def _get(self, key: str) -> str | None:
|
|
139
|
+
value: Any = self._config.get(key)
|
|
140
|
+
if value is self._missing:
|
|
141
|
+
return None
|
|
142
|
+
return value if isinstance(value, str) else str(value)
|
|
143
|
+
|
|
144
|
+
def __repr__(self) -> str:
|
|
145
|
+
return f"WpConfigFile(path={self._path!r})"
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: l3io-wordpress-database
|
|
3
|
+
Version: 0.3.5
|
|
4
|
+
Summary: Create and inspect WordPress MySQL databases and their users, idempotently.
|
|
5
|
+
Project-URL: Homepage, https://github.com/LiquidLogicLabs/l3io-wordpress-database
|
|
6
|
+
Project-URL: Source, https://github.com/LiquidLogicLabs/l3io-wordpress-database
|
|
7
|
+
Author: Shawn Anderson
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: database,mariadb,mysql,provisioning,wordpress
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: Site Management
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.11
|
|
24
|
+
Requires-Dist: opentelemetry-api<2,>=1.30
|
|
25
|
+
Requires-Dist: pymysql[rsa]<2,>=1.2
|
|
26
|
+
Requires-Dist: structlog<27,>=26.1
|
|
27
|
+
Provides-Extra: aws
|
|
28
|
+
Requires-Dist: boto3<2,>=1.34; extra == 'aws'
|
|
29
|
+
Provides-Extra: encryption
|
|
30
|
+
Requires-Dist: l3io-age-passphrase<0.2,>=0.1.1; extra == 'encryption'
|
|
31
|
+
Requires-Dist: pyrage<2,>=1.4; extra == 'encryption'
|
|
32
|
+
Provides-Extra: wpconfig
|
|
33
|
+
Requires-Dist: l3io-wordpress-config<0.2,>=0.1.2; extra == 'wpconfig'
|
|
34
|
+
Description-Content-Type: text/markdown
|
|
35
|
+
|
|
36
|
+
# l3io-wordpress-database
|
|
37
|
+
|
|
38
|
+
The operations a WordPress site performs against its MySQL or MariaDB database:
|
|
39
|
+
inspecting it, provisioning it, changing its accounts, destroying it, and exporting
|
|
40
|
+
and importing it. Every operation acts with credentials the caller supplies, and
|
|
41
|
+
every operation that changes or moves something can be checked before it runs.
|
|
42
|
+
|
|
43
|
+
It imports as `l3io.wp.database`, in the `l3io.wp` namespace shared with
|
|
44
|
+
`l3io-wordpress-config` and `l3io-wordpress-backup`.
|
|
45
|
+
|
|
46
|
+
> **Status:** 0.x — documented and typed, with no stability promise until 1.0.0.
|
|
47
|
+
> Not yet on PyPI; releases are on
|
|
48
|
+
> [GitHub](https://github.com/LiquidLogicLabs/l3io-wordpress-database/releases).
|
|
49
|
+
|
|
50
|
+
## Installing
|
|
51
|
+
|
|
52
|
+
```shell
|
|
53
|
+
pip install l3io-wordpress-database # core: PyMySQL, structlog, opentelemetry-api
|
|
54
|
+
pip install 'l3io-wordpress-database[aws]' # credentials from AWS Secrets Manager
|
|
55
|
+
pip install 'l3io-wordpress-database[wpconfig]' # credentials and location from wp-config.php
|
|
56
|
+
pip install 'l3io-wordpress-database[encryption]' # age-encrypted dumps
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
An optional dependency is an optional import: a base install never imports
|
|
60
|
+
`boto3`, the config reader or `pyrage`, and asking for a feature whose extra is
|
|
61
|
+
absent raises `MissingDependencyError` naming the extra.
|
|
62
|
+
|
|
63
|
+
## Two accounts
|
|
64
|
+
|
|
65
|
+
WordPress's own account — the one in `wp-config.php` — is the account that is
|
|
66
|
+
created and granted. A separate administrative account does the creating, and is
|
|
67
|
+
never defaulted to the WordPress account. Whether a database exists is answered
|
|
68
|
+
by the administrator: a server authenticates before it resolves the database, so a
|
|
69
|
+
failed login by the WordPress account says nothing about the database.
|
|
70
|
+
|
|
71
|
+
## Check, then run
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
from l3io.wp.database import Connection, Database, Environment, Explicit
|
|
75
|
+
|
|
76
|
+
db = Database(Connection("127.0.0.1", "wp_site"))
|
|
77
|
+
admin = Environment("DB_ADMIN_USER", "DB_ADMIN_PASSWORD")
|
|
78
|
+
wordpress = Explicit("wpuser", "wp-pass")
|
|
79
|
+
|
|
80
|
+
verdict = db.check_ensure(admin, wordpress)
|
|
81
|
+
for finding in verdict.findings:
|
|
82
|
+
print(finding.severity, finding.code, finding.message)
|
|
83
|
+
|
|
84
|
+
if verdict.ok:
|
|
85
|
+
result = db.ensure(admin, wordpress)
|
|
86
|
+
print(result.created_database, result.created_user, result.granted, result.changed)
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
`ensure` runs the same check itself and raises `RefusedError`, carrying the
|
|
90
|
+
`Verdict`, on any blocker. It creates only what is missing, and a second run
|
|
91
|
+
against a converged server changes nothing. Grants are scoped to the one database,
|
|
92
|
+
with the `_` and `%` wildcards in its name escaped.
|
|
93
|
+
|
|
94
|
+
Destructive operations — `revoke`, `drop_user`, `drop_database` — take
|
|
95
|
+
`confirm=`, which must repeat the target's name.
|
|
96
|
+
|
|
97
|
+
## From wp-config.php
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
from l3io.wp.database import Database, Environment, WpConfigFile
|
|
101
|
+
|
|
102
|
+
site = WpConfigFile("/var/www/html/wp-config.php")
|
|
103
|
+
db = Database(site.connection())
|
|
104
|
+
survey = db.survey(
|
|
105
|
+
Environment("DB_ADMIN_USER", "DB_ADMIN_PASSWORD"),
|
|
106
|
+
wordpress_user=site.resolve().username,
|
|
107
|
+
table_prefix=site.table_prefix,
|
|
108
|
+
)
|
|
109
|
+
print(survey.database_exists, survey.table_count, survey.is_wordpress, survey.schema_version)
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
`inspect` asks the WordPress account itself what it can do, and never raises
|
|
113
|
+
for an ordinary failure:
|
|
114
|
+
|
|
115
|
+
| State | Meaning |
|
|
116
|
+
|---|---|
|
|
117
|
+
| `ready` | connected, authenticated, and the database is usable |
|
|
118
|
+
| `database_absent` | authenticated, but the database does not exist |
|
|
119
|
+
| `access_denied` | authenticated, but may not use this database |
|
|
120
|
+
| `authentication_failed` | the server rejected the account |
|
|
121
|
+
| `unreachable` | the server could not be reached |
|
|
122
|
+
| `credentials_unobtainable` | the credentials could not be obtained; nothing was attempted |
|
|
123
|
+
|
|
124
|
+
## Export and import
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
from l3io.wp.database import (
|
|
128
|
+
Connection,
|
|
129
|
+
Database,
|
|
130
|
+
Environment,
|
|
131
|
+
WpConfigFile,
|
|
132
|
+
encrypt,
|
|
133
|
+
generate_identity,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
site = WpConfigFile("/var/www/html/wp-config.php")
|
|
137
|
+
identity, recipient = generate_identity()
|
|
138
|
+
Database(site.connection()).dump(site, "/backups/wp_site.sql.age", recipients=[recipient])
|
|
139
|
+
# Or passphrase mode (mutually exclusive with recipients= / identities=):
|
|
140
|
+
# Database(site.connection()).dump(site, "/backups/wp_site.sql.age", passphrase=secret)
|
|
141
|
+
|
|
142
|
+
admin = Environment("DB_ADMIN_USER", "DB_ADMIN_PASSWORD")
|
|
143
|
+
staging = Database(Connection("127.0.0.1", "wp_site_staging"))
|
|
144
|
+
staging.ensure(admin, site)
|
|
145
|
+
staging.load(site, "/backups/wp_site.sql.age", identities=[identity])
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Dumps are made by the server's own client tools (`mysqldump` or `mariadb-dump`),
|
|
149
|
+
and loads by `mysql` or `mariadb`. The password reaches them only through an
|
|
150
|
+
anonymous pipe: never an argument, an environment variable or a file. A dump to
|
|
151
|
+
a path is written under a temporary name and renamed only on success. `iter_dump`
|
|
152
|
+
yields the dump as chunks, and raises at the end if the tool failed, so a
|
|
153
|
+
truncated dump is never mistaken for a whole one.
|
|
154
|
+
|
|
155
|
+
Loading into a database that holds tables requires `replace=` naming it. A damaged
|
|
156
|
+
source is discovered mid-load, after the statements before the damage have run,
|
|
157
|
+
so load into a staging database and switch to it once the load succeeds.
|
|
158
|
+
|
|
159
|
+
Encryption is standard [age](https://age-encryption.org): recipient keys or a
|
|
160
|
+
passphrase (scrypt sole recipient). Either streams; any dump can be decrypted
|
|
161
|
+
with the stock `age` command line. Passphrase mode uses the
|
|
162
|
+
`l3io-age-passphrase` binder by default, or `age` + `age-plugin-batchpass` when
|
|
163
|
+
`L3IO_AGE_PASSPHRASE_BACKEND=cli` (or when the binder is absent under `auto`).
|
|
164
|
+
Archive callers can also
|
|
165
|
+
`from l3io.wp.database import encrypt, decrypt, check_passphrase`.
|
|
166
|
+
|
|
167
|
+
## Command line
|
|
168
|
+
|
|
169
|
+
```shell
|
|
170
|
+
l3io-wordpress-database survey --wp-config /var/www/html/wp-config.php \
|
|
171
|
+
--admin-user root --admin-password-env DB_ADMIN_PASSWORD
|
|
172
|
+
l3io-wordpress-database check ensure --wp-config /var/www/html/wp-config.php \
|
|
173
|
+
--admin-user root --admin-password-env DB_ADMIN_PASSWORD
|
|
174
|
+
l3io-wordpress-database ensure --wp-config /var/www/html/wp-config.php \
|
|
175
|
+
--admin-user root --admin-password-env DB_ADMIN_PASSWORD
|
|
176
|
+
l3io-wordpress-database dump --wp-config /var/www/html/wp-config.php \
|
|
177
|
+
--output wp_site.sql.age --recipient age1examplerecipient
|
|
178
|
+
l3io-wordpress-database dump --wp-config /var/www/html/wp-config.php \
|
|
179
|
+
--output wp_site.sql.age --passphrase-file /run/secrets/age-passphrase
|
|
180
|
+
l3io-wordpress-database load --db-host 127.0.0.1 --db-name wp_site_staging \
|
|
181
|
+
--db-user wpuser --db-password-env DB_PASSWORD \
|
|
182
|
+
--input wp_site.sql.age --identity key.txt
|
|
183
|
+
l3io-wordpress-database load --db-host 127.0.0.1 --db-name wp_site_staging \
|
|
184
|
+
--db-user wpuser --db-password-env DB_PASSWORD \
|
|
185
|
+
--input wp_site.sql.age --ask-passphrase
|
|
186
|
+
l3io-wordpress-database drop-database --db-host 127.0.0.1 --db-name wp_site_staging \
|
|
187
|
+
--admin-user root --admin-password-env DB_ADMIN_PASSWORD --confirm wp_site_staging
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
No password or age passphrase is accepted as an argument value, because an
|
|
191
|
+
argument vector is visible to every user on the machine: a password comes from
|
|
192
|
+
`--wp-config`, from the environment variable named by a `--*-password-env`
|
|
193
|
+
option, from AWS Secrets Manager, or from a prompt; an age passphrase comes from
|
|
194
|
+
`--passphrase-file` or `--ask-passphrase` (mutually exclusive with
|
|
195
|
+
`--recipient` / `--identity`). Results are printed as JSON on stdout; with
|
|
196
|
+
`dump --output -` the dump is on stdout and the result on stderr.
|
|
197
|
+
|
|
198
|
+
| Exit code | Meaning |
|
|
199
|
+
|---|---|
|
|
200
|
+
| `0` | success |
|
|
201
|
+
| `1` | the operation started and failed |
|
|
202
|
+
| `2` | usage |
|
|
203
|
+
| `3` | refused: the check found a blocker, and nothing ran |
|
|
204
|
+
| `4` | credentials could not be obtained |
|
|
205
|
+
| `5` | the server could not be reached |
|
|
206
|
+
| `7` | not ready: `inspect` found the account cannot use the database |
|
|
207
|
+
|
|
208
|
+
## Supported
|
|
209
|
+
|
|
210
|
+
- Python 3.11, 3.12, 3.13 and 3.14.
|
|
211
|
+
- MySQL 8.0 and later, MariaDB 10.2 and later. CI runs MySQL 8.0, 8.4 and 9.7 and
|
|
212
|
+
MariaDB 10.2 and 11.4.
|
|
213
|
+
- Export and import need `/dev/fd` (Linux or macOS) and the client tools on `PATH`.
|
|
214
|
+
Everything else is pure Python.
|
|
215
|
+
|
|
216
|
+
The package logs through `structlog` and never configures logging, and opens one
|
|
217
|
+
OpenTelemetry span per operation through `opentelemetry-api`, inert until the host
|
|
218
|
+
configures an SDK.
|
|
219
|
+
|
|
220
|
+
## Design records
|
|
221
|
+
|
|
222
|
+
This package's decisions are in [docs/adr](docs/adr/README.md). The rules the
|
|
223
|
+
`l3io.wp` family shares (cited as AD-n) are in `l3io-wordpress-backup`'s
|
|
224
|
+
architecture spine.
|
|
225
|
+
|
|
226
|
+
## Development
|
|
227
|
+
|
|
228
|
+
```shell
|
|
229
|
+
make sync # locked environment, all extras
|
|
230
|
+
make check # lint, format, types, tests, contracts
|
|
231
|
+
make test-db DB_HOST=127.0.0.1 # integration tests against a server
|
|
232
|
+
make test-transfer ENGINE=mysql:8.4 # export/import with that image's own client tools
|
|
233
|
+
make verify-wheel # install the built wheel, bare and with each extra
|
|
234
|
+
make ci # replay the CI pipeline under act
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
## Licence
|
|
238
|
+
|
|
239
|
+
MIT.
|