l3io-wordpress-database 0.3.5__tar.gz
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_wordpress_database-0.3.5/.gitignore +23 -0
- l3io_wordpress_database-0.3.5/LICENSE +21 -0
- l3io_wordpress_database-0.3.5/PKG-INFO +239 -0
- l3io_wordpress_database-0.3.5/README.md +204 -0
- l3io_wordpress_database-0.3.5/pyproject.toml +191 -0
- l3io_wordpress_database-0.3.5/src/l3io/wp/database/__init__.py +192 -0
- l3io_wordpress_database-0.3.5/src/l3io/wp/database/__main__.py +10 -0
- l3io_wordpress_database-0.3.5/src/l3io/wp/database/_client.py +318 -0
- l3io_wordpress_database-0.3.5/src/l3io/wp/database/_observe.py +157 -0
- l3io_wordpress_database-0.3.5/src/l3io/wp/database/_passphrase.py +237 -0
- l3io_wordpress_database-0.3.5/src/l3io/wp/database/_sql.py +79 -0
- l3io_wordpress_database-0.3.5/src/l3io/wp/database/_version.py +24 -0
- l3io_wordpress_database-0.3.5/src/l3io/wp/database/_version_map.py +35 -0
- l3io_wordpress_database-0.3.5/src/l3io/wp/database/auth.py +64 -0
- l3io_wordpress_database-0.3.5/src/l3io/wp/database/cli.py +531 -0
- l3io_wordpress_database-0.3.5/src/l3io/wp/database/connection.py +71 -0
- l3io_wordpress_database-0.3.5/src/l3io/wp/database/credentials.py +196 -0
- l3io_wordpress_database-0.3.5/src/l3io/wp/database/encryption.py +188 -0
- l3io_wordpress_database-0.3.5/src/l3io/wp/database/errors.py +186 -0
- l3io_wordpress_database-0.3.5/src/l3io/wp/database/facade.py +220 -0
- l3io_wordpress_database-0.3.5/src/l3io/wp/database/identifiers.py +95 -0
- l3io_wordpress_database-0.3.5/src/l3io/wp/database/inspection.py +254 -0
- l3io_wordpress_database-0.3.5/src/l3io/wp/database/provision.py +751 -0
- l3io_wordpress_database-0.3.5/src/l3io/wp/database/py.typed +0 -0
- l3io_wordpress_database-0.3.5/src/l3io/wp/database/server.py +139 -0
- l3io_wordpress_database-0.3.5/src/l3io/wp/database/state.py +82 -0
- l3io_wordpress_database-0.3.5/src/l3io/wp/database/tls.py +211 -0
- l3io_wordpress_database-0.3.5/src/l3io/wp/database/transfer.py +1165 -0
- l3io_wordpress_database-0.3.5/src/l3io/wp/database/verdict.py +193 -0
- l3io_wordpress_database-0.3.5/src/l3io/wp/database/versions.py +101 -0
- l3io_wordpress_database-0.3.5/src/l3io/wp/database/wpconfig.py +145 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
__pycache__
|
|
2
|
+
.coverage
|
|
3
|
+
.DS_Store
|
|
4
|
+
.vscode
|
|
5
|
+
build
|
|
6
|
+
dist
|
|
7
|
+
htmlcov
|
|
8
|
+
*.egg-info
|
|
9
|
+
coverage.xml
|
|
10
|
+
.pytest_cache
|
|
11
|
+
.mypy_cache
|
|
12
|
+
.ruff_cache
|
|
13
|
+
.venv
|
|
14
|
+
dist-verify
|
|
15
|
+
# Local age ≥1.3 + batchpass binaries for CLI passphrase tests (not shipped).
|
|
16
|
+
.tools/
|
|
17
|
+
|
|
18
|
+
# BMad agent definitions: tool output, regenerated by the installer.
|
|
19
|
+
.github/agents/
|
|
20
|
+
|
|
21
|
+
# Subagent-driven-development scratch: ledgers, briefs, review packages.
|
|
22
|
+
.superpowers/
|
|
23
|
+
.cache/
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Shawn Anderson
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -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.
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# l3io-wordpress-database
|
|
2
|
+
|
|
3
|
+
The operations a WordPress site performs against its MySQL or MariaDB database:
|
|
4
|
+
inspecting it, provisioning it, changing its accounts, destroying it, and exporting
|
|
5
|
+
and importing it. Every operation acts with credentials the caller supplies, and
|
|
6
|
+
every operation that changes or moves something can be checked before it runs.
|
|
7
|
+
|
|
8
|
+
It imports as `l3io.wp.database`, in the `l3io.wp` namespace shared with
|
|
9
|
+
`l3io-wordpress-config` and `l3io-wordpress-backup`.
|
|
10
|
+
|
|
11
|
+
> **Status:** 0.x — documented and typed, with no stability promise until 1.0.0.
|
|
12
|
+
> Not yet on PyPI; releases are on
|
|
13
|
+
> [GitHub](https://github.com/LiquidLogicLabs/l3io-wordpress-database/releases).
|
|
14
|
+
|
|
15
|
+
## Installing
|
|
16
|
+
|
|
17
|
+
```shell
|
|
18
|
+
pip install l3io-wordpress-database # core: PyMySQL, structlog, opentelemetry-api
|
|
19
|
+
pip install 'l3io-wordpress-database[aws]' # credentials from AWS Secrets Manager
|
|
20
|
+
pip install 'l3io-wordpress-database[wpconfig]' # credentials and location from wp-config.php
|
|
21
|
+
pip install 'l3io-wordpress-database[encryption]' # age-encrypted dumps
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
An optional dependency is an optional import: a base install never imports
|
|
25
|
+
`boto3`, the config reader or `pyrage`, and asking for a feature whose extra is
|
|
26
|
+
absent raises `MissingDependencyError` naming the extra.
|
|
27
|
+
|
|
28
|
+
## Two accounts
|
|
29
|
+
|
|
30
|
+
WordPress's own account — the one in `wp-config.php` — is the account that is
|
|
31
|
+
created and granted. A separate administrative account does the creating, and is
|
|
32
|
+
never defaulted to the WordPress account. Whether a database exists is answered
|
|
33
|
+
by the administrator: a server authenticates before it resolves the database, so a
|
|
34
|
+
failed login by the WordPress account says nothing about the database.
|
|
35
|
+
|
|
36
|
+
## Check, then run
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from l3io.wp.database import Connection, Database, Environment, Explicit
|
|
40
|
+
|
|
41
|
+
db = Database(Connection("127.0.0.1", "wp_site"))
|
|
42
|
+
admin = Environment("DB_ADMIN_USER", "DB_ADMIN_PASSWORD")
|
|
43
|
+
wordpress = Explicit("wpuser", "wp-pass")
|
|
44
|
+
|
|
45
|
+
verdict = db.check_ensure(admin, wordpress)
|
|
46
|
+
for finding in verdict.findings:
|
|
47
|
+
print(finding.severity, finding.code, finding.message)
|
|
48
|
+
|
|
49
|
+
if verdict.ok:
|
|
50
|
+
result = db.ensure(admin, wordpress)
|
|
51
|
+
print(result.created_database, result.created_user, result.granted, result.changed)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
`ensure` runs the same check itself and raises `RefusedError`, carrying the
|
|
55
|
+
`Verdict`, on any blocker. It creates only what is missing, and a second run
|
|
56
|
+
against a converged server changes nothing. Grants are scoped to the one database,
|
|
57
|
+
with the `_` and `%` wildcards in its name escaped.
|
|
58
|
+
|
|
59
|
+
Destructive operations — `revoke`, `drop_user`, `drop_database` — take
|
|
60
|
+
`confirm=`, which must repeat the target's name.
|
|
61
|
+
|
|
62
|
+
## From wp-config.php
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
from l3io.wp.database import Database, Environment, WpConfigFile
|
|
66
|
+
|
|
67
|
+
site = WpConfigFile("/var/www/html/wp-config.php")
|
|
68
|
+
db = Database(site.connection())
|
|
69
|
+
survey = db.survey(
|
|
70
|
+
Environment("DB_ADMIN_USER", "DB_ADMIN_PASSWORD"),
|
|
71
|
+
wordpress_user=site.resolve().username,
|
|
72
|
+
table_prefix=site.table_prefix,
|
|
73
|
+
)
|
|
74
|
+
print(survey.database_exists, survey.table_count, survey.is_wordpress, survey.schema_version)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
`inspect` asks the WordPress account itself what it can do, and never raises
|
|
78
|
+
for an ordinary failure:
|
|
79
|
+
|
|
80
|
+
| State | Meaning |
|
|
81
|
+
|---|---|
|
|
82
|
+
| `ready` | connected, authenticated, and the database is usable |
|
|
83
|
+
| `database_absent` | authenticated, but the database does not exist |
|
|
84
|
+
| `access_denied` | authenticated, but may not use this database |
|
|
85
|
+
| `authentication_failed` | the server rejected the account |
|
|
86
|
+
| `unreachable` | the server could not be reached |
|
|
87
|
+
| `credentials_unobtainable` | the credentials could not be obtained; nothing was attempted |
|
|
88
|
+
|
|
89
|
+
## Export and import
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
from l3io.wp.database import (
|
|
93
|
+
Connection,
|
|
94
|
+
Database,
|
|
95
|
+
Environment,
|
|
96
|
+
WpConfigFile,
|
|
97
|
+
encrypt,
|
|
98
|
+
generate_identity,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
site = WpConfigFile("/var/www/html/wp-config.php")
|
|
102
|
+
identity, recipient = generate_identity()
|
|
103
|
+
Database(site.connection()).dump(site, "/backups/wp_site.sql.age", recipients=[recipient])
|
|
104
|
+
# Or passphrase mode (mutually exclusive with recipients= / identities=):
|
|
105
|
+
# Database(site.connection()).dump(site, "/backups/wp_site.sql.age", passphrase=secret)
|
|
106
|
+
|
|
107
|
+
admin = Environment("DB_ADMIN_USER", "DB_ADMIN_PASSWORD")
|
|
108
|
+
staging = Database(Connection("127.0.0.1", "wp_site_staging"))
|
|
109
|
+
staging.ensure(admin, site)
|
|
110
|
+
staging.load(site, "/backups/wp_site.sql.age", identities=[identity])
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Dumps are made by the server's own client tools (`mysqldump` or `mariadb-dump`),
|
|
114
|
+
and loads by `mysql` or `mariadb`. The password reaches them only through an
|
|
115
|
+
anonymous pipe: never an argument, an environment variable or a file. A dump to
|
|
116
|
+
a path is written under a temporary name and renamed only on success. `iter_dump`
|
|
117
|
+
yields the dump as chunks, and raises at the end if the tool failed, so a
|
|
118
|
+
truncated dump is never mistaken for a whole one.
|
|
119
|
+
|
|
120
|
+
Loading into a database that holds tables requires `replace=` naming it. A damaged
|
|
121
|
+
source is discovered mid-load, after the statements before the damage have run,
|
|
122
|
+
so load into a staging database and switch to it once the load succeeds.
|
|
123
|
+
|
|
124
|
+
Encryption is standard [age](https://age-encryption.org): recipient keys or a
|
|
125
|
+
passphrase (scrypt sole recipient). Either streams; any dump can be decrypted
|
|
126
|
+
with the stock `age` command line. Passphrase mode uses the
|
|
127
|
+
`l3io-age-passphrase` binder by default, or `age` + `age-plugin-batchpass` when
|
|
128
|
+
`L3IO_AGE_PASSPHRASE_BACKEND=cli` (or when the binder is absent under `auto`).
|
|
129
|
+
Archive callers can also
|
|
130
|
+
`from l3io.wp.database import encrypt, decrypt, check_passphrase`.
|
|
131
|
+
|
|
132
|
+
## Command line
|
|
133
|
+
|
|
134
|
+
```shell
|
|
135
|
+
l3io-wordpress-database survey --wp-config /var/www/html/wp-config.php \
|
|
136
|
+
--admin-user root --admin-password-env DB_ADMIN_PASSWORD
|
|
137
|
+
l3io-wordpress-database check ensure --wp-config /var/www/html/wp-config.php \
|
|
138
|
+
--admin-user root --admin-password-env DB_ADMIN_PASSWORD
|
|
139
|
+
l3io-wordpress-database ensure --wp-config /var/www/html/wp-config.php \
|
|
140
|
+
--admin-user root --admin-password-env DB_ADMIN_PASSWORD
|
|
141
|
+
l3io-wordpress-database dump --wp-config /var/www/html/wp-config.php \
|
|
142
|
+
--output wp_site.sql.age --recipient age1examplerecipient
|
|
143
|
+
l3io-wordpress-database dump --wp-config /var/www/html/wp-config.php \
|
|
144
|
+
--output wp_site.sql.age --passphrase-file /run/secrets/age-passphrase
|
|
145
|
+
l3io-wordpress-database load --db-host 127.0.0.1 --db-name wp_site_staging \
|
|
146
|
+
--db-user wpuser --db-password-env DB_PASSWORD \
|
|
147
|
+
--input wp_site.sql.age --identity key.txt
|
|
148
|
+
l3io-wordpress-database load --db-host 127.0.0.1 --db-name wp_site_staging \
|
|
149
|
+
--db-user wpuser --db-password-env DB_PASSWORD \
|
|
150
|
+
--input wp_site.sql.age --ask-passphrase
|
|
151
|
+
l3io-wordpress-database drop-database --db-host 127.0.0.1 --db-name wp_site_staging \
|
|
152
|
+
--admin-user root --admin-password-env DB_ADMIN_PASSWORD --confirm wp_site_staging
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
No password or age passphrase is accepted as an argument value, because an
|
|
156
|
+
argument vector is visible to every user on the machine: a password comes from
|
|
157
|
+
`--wp-config`, from the environment variable named by a `--*-password-env`
|
|
158
|
+
option, from AWS Secrets Manager, or from a prompt; an age passphrase comes from
|
|
159
|
+
`--passphrase-file` or `--ask-passphrase` (mutually exclusive with
|
|
160
|
+
`--recipient` / `--identity`). Results are printed as JSON on stdout; with
|
|
161
|
+
`dump --output -` the dump is on stdout and the result on stderr.
|
|
162
|
+
|
|
163
|
+
| Exit code | Meaning |
|
|
164
|
+
|---|---|
|
|
165
|
+
| `0` | success |
|
|
166
|
+
| `1` | the operation started and failed |
|
|
167
|
+
| `2` | usage |
|
|
168
|
+
| `3` | refused: the check found a blocker, and nothing ran |
|
|
169
|
+
| `4` | credentials could not be obtained |
|
|
170
|
+
| `5` | the server could not be reached |
|
|
171
|
+
| `7` | not ready: `inspect` found the account cannot use the database |
|
|
172
|
+
|
|
173
|
+
## Supported
|
|
174
|
+
|
|
175
|
+
- Python 3.11, 3.12, 3.13 and 3.14.
|
|
176
|
+
- MySQL 8.0 and later, MariaDB 10.2 and later. CI runs MySQL 8.0, 8.4 and 9.7 and
|
|
177
|
+
MariaDB 10.2 and 11.4.
|
|
178
|
+
- Export and import need `/dev/fd` (Linux or macOS) and the client tools on `PATH`.
|
|
179
|
+
Everything else is pure Python.
|
|
180
|
+
|
|
181
|
+
The package logs through `structlog` and never configures logging, and opens one
|
|
182
|
+
OpenTelemetry span per operation through `opentelemetry-api`, inert until the host
|
|
183
|
+
configures an SDK.
|
|
184
|
+
|
|
185
|
+
## Design records
|
|
186
|
+
|
|
187
|
+
This package's decisions are in [docs/adr](docs/adr/README.md). The rules the
|
|
188
|
+
`l3io.wp` family shares (cited as AD-n) are in `l3io-wordpress-backup`'s
|
|
189
|
+
architecture spine.
|
|
190
|
+
|
|
191
|
+
## Development
|
|
192
|
+
|
|
193
|
+
```shell
|
|
194
|
+
make sync # locked environment, all extras
|
|
195
|
+
make check # lint, format, types, tests, contracts
|
|
196
|
+
make test-db DB_HOST=127.0.0.1 # integration tests against a server
|
|
197
|
+
make test-transfer ENGINE=mysql:8.4 # export/import with that image's own client tools
|
|
198
|
+
make verify-wheel # install the built wheel, bare and with each extra
|
|
199
|
+
make ci # replay the CI pipeline under act
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
## Licence
|
|
203
|
+
|
|
204
|
+
MIT.
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling", "hatch-vcs"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "l3io-wordpress-database"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Create and inspect WordPress MySQL databases and their users, idempotently."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [{ name = "Shawn Anderson" }]
|
|
13
|
+
keywords = ["wordpress", "mysql", "mariadb", "database", "provisioning"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Environment :: Console",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Programming Language :: Python :: 3.14",
|
|
24
|
+
"Operating System :: OS Independent",
|
|
25
|
+
"Topic :: Internet :: WWW/HTTP :: Site Management",
|
|
26
|
+
"Typing :: Typed",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
# Core stays deliberately small. opentelemetry-api is a hard dependency on purpose:
|
|
30
|
+
# it is ~58KB with one dependency and already ships the no-op NonRecordingSpan /
|
|
31
|
+
# ProxyTracer, so putting it behind an extra would oblige us to hand-roll a shim.
|
|
32
|
+
dependencies = [
|
|
33
|
+
"PyMySQL[rsa]>=1.2,<2",
|
|
34
|
+
"structlog>=26.1,<27",
|
|
35
|
+
"opentelemetry-api>=1.30,<2",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
[project.optional-dependencies]
|
|
39
|
+
aws = ["boto3>=1.34,<2"]
|
|
40
|
+
wpconfig = ["l3io-wordpress-config>=0.1.2,<0.2"]
|
|
41
|
+
encryption = [
|
|
42
|
+
"pyrage>=1.4,<2",
|
|
43
|
+
"l3io-age-passphrase>=0.1.1,<0.2",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
# Mechanical enforcement of the family's uv version, matching l3io-wordpress-config.
|
|
47
|
+
# A lock generated by a different uv version can make `uv sync --locked` fail in
|
|
48
|
+
# CI while passing locally; pinning here makes that impossible rather than
|
|
49
|
+
# remembered. Bump deliberately and across all three repositories together.
|
|
50
|
+
[tool.uv]
|
|
51
|
+
required-version = "==0.11.6"
|
|
52
|
+
|
|
53
|
+
[tool.uv.sources]
|
|
54
|
+
# Development and CI only. Both packages are on TestPyPI and the family Gitea
|
|
55
|
+
# index, but a clean public clone still has no default index for them, so we
|
|
56
|
+
# keep git tags until consumers (and this package) resolve from an index by
|
|
57
|
+
# default. `uv build --no-sources` strips these; check_dist_metadata fails a
|
|
58
|
+
# build that carries a VCS dependency. Delete this block once public PyPI (or
|
|
59
|
+
# a documented default index) carries both.
|
|
60
|
+
l3io-wordpress-config = { git = "https://github.com/LiquidLogicLabs/l3io-wordpress-config.git", tag = "v0.1.3" }
|
|
61
|
+
l3io-age-passphrase = { git = "https://github.com/LiquidLogicLabs/l3io-age-passphrase.git", tag = "v0.1.1" }
|
|
62
|
+
|
|
63
|
+
[project.scripts]
|
|
64
|
+
l3io-wordpress-database = "l3io.wp.database.cli:main"
|
|
65
|
+
|
|
66
|
+
[project.urls]
|
|
67
|
+
Homepage = "https://github.com/LiquidLogicLabs/l3io-wordpress-database"
|
|
68
|
+
Source = "https://github.com/LiquidLogicLabs/l3io-wordpress-database"
|
|
69
|
+
|
|
70
|
+
[dependency-groups]
|
|
71
|
+
dev = [
|
|
72
|
+
"ruff==0.16.7",
|
|
73
|
+
"mypy==2.3.1",
|
|
74
|
+
"pytest==9.1.1",
|
|
75
|
+
"pytest-cov==7.1.0",
|
|
76
|
+
"types-pymysql>=1.2.0.20260807",
|
|
77
|
+
# The contract tests read the workflow files. A maintained parser, because the
|
|
78
|
+
# one thing this repo's rules will not tolerate is a hand-written YAML parser.
|
|
79
|
+
"pyyaml==6.0.3",
|
|
80
|
+
"types-pyyaml==6.0.12.20260906",
|
|
81
|
+
# tests/test_declared_facts.py compares requirement names, specifiers and
|
|
82
|
+
# versions. Declared directly rather than relied on through pytest's own
|
|
83
|
+
# dependency on it.
|
|
84
|
+
"packaging==26.3",
|
|
85
|
+
# Span assertions need a real SDK; the package itself depends on the API only.
|
|
86
|
+
"opentelemetry-sdk==1.45.1",
|
|
87
|
+
# The encryption extra, also here because `uv run` prunes extras.
|
|
88
|
+
"pyrage==1.4.0",
|
|
89
|
+
"l3io-age-passphrase>=0.1.1,<0.2",
|
|
90
|
+
# The optional extras are dev dependencies too, so a bare `uv run` can
|
|
91
|
+
# exercise the code paths behind them. `uv run` syncs the default groups
|
|
92
|
+
# only, which PRUNES extras -- without this, every `uv run` silently
|
|
93
|
+
# uninstalled the wpconfig extra and its tests began skipping.
|
|
94
|
+
#
|
|
95
|
+
# This does not weaken the optionality guarantee. That is enforced against
|
|
96
|
+
# the BUILT WHEEL in an isolated environment, by the verify-install job and
|
|
97
|
+
# `make verify-wheel`, not by whatever happens to be in the dev environment.
|
|
98
|
+
"boto3>=1.34,<2",
|
|
99
|
+
"l3io-wordpress-config>=0.1.2,<0.2",
|
|
100
|
+
]
|
|
101
|
+
|
|
102
|
+
# AD-27: l3io.wp is a PEP 420 native namespace. Ship no __init__.py at
|
|
103
|
+
# src/l3io/ or src/l3io/wp/ -- doing so shadows the namespace and makes the
|
|
104
|
+
# sibling distributions unimportable, while this package's own tests still pass.
|
|
105
|
+
[tool.hatch.version]
|
|
106
|
+
# AD-30: the git tag IS the version. There is no literal anywhere in this repo
|
|
107
|
+
# to drift from it -- not in pyproject, not in the package -- so "keep the tag
|
|
108
|
+
# and the package in sync" is not a rule anyone can break.
|
|
109
|
+
source = "vcs"
|
|
110
|
+
|
|
111
|
+
[tool.hatch.version.raw-options]
|
|
112
|
+
# PyPI rejects local version labels, so a dev build must not carry +g<sha>.
|
|
113
|
+
local_scheme = "no-local-version"
|
|
114
|
+
# Only three-component tags count. Without this the abandoned 2018 `v1.0` tag
|
|
115
|
+
# is the nearest ancestor of every commit on main, and setuptools-scm reads its
|
|
116
|
+
# two components and guesses the next MINOR: builds came out as 1.1.dev39, which
|
|
117
|
+
# sorts ABOVE 1.0.0 in PEP 440, so publishing one would have permanently
|
|
118
|
+
# outranked the release.
|
|
119
|
+
#
|
|
120
|
+
# The match also needs a three-component ancestor to find, which is what the
|
|
121
|
+
# v0.0.0 BASELINE tag provides. Without it the fallback was 0.1.devN, and
|
|
122
|
+
# 0.1.dev1 > 0.0.1 in PEP 440 -- verified, not assumed -- so a dev build published
|
|
123
|
+
# before the first release would have outranked it permanently. From the baseline
|
|
124
|
+
# an untagged tree derives 0.0.1.devN, which sorts below 0.0.1.
|
|
125
|
+
#
|
|
126
|
+
# v0.0.0 is not a release and is excluded from publish.yml's tag trigger.
|
|
127
|
+
git_describe_command = "git describe --dirty --tags --long --match v[0-9]*.[0-9]*.[0-9]*"
|
|
128
|
+
|
|
129
|
+
[tool.hatch.build.targets.wheel]
|
|
130
|
+
packages = ["src/l3io"]
|
|
131
|
+
|
|
132
|
+
[tool.hatch.build.targets.sdist]
|
|
133
|
+
only-include = ["src/l3io", "README.md", "LICENSE"]
|
|
134
|
+
|
|
135
|
+
[tool.ruff]
|
|
136
|
+
line-length = 100
|
|
137
|
+
target-version = "py311"
|
|
138
|
+
# Installed tooling and generated artifacts, not this project's source. CI lints
|
|
139
|
+
# `.` rather than a hand-listed set of directories so a new package directory is
|
|
140
|
+
# covered automatically -- which means the exclusions, not the inclusions, are
|
|
141
|
+
# what has to be maintained.
|
|
142
|
+
extend-exclude = ["_bmad", "_bmad-output", ".claude", ".agents", ".github"]
|
|
143
|
+
|
|
144
|
+
[tool.ruff.lint]
|
|
145
|
+
select = ["E", "F", "W", "I", "N", "UP", "B", "A", "C4", "SIM", "RET", "ARG", "PTH", "BLE", "TRY", "EM", "S"]
|
|
146
|
+
# BLE001 (blind except): a bare `except Exception` silently turns distinct failures
|
|
147
|
+
# into one benign-looking result.
|
|
148
|
+
ignore = []
|
|
149
|
+
|
|
150
|
+
[tool.ruff.lint.per-file-ignores]
|
|
151
|
+
# S101 asserts are the point of a test; S105-S107 flag the fake
|
|
152
|
+
# credentials every credential test must construct.
|
|
153
|
+
"tests/*" = ["S101", "S105", "S106", "S107", "ARG"]
|
|
154
|
+
|
|
155
|
+
[tool.mypy]
|
|
156
|
+
python_version = "3.11"
|
|
157
|
+
strict = true
|
|
158
|
+
files = ["src", "tests"]
|
|
159
|
+
# AD-27: l3io/ and l3io/wp/ have no __init__.py by design, so mypy needs to be
|
|
160
|
+
# told where package roots begin. The fix for "source file found twice" here is
|
|
161
|
+
# this setting -- never adding an __init__.py, which would break the siblings.
|
|
162
|
+
mypy_path = "src"
|
|
163
|
+
namespace_packages = true
|
|
164
|
+
explicit_package_bases = true
|
|
165
|
+
|
|
166
|
+
[[tool.mypy.overrides]]
|
|
167
|
+
# boto3 lives behind the `aws` extra and is absent from a base install, which
|
|
168
|
+
# is the point of AD-8.
|
|
169
|
+
module = ["boto3", "boto3.*"]
|
|
170
|
+
ignore_missing_imports = true
|
|
171
|
+
|
|
172
|
+
[[tool.mypy.overrides]]
|
|
173
|
+
# pyrage lives behind the `encryption` extra and ships no type information.
|
|
174
|
+
module = ["pyrage", "pyrage.*"]
|
|
175
|
+
ignore_missing_imports = true
|
|
176
|
+
|
|
177
|
+
[[tool.mypy.overrides]]
|
|
178
|
+
# l3io-age-passphrase is a compiled extension behind the encryption extra.
|
|
179
|
+
module = ["l3io_age_passphrase", "l3io_age_passphrase.*"]
|
|
180
|
+
ignore_missing_imports = true
|
|
181
|
+
|
|
182
|
+
[tool.pytest.ini_options]
|
|
183
|
+
testpaths = ["tests"]
|
|
184
|
+
addopts = "-q"
|
|
185
|
+
markers = ["slow: builds a wheel or talks to a real database server", "transfer: export/import tests run by make test-transfer inside a server image"]
|
|
186
|
+
|
|
187
|
+
# Just under the line coverage the integration legs measure (89% on MySQL 8.4), so
|
|
188
|
+
# a deleted test file fails CI instead of passing quietly. pytest-cov applies it to
|
|
189
|
+
# every run that measures coverage; `make test` measures none.
|
|
190
|
+
[tool.coverage.report]
|
|
191
|
+
fail_under = 87
|