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.
@@ -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
+ )