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
l3io/wp/database/cli.py
ADDED
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
"""Command line entry point.
|
|
2
|
+
|
|
3
|
+
The library never configures logging; this does, because it owns the process.
|
|
4
|
+
No option takes a password value: an argument vector is visible to every user
|
|
5
|
+
on the machine. Passwords come from environment variables, wp-config.php, AWS,
|
|
6
|
+
or a prompt on a terminal.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import dataclasses
|
|
13
|
+
import getpass
|
|
14
|
+
import json
|
|
15
|
+
import logging
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
from collections.abc import Mapping
|
|
19
|
+
from enum import Enum, IntEnum
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
import structlog
|
|
24
|
+
|
|
25
|
+
from ._version import __version__
|
|
26
|
+
from .connection import Connection
|
|
27
|
+
from .credentials import AwsSecretsManager, CredentialSource, Explicit
|
|
28
|
+
from .errors import (
|
|
29
|
+
ConfigurationError,
|
|
30
|
+
CredentialsAbsentError,
|
|
31
|
+
CredentialsUnobtainableError,
|
|
32
|
+
DatabaseError,
|
|
33
|
+
DatabaseUnreachableError,
|
|
34
|
+
InvalidArgumentsError,
|
|
35
|
+
InvalidIdentifierError,
|
|
36
|
+
MissingDependencyError,
|
|
37
|
+
RefusedError,
|
|
38
|
+
)
|
|
39
|
+
from .inspection import inspect, schema_version, survey
|
|
40
|
+
from .provision import (
|
|
41
|
+
check_drop_database,
|
|
42
|
+
check_drop_user,
|
|
43
|
+
check_ensure,
|
|
44
|
+
check_revoke,
|
|
45
|
+
check_rotate_password,
|
|
46
|
+
drop_database,
|
|
47
|
+
drop_user,
|
|
48
|
+
ensure,
|
|
49
|
+
revoke,
|
|
50
|
+
rotate_password,
|
|
51
|
+
)
|
|
52
|
+
from .transfer import check_drop_tables, check_dump, check_load, drop_tables, dump, load
|
|
53
|
+
from .verdict import Code, Verdict
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Exit(IntEnum):
|
|
57
|
+
"""Process exit codes. Part of the CLI contract."""
|
|
58
|
+
|
|
59
|
+
OK = 0
|
|
60
|
+
FAILED = 1
|
|
61
|
+
USAGE = 2
|
|
62
|
+
REFUSED = 3
|
|
63
|
+
CREDENTIALS = 4
|
|
64
|
+
UNREACHABLE = 5
|
|
65
|
+
NOT_READY = 7
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
_NEEDS_WP = {
|
|
69
|
+
"inspect",
|
|
70
|
+
"schema-version",
|
|
71
|
+
"ensure",
|
|
72
|
+
"rotate-password",
|
|
73
|
+
"dump",
|
|
74
|
+
"load",
|
|
75
|
+
"drop-tables",
|
|
76
|
+
}
|
|
77
|
+
_NEEDS_ADMIN = {"survey", "ensure", "rotate-password", "revoke", "drop-user", "drop-database"}
|
|
78
|
+
_NAMED_USER = {"revoke", "drop-user"}
|
|
79
|
+
_CONFIRM = {"revoke", "drop-user", "drop-database"}
|
|
80
|
+
_CHECKABLE = [
|
|
81
|
+
"ensure",
|
|
82
|
+
"rotate-password",
|
|
83
|
+
"revoke",
|
|
84
|
+
"drop-user",
|
|
85
|
+
"drop-database",
|
|
86
|
+
"dump",
|
|
87
|
+
"load",
|
|
88
|
+
"drop-tables",
|
|
89
|
+
]
|
|
90
|
+
_TRANSFER = {"dump", "load", "drop-tables"}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _add_operation_options(
|
|
94
|
+
p: argparse.ArgumentParser, operation: str, *, check: bool = False
|
|
95
|
+
) -> None:
|
|
96
|
+
site = p.add_argument_group("site")
|
|
97
|
+
site.add_argument("--wp-config", help="path to wp-config.php (needs the 'wpconfig' extra)")
|
|
98
|
+
site.add_argument("--db-host", help="database host, optionally host:port")
|
|
99
|
+
site.add_argument("--db-name", help="database name")
|
|
100
|
+
if operation in _NEEDS_WP:
|
|
101
|
+
wp = p.add_argument_group("WordPress account (or --wp-config)")
|
|
102
|
+
wp.add_argument("--db-user", help="WordPress database user")
|
|
103
|
+
wp.add_argument("--db-password-env", metavar="VAR", help="variable holding its password")
|
|
104
|
+
if operation in _NEEDS_ADMIN:
|
|
105
|
+
admin = p.add_argument_group("administrative account (required)")
|
|
106
|
+
admin.add_argument("--admin-user", help="administrative user")
|
|
107
|
+
admin.add_argument(
|
|
108
|
+
"--admin-password-env", metavar="VAR", help="variable holding its password"
|
|
109
|
+
)
|
|
110
|
+
admin.add_argument("--admin-aws-secret-id", help="AWS secret holding the admin credentials")
|
|
111
|
+
admin.add_argument("--admin-aws-region", help="AWS region of that secret")
|
|
112
|
+
if operation in {"survey", "ensure", "rotate-password", *_NAMED_USER}:
|
|
113
|
+
p.add_argument("--user-host", default="%", help="host part of the account (default: %%)")
|
|
114
|
+
if operation in _NAMED_USER:
|
|
115
|
+
p.add_argument("--user", required=True, help="the account to act on")
|
|
116
|
+
if operation in _CONFIRM:
|
|
117
|
+
p.add_argument("--confirm", required=True, metavar="NAME", help="repeat the target's name")
|
|
118
|
+
if operation == "survey":
|
|
119
|
+
p.add_argument(
|
|
120
|
+
"--wordpress-user", help="also report this account's existence and privileges"
|
|
121
|
+
)
|
|
122
|
+
if operation in {"survey", "schema-version"}:
|
|
123
|
+
p.add_argument(
|
|
124
|
+
"--table-prefix", help="WordPress $table_prefix (default: from wp-config, else wp_)"
|
|
125
|
+
)
|
|
126
|
+
if operation == "dump":
|
|
127
|
+
# A check writes and encrypts nothing, so it takes neither option.
|
|
128
|
+
if not check:
|
|
129
|
+
p.add_argument("--output", required=True, help="file to write, or - for stdout")
|
|
130
|
+
p.add_argument(
|
|
131
|
+
"--recipient",
|
|
132
|
+
action="append",
|
|
133
|
+
default=[],
|
|
134
|
+
help="age or SSH public key (repeatable)",
|
|
135
|
+
)
|
|
136
|
+
p.add_argument(
|
|
137
|
+
"--passphrase-file",
|
|
138
|
+
metavar="PATH",
|
|
139
|
+
help="file holding the age passphrase (never pass the secret on argv)",
|
|
140
|
+
)
|
|
141
|
+
p.add_argument(
|
|
142
|
+
"--ask-passphrase",
|
|
143
|
+
action="store_true",
|
|
144
|
+
help="prompt on a terminal for the age passphrase",
|
|
145
|
+
)
|
|
146
|
+
p.add_argument(
|
|
147
|
+
"--option",
|
|
148
|
+
action="append",
|
|
149
|
+
default=[],
|
|
150
|
+
help="extra dump option (allow-listed, repeatable); join it with '=': "
|
|
151
|
+
"--option=--ignore-table=wp_site.wp_logs",
|
|
152
|
+
)
|
|
153
|
+
if operation == "load":
|
|
154
|
+
p.add_argument("--input", required=True, help="dump file to read, or - for stdin")
|
|
155
|
+
p.add_argument(
|
|
156
|
+
"--identity", action="append", default=[], help="age identity file (repeatable)"
|
|
157
|
+
)
|
|
158
|
+
p.add_argument(
|
|
159
|
+
"--identity-passphrase-env",
|
|
160
|
+
metavar="VAR",
|
|
161
|
+
help="variable holding the identity files' passphrase",
|
|
162
|
+
)
|
|
163
|
+
p.add_argument(
|
|
164
|
+
"--passphrase-file",
|
|
165
|
+
metavar="PATH",
|
|
166
|
+
help="file holding the age passphrase (never pass the secret on argv)",
|
|
167
|
+
)
|
|
168
|
+
p.add_argument(
|
|
169
|
+
"--ask-passphrase",
|
|
170
|
+
action="store_true",
|
|
171
|
+
help="prompt on a terminal for the age passphrase",
|
|
172
|
+
)
|
|
173
|
+
p.add_argument(
|
|
174
|
+
"--replace", metavar="NAME", help="repeat the target's name to load over its tables"
|
|
175
|
+
)
|
|
176
|
+
p.add_argument(
|
|
177
|
+
"--prune-prefix",
|
|
178
|
+
metavar="PREFIX",
|
|
179
|
+
help="drop base tables and views with this name prefix before loading",
|
|
180
|
+
)
|
|
181
|
+
p.add_argument(
|
|
182
|
+
"--allow-unsandboxed",
|
|
183
|
+
action="store_true",
|
|
184
|
+
help="strip MariaDB's sandbox line for an older client",
|
|
185
|
+
)
|
|
186
|
+
p.add_argument(
|
|
187
|
+
"--option",
|
|
188
|
+
action="append",
|
|
189
|
+
default=[],
|
|
190
|
+
help="extra client option (allow-listed, repeatable); join it with '=': "
|
|
191
|
+
"--option=--binary-mode",
|
|
192
|
+
)
|
|
193
|
+
if operation == "drop-tables":
|
|
194
|
+
p.add_argument(
|
|
195
|
+
"--prefix",
|
|
196
|
+
required=True,
|
|
197
|
+
help="drop base tables and views whose names start with this prefix",
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
202
|
+
"""Build the argument parser."""
|
|
203
|
+
parser = argparse.ArgumentParser(
|
|
204
|
+
prog="l3io-wordpress-database",
|
|
205
|
+
description="WordPress database operations, each checked before it runs.",
|
|
206
|
+
)
|
|
207
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
208
|
+
parser.add_argument("--log-level", default="INFO", help="logging level (default: INFO)")
|
|
209
|
+
parser.add_argument("--log-format", choices=["console", "json"], default="console")
|
|
210
|
+
sub = parser.add_subparsers(dest="operation", required=True)
|
|
211
|
+
for name in ["survey", "inspect", "schema-version", *_CHECKABLE]:
|
|
212
|
+
_add_operation_options(sub.add_parser(name), name)
|
|
213
|
+
check = sub.add_parser("check", help="run an operation's check only")
|
|
214
|
+
check_sub = check.add_subparsers(dest="checked", required=True)
|
|
215
|
+
for name in _CHECKABLE:
|
|
216
|
+
_add_operation_options(check_sub.add_parser(name), name, check=True)
|
|
217
|
+
return parser
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _configure_logging(level: str, fmt: str) -> None:
|
|
221
|
+
numeric = logging.getLevelNamesMapping().get(level.upper(), logging.INFO)
|
|
222
|
+
logging.basicConfig(level=numeric, format="%(message)s", stream=sys.stderr)
|
|
223
|
+
renderer: Any = (
|
|
224
|
+
structlog.processors.JSONRenderer() if fmt == "json" else structlog.dev.ConsoleRenderer()
|
|
225
|
+
)
|
|
226
|
+
structlog.configure(
|
|
227
|
+
processors=[
|
|
228
|
+
structlog.contextvars.merge_contextvars,
|
|
229
|
+
structlog.processors.add_log_level,
|
|
230
|
+
structlog.processors.TimeStamper(fmt="iso"),
|
|
231
|
+
renderer,
|
|
232
|
+
],
|
|
233
|
+
wrapper_class=structlog.make_filtering_bound_logger(numeric),
|
|
234
|
+
logger_factory=structlog.PrintLoggerFactory(file=sys.stderr),
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _password(var: str | None, who: str) -> str:
|
|
239
|
+
if var is not None:
|
|
240
|
+
value = os.environ.get(var)
|
|
241
|
+
if value is None:
|
|
242
|
+
msg = f"{var} is not set"
|
|
243
|
+
raise CredentialsUnobtainableError(msg)
|
|
244
|
+
return value
|
|
245
|
+
if sys.stdin.isatty():
|
|
246
|
+
return getpass.getpass(f"password for {who}: ")
|
|
247
|
+
msg = f"no password source for {who}: give the -env option or run on a terminal"
|
|
248
|
+
raise CredentialsUnobtainableError(msg)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _site(args: argparse.Namespace) -> tuple[Connection, Any]:
|
|
252
|
+
if args.wp_config:
|
|
253
|
+
if args.db_host or args.db_name:
|
|
254
|
+
msg = "give either --wp-config or --db-host/--db-name, not both"
|
|
255
|
+
raise InvalidArgumentsError(msg)
|
|
256
|
+
from .wpconfig import WpConfigFile
|
|
257
|
+
|
|
258
|
+
config = WpConfigFile(args.wp_config)
|
|
259
|
+
return config.connection(), config
|
|
260
|
+
if not (args.db_host and args.db_name):
|
|
261
|
+
msg = "give --wp-config, or both --db-host and --db-name"
|
|
262
|
+
raise InvalidArgumentsError(msg)
|
|
263
|
+
return Connection.from_db_host(args.db_host, args.db_name), None
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _wordpress(args: argparse.Namespace, config: Any) -> CredentialSource:
|
|
267
|
+
if config is not None and not getattr(args, "db_user", None):
|
|
268
|
+
return config # type: ignore[no-any-return]
|
|
269
|
+
if not getattr(args, "db_user", None):
|
|
270
|
+
msg = "give --wp-config or --db-user"
|
|
271
|
+
raise InvalidArgumentsError(msg)
|
|
272
|
+
return Explicit(args.db_user, _password(args.db_password_env, args.db_user))
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _admin(args: argparse.Namespace) -> CredentialSource:
|
|
276
|
+
if args.admin_aws_secret_id:
|
|
277
|
+
if args.admin_user:
|
|
278
|
+
msg = "give either --admin-user or --admin-aws-secret-id, not both"
|
|
279
|
+
raise InvalidArgumentsError(msg)
|
|
280
|
+
return AwsSecretsManager(args.admin_aws_secret_id, args.admin_aws_region)
|
|
281
|
+
if not args.admin_user:
|
|
282
|
+
msg = "administrative credentials are required: give --admin-user or --admin-aws-secret-id"
|
|
283
|
+
raise InvalidArgumentsError(msg)
|
|
284
|
+
return Explicit(args.admin_user, _password(args.admin_password_env, args.admin_user))
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _jsonable(obj: Any) -> Any:
|
|
288
|
+
"""Convert a result to JSON-ready data.
|
|
289
|
+
|
|
290
|
+
Not ``dataclasses.asdict``: it deep-copies values, and a Finding's fields
|
|
291
|
+
are a read-only mapping that cannot be copied.
|
|
292
|
+
"""
|
|
293
|
+
if isinstance(obj, Verdict):
|
|
294
|
+
return {
|
|
295
|
+
"operation": obj.operation,
|
|
296
|
+
"ok": obj.ok,
|
|
297
|
+
"findings": [
|
|
298
|
+
{
|
|
299
|
+
"code": str(f.code),
|
|
300
|
+
"severity": str(f.severity),
|
|
301
|
+
"message": f.message,
|
|
302
|
+
"fields": dict(f.fields),
|
|
303
|
+
}
|
|
304
|
+
for f in obj.findings
|
|
305
|
+
],
|
|
306
|
+
}
|
|
307
|
+
if dataclasses.is_dataclass(obj) and not isinstance(obj, type):
|
|
308
|
+
return {f.name: _jsonable(getattr(obj, f.name)) for f in dataclasses.fields(obj)}
|
|
309
|
+
if isinstance(obj, Enum):
|
|
310
|
+
return str(obj.value)
|
|
311
|
+
if isinstance(obj, Mapping):
|
|
312
|
+
return {str(k): _jsonable(v) for k, v in obj.items()}
|
|
313
|
+
if isinstance(obj, (set, frozenset)):
|
|
314
|
+
return sorted(_jsonable(v) for v in obj)
|
|
315
|
+
if isinstance(obj, (list, tuple)):
|
|
316
|
+
return [_jsonable(v) for v in obj]
|
|
317
|
+
return obj
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _identities(args: argparse.Namespace) -> list[str]:
|
|
321
|
+
from .encryption import read_identity
|
|
322
|
+
|
|
323
|
+
passphrase = (
|
|
324
|
+
_password(args.identity_passphrase_env, "the identity files")
|
|
325
|
+
if args.identity_passphrase_env
|
|
326
|
+
else None
|
|
327
|
+
)
|
|
328
|
+
return [read_identity(path, passphrase) for path in args.identity]
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _passphrase(args: argparse.Namespace) -> str | None:
|
|
332
|
+
"""Age content passphrase from a file or a TTY prompt — never from argv."""
|
|
333
|
+
path = getattr(args, "passphrase_file", None)
|
|
334
|
+
ask = bool(getattr(args, "ask_passphrase", False))
|
|
335
|
+
if path is not None and ask:
|
|
336
|
+
msg = "supply --passphrase-file or --ask-passphrase, not both"
|
|
337
|
+
raise InvalidArgumentsError(msg)
|
|
338
|
+
if path is not None:
|
|
339
|
+
try:
|
|
340
|
+
text = Path(path).read_text(encoding="utf-8")
|
|
341
|
+
except OSError as exc:
|
|
342
|
+
msg = f"cannot read the passphrase file {path!r}: {exc.strerror}"
|
|
343
|
+
raise InvalidArgumentsError(msg) from exc
|
|
344
|
+
secret = text.rstrip("\n")
|
|
345
|
+
if not secret:
|
|
346
|
+
msg = f"passphrase file {path!r} is empty"
|
|
347
|
+
raise InvalidArgumentsError(msg)
|
|
348
|
+
return secret
|
|
349
|
+
if ask:
|
|
350
|
+
if not sys.stdin.isatty():
|
|
351
|
+
msg = "no passphrase source: give --passphrase-file or run on a terminal"
|
|
352
|
+
raise CredentialsUnobtainableError(msg)
|
|
353
|
+
return getpass.getpass("age passphrase: ")
|
|
354
|
+
return None
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _emit(value: Any, stream: Any = None) -> None:
|
|
358
|
+
print(json.dumps(_jsonable(value), indent=2), file=stream or sys.stdout)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _run(args: argparse.Namespace) -> int:
|
|
362
|
+
operation = args.checked if args.operation == "check" else args.operation
|
|
363
|
+
connection, config = _site(args)
|
|
364
|
+
prefix = getattr(args, "table_prefix", None) or (config.table_prefix if config else "wp_")
|
|
365
|
+
|
|
366
|
+
if args.operation == "check":
|
|
367
|
+
# Never resolved for a transfer: only the provisioning checks use the admin account.
|
|
368
|
+
admin = Explicit("", "") if operation in _TRANSFER else _admin(args)
|
|
369
|
+
checks: dict[str, Any] = {
|
|
370
|
+
"ensure": lambda: check_ensure(
|
|
371
|
+
connection, admin, _wordpress(args, config), user_host=args.user_host
|
|
372
|
+
),
|
|
373
|
+
"rotate-password": lambda: check_rotate_password(
|
|
374
|
+
connection, admin, _wordpress(args, config), user_host=args.user_host
|
|
375
|
+
),
|
|
376
|
+
"revoke": lambda: check_revoke(
|
|
377
|
+
connection, admin, args.user, user_host=args.user_host, confirm=args.confirm
|
|
378
|
+
),
|
|
379
|
+
"drop-user": lambda: check_drop_user(
|
|
380
|
+
connection, admin, args.user, user_host=args.user_host, confirm=args.confirm
|
|
381
|
+
),
|
|
382
|
+
"drop-database": lambda: check_drop_database(connection, admin, confirm=args.confirm),
|
|
383
|
+
"dump": lambda: check_dump(connection, _wordpress(args, config), options=args.option),
|
|
384
|
+
"load": lambda: check_load(
|
|
385
|
+
connection,
|
|
386
|
+
_wordpress(args, config),
|
|
387
|
+
source=None if args.input == "-" else args.input,
|
|
388
|
+
replace=args.replace,
|
|
389
|
+
prune_prefix=args.prune_prefix,
|
|
390
|
+
identities=_identities(args) or None,
|
|
391
|
+
passphrase=_passphrase(args),
|
|
392
|
+
options=args.option,
|
|
393
|
+
allow_unsandboxed=args.allow_unsandboxed,
|
|
394
|
+
),
|
|
395
|
+
"drop-tables": lambda: check_drop_tables(
|
|
396
|
+
connection, _wordpress(args, config), prefix=args.prefix
|
|
397
|
+
),
|
|
398
|
+
}
|
|
399
|
+
result = checks[operation]()
|
|
400
|
+
verdict = result.verdict if operation == "drop-tables" else result
|
|
401
|
+
_emit(result)
|
|
402
|
+
return Exit.OK if verdict.ok else _refusal_exit(verdict)
|
|
403
|
+
|
|
404
|
+
if operation == "inspect":
|
|
405
|
+
state = inspect(connection, _wordpress(args, config))
|
|
406
|
+
_emit({"state": str(state)})
|
|
407
|
+
return Exit.OK if state.is_ready else Exit.NOT_READY
|
|
408
|
+
if operation == "schema-version":
|
|
409
|
+
version = schema_version(connection, _wordpress(args, config), prefix)
|
|
410
|
+
_emit(
|
|
411
|
+
{
|
|
412
|
+
"db_version": getattr(version, "db_version", None),
|
|
413
|
+
"release": getattr(version, "release", None),
|
|
414
|
+
}
|
|
415
|
+
)
|
|
416
|
+
return Exit.OK
|
|
417
|
+
if operation == "survey":
|
|
418
|
+
_emit(
|
|
419
|
+
survey(
|
|
420
|
+
connection,
|
|
421
|
+
_admin(args),
|
|
422
|
+
wordpress_user=args.wordpress_user,
|
|
423
|
+
user_host=args.user_host,
|
|
424
|
+
table_prefix=prefix,
|
|
425
|
+
)
|
|
426
|
+
)
|
|
427
|
+
return Exit.OK
|
|
428
|
+
if operation == "ensure":
|
|
429
|
+
_emit(ensure(connection, _admin(args), _wordpress(args, config), user_host=args.user_host))
|
|
430
|
+
return Exit.OK
|
|
431
|
+
if operation == "rotate-password":
|
|
432
|
+
_emit(
|
|
433
|
+
rotate_password(
|
|
434
|
+
connection, _admin(args), _wordpress(args, config), user_host=args.user_host
|
|
435
|
+
)
|
|
436
|
+
)
|
|
437
|
+
return Exit.OK
|
|
438
|
+
if operation == "revoke":
|
|
439
|
+
_emit(
|
|
440
|
+
revoke(
|
|
441
|
+
connection, _admin(args), args.user, user_host=args.user_host, confirm=args.confirm
|
|
442
|
+
)
|
|
443
|
+
)
|
|
444
|
+
return Exit.OK
|
|
445
|
+
if operation == "drop-user":
|
|
446
|
+
_emit(
|
|
447
|
+
drop_user(
|
|
448
|
+
connection, _admin(args), args.user, user_host=args.user_host, confirm=args.confirm
|
|
449
|
+
)
|
|
450
|
+
)
|
|
451
|
+
return Exit.OK
|
|
452
|
+
if operation == "dump":
|
|
453
|
+
target = sys.stdout.buffer if args.output == "-" else args.output
|
|
454
|
+
recipients = args.recipient or None
|
|
455
|
+
passphrase = _passphrase(args)
|
|
456
|
+
if recipients and passphrase is not None:
|
|
457
|
+
msg = "supply --recipient or a passphrase option, not both"
|
|
458
|
+
raise InvalidArgumentsError(msg)
|
|
459
|
+
result = dump(
|
|
460
|
+
connection,
|
|
461
|
+
_wordpress(args, config),
|
|
462
|
+
target,
|
|
463
|
+
recipients=recipients,
|
|
464
|
+
passphrase=passphrase,
|
|
465
|
+
options=args.option,
|
|
466
|
+
)
|
|
467
|
+
# With the dump on stdout, the result must not be mixed into it.
|
|
468
|
+
_emit(result, stream=sys.stderr if args.output == "-" else sys.stdout)
|
|
469
|
+
return Exit.OK
|
|
470
|
+
if operation == "load":
|
|
471
|
+
source = sys.stdin.buffer if args.input == "-" else args.input
|
|
472
|
+
identities = _identities(args) or None
|
|
473
|
+
passphrase = _passphrase(args)
|
|
474
|
+
if identities and passphrase is not None:
|
|
475
|
+
msg = "supply --identity or a passphrase option, not both"
|
|
476
|
+
raise InvalidArgumentsError(msg)
|
|
477
|
+
_emit(
|
|
478
|
+
load(
|
|
479
|
+
connection,
|
|
480
|
+
_wordpress(args, config),
|
|
481
|
+
source,
|
|
482
|
+
replace=args.replace,
|
|
483
|
+
prune_prefix=args.prune_prefix,
|
|
484
|
+
identities=identities,
|
|
485
|
+
passphrase=passphrase,
|
|
486
|
+
options=args.option,
|
|
487
|
+
allow_unsandboxed=args.allow_unsandboxed,
|
|
488
|
+
)
|
|
489
|
+
)
|
|
490
|
+
return Exit.OK
|
|
491
|
+
if operation == "drop-tables":
|
|
492
|
+
_emit(drop_tables(connection, _wordpress(args, config), prefix=args.prefix))
|
|
493
|
+
return Exit.OK
|
|
494
|
+
_emit(drop_database(connection, _admin(args), confirm=args.confirm))
|
|
495
|
+
return Exit.OK
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def _refusal_exit(verdict: Verdict) -> Exit:
|
|
499
|
+
if Code.UNREACHABLE in verdict.codes:
|
|
500
|
+
return Exit.UNREACHABLE
|
|
501
|
+
if Code.CREDENTIALS_UNOBTAINABLE in verdict.codes:
|
|
502
|
+
return Exit.CREDENTIALS
|
|
503
|
+
return Exit.REFUSED
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def main(argv: list[str] | None = None) -> int:
|
|
507
|
+
"""Run the command line interface."""
|
|
508
|
+
args = build_parser().parse_args(argv)
|
|
509
|
+
_configure_logging(args.log_level, args.log_format)
|
|
510
|
+
try:
|
|
511
|
+
return _run(args)
|
|
512
|
+
except RefusedError as exc:
|
|
513
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
514
|
+
return _refusal_exit(exc.verdict)
|
|
515
|
+
except (CredentialsUnobtainableError, CredentialsAbsentError) as exc:
|
|
516
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
517
|
+
return Exit.CREDENTIALS
|
|
518
|
+
except DatabaseUnreachableError as exc:
|
|
519
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
520
|
+
return Exit.UNREACHABLE
|
|
521
|
+
except (
|
|
522
|
+
InvalidArgumentsError,
|
|
523
|
+
InvalidIdentifierError,
|
|
524
|
+
MissingDependencyError,
|
|
525
|
+
ConfigurationError,
|
|
526
|
+
) as exc:
|
|
527
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
528
|
+
return Exit.USAGE
|
|
529
|
+
except DatabaseError as exc:
|
|
530
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
531
|
+
return Exit.FAILED
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Where a WordPress database lives and who connects to it.
|
|
2
|
+
|
|
3
|
+
AD-3: one immutable site descriptor. The original had two writers for a single
|
|
4
|
+
value -- ``db_host``'s setter parsed ``host:port`` and derived ``_db_port``,
|
|
5
|
+
while ``db_port`` had its own independent setter, so assigning ``db_host``
|
|
6
|
+
after ``db_port`` silently discarded the port. Parsed once, frozen, no setters.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
|
|
13
|
+
from .errors import InvalidArgumentsError
|
|
14
|
+
from .identifiers import validate_database_name
|
|
15
|
+
from .tls import TlsSettings
|
|
16
|
+
|
|
17
|
+
DEFAULT_PORT = 3306
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _split_db_host(db_host: str) -> tuple[str, int]:
|
|
21
|
+
"""Split WordPress's ``DB_HOST`` (``host`` or ``host:port``)."""
|
|
22
|
+
if not db_host:
|
|
23
|
+
msg = "DB_HOST must not be empty"
|
|
24
|
+
raise InvalidArgumentsError(msg)
|
|
25
|
+
host, separator, port_text = db_host.partition(":")
|
|
26
|
+
if separator and port_text:
|
|
27
|
+
try:
|
|
28
|
+
return host, int(port_text)
|
|
29
|
+
except ValueError as exc:
|
|
30
|
+
msg = f"DB_HOST port {port_text!r} is not an integer"
|
|
31
|
+
raise InvalidArgumentsError(msg) from exc
|
|
32
|
+
return host, DEFAULT_PORT
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True, slots=True)
|
|
36
|
+
class Connection:
|
|
37
|
+
"""Where one WordPress database lives. Carries no credentials."""
|
|
38
|
+
|
|
39
|
+
host: str
|
|
40
|
+
database: str
|
|
41
|
+
port: int = DEFAULT_PORT
|
|
42
|
+
tls: TlsSettings = field(default_factory=TlsSettings)
|
|
43
|
+
|
|
44
|
+
def __post_init__(self) -> None:
|
|
45
|
+
if not self.host:
|
|
46
|
+
msg = "host must not be empty"
|
|
47
|
+
raise InvalidArgumentsError(msg)
|
|
48
|
+
if not isinstance(self.port, int) or not (0 < self.port < 65536):
|
|
49
|
+
msg = f"port must be an integer between 1 and 65535, not {self.port!r}"
|
|
50
|
+
raise InvalidArgumentsError(msg)
|
|
51
|
+
validate_database_name(self.database)
|
|
52
|
+
if not isinstance(self.tls, TlsSettings):
|
|
53
|
+
msg = f"tls must be a TlsSettings, not {type(self.tls).__name__}"
|
|
54
|
+
raise InvalidArgumentsError(msg)
|
|
55
|
+
|
|
56
|
+
@classmethod
|
|
57
|
+
def from_db_host(
|
|
58
|
+
cls,
|
|
59
|
+
db_host: str,
|
|
60
|
+
database: str,
|
|
61
|
+
*,
|
|
62
|
+
tls: TlsSettings | None = None,
|
|
63
|
+
) -> Connection:
|
|
64
|
+
"""Build from WordPress's ``DB_HOST``, which may carry ``host:port``."""
|
|
65
|
+
host, port = _split_db_host(db_host)
|
|
66
|
+
return cls(
|
|
67
|
+
host=host,
|
|
68
|
+
database=database,
|
|
69
|
+
port=port,
|
|
70
|
+
tls=TlsSettings() if tls is None else tls,
|
|
71
|
+
)
|