l3io-wordpress-backup 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- l3io/wp/backup/__init__.py +22 -0
- l3io/wp/backup/_version.py +31 -0
- l3io/wp/backup/adapters/__init__.py +9 -0
- l3io/wp/backup/adapters/clock.py +22 -0
- l3io/wp/backup/adapters/config_writer.py +702 -0
- l3io/wp/backup/adapters/credential_providers.py +236 -0
- l3io/wp/backup/adapters/crypto.py +237 -0
- l3io/wp/backup/adapters/database.py +1555 -0
- l3io/wp/backup/adapters/database_load.py +1263 -0
- l3io/wp/backup/adapters/exclusions.py +83 -0
- l3io/wp/backup/adapters/journal.py +507 -0
- l3io/wp/backup/adapters/lock.py +372 -0
- l3io/wp/backup/adapters/noop.py +666 -0
- l3io/wp/backup/adapters/phase.py +218 -0
- l3io/wp/backup/adapters/preflight.py +869 -0
- l3io/wp/backup/adapters/redaction.py +237 -0
- l3io/wp/backup/adapters/restore_extension.py +341 -0
- l3io/wp/backup/adapters/restore_runner.py +1030 -0
- l3io/wp/backup/adapters/site_files.py +191 -0
- l3io/wp/backup/adapters/store.py +227 -0
- l3io/wp/backup/adapters/tar.py +1648 -0
- l3io/wp/backup/adapters/tracing.py +235 -0
- l3io/wp/backup/adapters/workspace.py +299 -0
- l3io/wp/backup/adapters/wpcli.py +584 -0
- l3io/wp/backup/adapters/wpcli_child.py +135 -0
- l3io/wp/backup/api/__init__.py +104 -0
- l3io/wp/backup/api/errors.py +344 -0
- l3io/wp/backup/api/operations.py +427 -0
- l3io/wp/backup/cli/__init__.py +228 -0
- l3io/wp/backup/cli/adapter_sets.py +255 -0
- l3io/wp/backup/cli/backup.py +1118 -0
- l3io/wp/backup/cli/context.py +382 -0
- l3io/wp/backup/cli/credentials.py +310 -0
- l3io/wp/backup/cli/doctor.py +171 -0
- l3io/wp/backup/cli/exit.py +171 -0
- l3io/wp/backup/cli/logging.py +147 -0
- l3io/wp/backup/cli/output.py +111 -0
- l3io/wp/backup/cli/parser.py +853 -0
- l3io/wp/backup/cli/passphrase.py +301 -0
- l3io/wp/backup/cli/preflight.py +227 -0
- l3io/wp/backup/cli/privileges.py +327 -0
- l3io/wp/backup/cli/restore.py +1571 -0
- l3io/wp/backup/cli/restore_report.py +396 -0
- l3io/wp/backup/cli/restore_request.py +560 -0
- l3io/wp/backup/cli/scope.py +31 -0
- l3io/wp/backup/cli/settings.py +524 -0
- l3io/wp/backup/cli/verify.py +286 -0
- l3io/wp/backup/core/__init__.py +9 -0
- l3io/wp/backup/core/archive_choice.py +156 -0
- l3io/wp/backup/core/backup.py +105 -0
- l3io/wp/backup/core/config_location.py +257 -0
- l3io/wp/backup/core/exclusions.py +189 -0
- l3io/wp/backup/core/relocation.py +437 -0
- l3io/wp/backup/core/restore.py +1246 -0
- l3io/wp/backup/core/restore_journal.py +541 -0
- l3io/wp/backup/core/restore_space.py +224 -0
- l3io/wp/backup/core/verify.py +198 -0
- l3io/wp/backup/domain/__init__.py +9 -0
- l3io/wp/backup/domain/archive_format.py +1072 -0
- l3io/wp/backup/domain/backup_scope.py +21 -0
- l3io/wp/backup/domain/config.py +27 -0
- l3io/wp/backup/domain/credentials.py +125 -0
- l3io/wp/backup/domain/database.py +113 -0
- l3io/wp/backup/domain/errors.py +1434 -0
- l3io/wp/backup/domain/exclusion.py +74 -0
- l3io/wp/backup/domain/exit_codes.py +87 -0
- l3io/wp/backup/domain/flag_map.py +246 -0
- l3io/wp/backup/domain/journal.py +142 -0
- l3io/wp/backup/domain/lock.py +61 -0
- l3io/wp/backup/domain/preflight.py +126 -0
- l3io/wp/backup/domain/restore.py +181 -0
- l3io/wp/backup/domain/search_replace.py +59 -0
- l3io/wp/backup/domain/secret.py +61 -0
- l3io/wp/backup/domain/site.py +60 -0
- l3io/wp/backup/domain/storage_target.py +60 -0
- l3io/wp/backup/ports/__init__.py +33 -0
- l3io/wp/backup/ports/adapter_set.py +66 -0
- l3io/wp/backup/ports/archive.py +136 -0
- l3io/wp/backup/ports/clock.py +23 -0
- l3io/wp/backup/ports/config.py +71 -0
- l3io/wp/backup/ports/crypto.py +30 -0
- l3io/wp/backup/ports/database.py +129 -0
- l3io/wp/backup/ports/exclusion.py +33 -0
- l3io/wp/backup/ports/journal.py +53 -0
- l3io/wp/backup/ports/lock.py +48 -0
- l3io/wp/backup/ports/preflight.py +23 -0
- l3io/wp/backup/ports/restore.py +59 -0
- l3io/wp/backup/ports/search_replace.py +40 -0
- l3io/wp/backup/ports/site_files.py +95 -0
- l3io/wp/backup/ports/tracing.py +80 -0
- l3io/wp/backup/py.typed +0 -0
- l3io_wordpress_backup-1.0.0.dist-info/METADATA +593 -0
- l3io_wordpress_backup-1.0.0.dist-info/RECORD +98 -0
- l3io_wordpress_backup-1.0.0.dist-info/WHEEL +4 -0
- l3io_wordpress_backup-1.0.0.dist-info/entry_points.txt +2 -0
- l3io_wordpress_backup-1.0.0.dist-info/licenses/LICENSE +21 -0
- wpbackup2/__init__.py +7 -0
- wpbackup2/__main__.py +8 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""FR-5 — the exclusion value types both ``core/exclusions`` and ``adapters/tar`` name.
|
|
2
|
+
|
|
3
|
+
Pure. Epic 4 owns the policy and its default patterns; this module defines only
|
|
4
|
+
the values that cross the ``ExclusionPolicy`` port, and :data:`INCLUDE_EVERYTHING`,
|
|
5
|
+
the policy that excludes nothing — what a Safety Snapshot passes, and what the
|
|
6
|
+
default resolves to until E008-S01-001 binds Epic 4's set.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from enum import StrEnum
|
|
13
|
+
from pathlib import PurePosixPath
|
|
14
|
+
from typing import Final
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"INCLUDE_EVERYTHING",
|
|
18
|
+
"ExcludedEntry",
|
|
19
|
+
"ExclusionDecision",
|
|
20
|
+
"ExclusionSource",
|
|
21
|
+
"IncludeEverything",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ExclusionSource(StrEnum):
|
|
26
|
+
"""Where an exclusion policy's patterns came from (the Manifest's key).
|
|
27
|
+
|
|
28
|
+
The one type for the value (BL-E004-016): ``ExclusionSet.source``,
|
|
29
|
+
``ManifestExclusions.source`` and :class:`IncludeEverything`. A host's own
|
|
30
|
+
``ExclusionPolicy`` returns its ``str`` value; the backup converts it when
|
|
31
|
+
it records the Manifest.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
DEFAULT = "default"
|
|
35
|
+
CUSTOM = "custom"
|
|
36
|
+
NONE = "none"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True, slots=True)
|
|
40
|
+
class ExcludedEntry:
|
|
41
|
+
"""One path left out of the Archive, and the pattern that left it out."""
|
|
42
|
+
|
|
43
|
+
path: str
|
|
44
|
+
pattern: str
|
|
45
|
+
is_dir: bool
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True, slots=True)
|
|
49
|
+
class ExclusionDecision:
|
|
50
|
+
"""The policy's answer for one path."""
|
|
51
|
+
|
|
52
|
+
excluded: bool
|
|
53
|
+
pattern: str | None
|
|
54
|
+
protected: bool
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
_INCLUDED: Final = ExclusionDecision(excluded=False, pattern=None, protected=False)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True, slots=True)
|
|
61
|
+
class IncludeEverything:
|
|
62
|
+
"""The policy that excludes nothing; satisfies the ``ExclusionPolicy`` port."""
|
|
63
|
+
|
|
64
|
+
source: ExclusionSource = ExclusionSource.NONE
|
|
65
|
+
patterns: tuple[str, ...] = ()
|
|
66
|
+
|
|
67
|
+
def decide(self, relative: PurePosixPath, *, is_dir: bool) -> ExclusionDecision:
|
|
68
|
+
"""Every path is included."""
|
|
69
|
+
del relative, is_dir
|
|
70
|
+
return _INCLUDED
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
#: The one instance.
|
|
74
|
+
INCLUDE_EVERYTHING: Final = IncludeEverything()
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""FR-21 — the exit-code table: the one source of truth for what a Run exits with.
|
|
2
|
+
|
|
3
|
+
The family codes are those of ``l3io-wp-database``'s ``cli.Exit`` (v0.2.0):
|
|
4
|
+
0 OK, 1 FAILED, 2 USAGE, 3 REFUSED, 4 CREDENTIALS, 5 UNREACHABLE and
|
|
5
|
+
7 NOT_READY, with **6 reserved for the family** and therefore not a member.
|
|
6
|
+
Two codes are this package's own: 8 VERIFICATION_FAILED and 9 LOCKED — the
|
|
7
|
+
next free values after the family's 7, skipping the reserved 6 (decided
|
|
8
|
+
2026-10-20). If the family later reserves a block above 7, these move before
|
|
9
|
+
the first release, never after. ``tests/contracts/test_exit_codes_family.py``
|
|
10
|
+
checks the family members against database's enum without an import here.
|
|
11
|
+
|
|
12
|
+
**Append-only (PRD §10).** A value is never renumbered or reused; a member is
|
|
13
|
+
added only for a genuinely new kind of failure, and only by E002.
|
|
14
|
+
|
|
15
|
+
**How later failures map onto this table**, without a new member:
|
|
16
|
+
|
|
17
|
+
- a non-empty restore target (FR-7) and an incomplete journal (AD-18) are
|
|
18
|
+
refusals: ``REFUSED``;
|
|
19
|
+
- a missing tool — WP-CLI (FR-13), or any FR-27 prerequisite — is
|
|
20
|
+
``NOT_READY``;
|
|
21
|
+
- a decryption failure (Epic 4) is ``CREDENTIALS`` for a missing or wrong key
|
|
22
|
+
and ``VERIFICATION_FAILED`` for corrupt ciphertext — except under ``verify
|
|
23
|
+
--authenticate``, where a wrong passphrase is ``VERIFICATION_FAILED`` too
|
|
24
|
+
(``PassphraseRejectedError``; Shawn, 2026-11-03): the Archive could not be
|
|
25
|
+
authenticated.
|
|
26
|
+
|
|
27
|
+
Each error class carries its code as the class attribute
|
|
28
|
+
``BackupError.exit_code`` (``domain/errors.py``); the CLI reads that attribute
|
|
29
|
+
and never matches over classes.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
from collections.abc import Mapping
|
|
35
|
+
from enum import IntEnum, unique
|
|
36
|
+
from types import MappingProxyType
|
|
37
|
+
from typing import Final
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"DESCRIPTIONS",
|
|
41
|
+
"INTERRUPTED_EXIT",
|
|
42
|
+
"INTERRUPTED_REASON",
|
|
43
|
+
"RESERVED",
|
|
44
|
+
"ExitCode",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@unique
|
|
49
|
+
class ExitCode(IntEnum):
|
|
50
|
+
"""The process exit code of a ``wpbackup2`` Run."""
|
|
51
|
+
|
|
52
|
+
OK = 0
|
|
53
|
+
FAILED = 1
|
|
54
|
+
USAGE = 2
|
|
55
|
+
REFUSED = 3
|
|
56
|
+
CREDENTIALS = 4
|
|
57
|
+
UNREACHABLE = 5
|
|
58
|
+
NOT_READY = 7
|
|
59
|
+
VERIFICATION_FAILED = 8
|
|
60
|
+
LOCKED = 9
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
#: A Run stopped by Ctrl-C (``KeyboardInterrupt``) exits with the shell's
|
|
64
|
+
#: convention, 128 + SIGINT, which is not a member of :class:`ExitCode`; its
|
|
65
|
+
#: ``run_finished`` and ``command_failed`` records name it
|
|
66
|
+
#: :data:`INTERRUPTED_REASON`.
|
|
67
|
+
INTERRUPTED_EXIT: Final = 130
|
|
68
|
+
#: The ``exit_reason`` an interrupted Run's records carry.
|
|
69
|
+
INTERRUPTED_REASON: Final = "INTERRUPTED"
|
|
70
|
+
|
|
71
|
+
#: Values the family reserves; never a member of :class:`ExitCode`.
|
|
72
|
+
RESERVED: Final[frozenset[int]] = frozenset({6})
|
|
73
|
+
|
|
74
|
+
#: One line per member, used by the CLI's stderr message, ``--help`` and README.
|
|
75
|
+
DESCRIPTIONS: Final[Mapping[ExitCode, str]] = MappingProxyType(
|
|
76
|
+
{
|
|
77
|
+
ExitCode.OK: "the Run succeeded",
|
|
78
|
+
ExitCode.FAILED: "the Run failed for a reason no other code names",
|
|
79
|
+
ExitCode.USAGE: "the command line was invalid",
|
|
80
|
+
ExitCode.REFUSED: "a check found a reason not to act; nothing was done",
|
|
81
|
+
ExitCode.CREDENTIALS: "credentials were missing, unusable or rejected",
|
|
82
|
+
ExitCode.UNREACHABLE: "the database server could not be reached",
|
|
83
|
+
ExitCode.NOT_READY: "a required tool or optional extra is not available",
|
|
84
|
+
ExitCode.VERIFICATION_FAILED: "the archive failed verification",
|
|
85
|
+
ExitCode.LOCKED: "another Run holds the site lock",
|
|
86
|
+
}
|
|
87
|
+
)
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""AD-25 — the restore flags map to ``(RestoreMode, OverrideSet)`` through one table.
|
|
2
|
+
|
|
3
|
+
The restore subcommand's mode-selecting flags, and what each one means:
|
|
4
|
+
|
|
5
|
+
* ``--mode {files,db,all}`` chooses the parts restored. The table is keyed by
|
|
6
|
+
the :class:`~l3io.wp.backup.domain.backup_scope.BackupScope` the CLI's
|
|
7
|
+
``CLI_MODE_TO_SCOPE`` translates it to, so there is no second spelling table.
|
|
8
|
+
* ``--cleanfirst`` authorises **clearing the Site Root**, and nothing else.
|
|
9
|
+
* ``--drop-database`` is accepted and changes nothing (owner decision 63):
|
|
10
|
+
every database restore loads straight into the live database and drops the
|
|
11
|
+
tables carrying the site's prefix first (decisions 49 and 53), so there is
|
|
12
|
+
no previous database to remove. It still selects ``database-clean`` with
|
|
13
|
+
``--mode db``, which now behaves as ``database``; with ``--mode files`` or
|
|
14
|
+
``--mode all`` it selects what the same flags without it select. The CLI
|
|
15
|
+
logs WARNING ``deprecated_option`` when it is given.
|
|
16
|
+
* ``--force`` waives FR-7's non-empty-target refusal for exactly the parts the
|
|
17
|
+
Mode restores. It selects no Mode and authorises no removal: it yields an
|
|
18
|
+
:class:`~l3io.wp.backup.domain.restore.OverrideSet`, a distinct type.
|
|
19
|
+
|
|
20
|
+
:data:`FLAG_MAP` is a **literal** of all 24 combinations, not a comprehension,
|
|
21
|
+
so each row can be reviewed against FR-6. It is total — there is no default
|
|
22
|
+
branch — and ``tests/contracts/test_flag_map.py`` derives the combinations from
|
|
23
|
+
the parser's ``restore mode`` argument group and asserts equality.
|
|
24
|
+
|
|
25
|
+
Behaviour change from v0.2.21 (B-3): ``--force`` no longer means
|
|
26
|
+
``ALLCLEAN``, and ``--cleanfirst`` no longer removes the database. Since
|
|
27
|
+
owner decision 63, ``--mode all --cleanfirst`` alone selects ``all-clean``.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
from collections.abc import Mapping
|
|
33
|
+
from dataclasses import dataclass
|
|
34
|
+
from types import MappingProxyType
|
|
35
|
+
from typing import Final, TypeAlias
|
|
36
|
+
|
|
37
|
+
from l3io.wp.backup.domain.backup_scope import BackupScope
|
|
38
|
+
from l3io.wp.backup.domain.errors import FlagCombinationRejectedError
|
|
39
|
+
from l3io.wp.backup.domain.restore import (
|
|
40
|
+
Override,
|
|
41
|
+
OverrideSet,
|
|
42
|
+
RestoreMode,
|
|
43
|
+
RestorePart,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
"COVERED_FLAGS",
|
|
48
|
+
"FLAG_MAP",
|
|
49
|
+
"FlagCombination",
|
|
50
|
+
"FlagEntry",
|
|
51
|
+
"FlagRejection",
|
|
52
|
+
"authorizing_combination",
|
|
53
|
+
"combination",
|
|
54
|
+
"resolve_flags",
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
#: One reachable flag combination, as ``(dest, value)`` pairs sorted by ``dest``.
|
|
58
|
+
FlagCombination: TypeAlias = tuple[tuple[str, object], ...]
|
|
59
|
+
|
|
60
|
+
#: The ``dest`` names of the restore flags this table maps. Asserted equal to
|
|
61
|
+
#: the ``restore mode`` argument group of the parser itself.
|
|
62
|
+
COVERED_FLAGS: Final[frozenset[str]] = frozenset(
|
|
63
|
+
{"mode", "cleanfirst", "drop_database", "force"}
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True, slots=True)
|
|
68
|
+
class FlagRejection:
|
|
69
|
+
"""A combination that expresses no Restore Mode, and what would."""
|
|
70
|
+
|
|
71
|
+
reason: str
|
|
72
|
+
use_instead: str
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def combination(
|
|
76
|
+
*, mode: BackupScope, cleanfirst: bool, drop_database: bool, force: bool
|
|
77
|
+
) -> FlagCombination:
|
|
78
|
+
"""The table key for one set of restore flag values."""
|
|
79
|
+
return (
|
|
80
|
+
("cleanfirst", cleanfirst),
|
|
81
|
+
("drop_database", drop_database),
|
|
82
|
+
("force", force),
|
|
83
|
+
("mode", mode),
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
_NONE = OverrideSet.none()
|
|
88
|
+
_FILES = OverrideSet(waived=frozenset({Override.NONEMPTY_FILES}))
|
|
89
|
+
_DATABASE = OverrideSet(waived=frozenset({Override.NONEMPTY_DATABASE}))
|
|
90
|
+
_BOTH = OverrideSet(
|
|
91
|
+
waived=frozenset({Override.NONEMPTY_FILES, Override.NONEMPTY_DATABASE})
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
_DATABASE_CLEAN = FlagRejection(
|
|
95
|
+
reason="--cleanfirst clears the Site Root, which --mode db does not restore",
|
|
96
|
+
use_instead=(
|
|
97
|
+
"use --mode all --cleanfirst to clear the Site Root and restore both; "
|
|
98
|
+
"--mode db alone already replaces every table with the site's prefix"
|
|
99
|
+
),
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
_F = BackupScope.FILES
|
|
103
|
+
_D = BackupScope.DATABASE
|
|
104
|
+
_A = BackupScope.ALL
|
|
105
|
+
|
|
106
|
+
#: AD-25's table, decided 2026-10-20. Every row is FR-6's; nothing re-derives it.
|
|
107
|
+
FlagEntry: TypeAlias = tuple[RestoreMode, OverrideSet] | FlagRejection
|
|
108
|
+
FLAG_MAP: Final[Mapping[FlagCombination, FlagEntry]] = MappingProxyType(
|
|
109
|
+
{
|
|
110
|
+
# --mode files
|
|
111
|
+
combination(mode=_F, cleanfirst=False, drop_database=False, force=False): (
|
|
112
|
+
RestoreMode.FILES,
|
|
113
|
+
_NONE,
|
|
114
|
+
),
|
|
115
|
+
combination(mode=_F, cleanfirst=False, drop_database=False, force=True): (
|
|
116
|
+
RestoreMode.FILES,
|
|
117
|
+
_FILES,
|
|
118
|
+
),
|
|
119
|
+
combination(mode=_F, cleanfirst=True, drop_database=False, force=False): (
|
|
120
|
+
RestoreMode.FILES_CLEAN,
|
|
121
|
+
_NONE,
|
|
122
|
+
),
|
|
123
|
+
combination(mode=_F, cleanfirst=True, drop_database=False, force=True): (
|
|
124
|
+
RestoreMode.FILES_CLEAN,
|
|
125
|
+
_FILES,
|
|
126
|
+
),
|
|
127
|
+
# --drop-database changes nothing (owner decision 63).
|
|
128
|
+
combination(mode=_F, cleanfirst=False, drop_database=True, force=False): (
|
|
129
|
+
RestoreMode.FILES,
|
|
130
|
+
_NONE,
|
|
131
|
+
),
|
|
132
|
+
combination(mode=_F, cleanfirst=False, drop_database=True, force=True): (
|
|
133
|
+
RestoreMode.FILES,
|
|
134
|
+
_FILES,
|
|
135
|
+
),
|
|
136
|
+
combination(mode=_F, cleanfirst=True, drop_database=True, force=False): (
|
|
137
|
+
RestoreMode.FILES_CLEAN,
|
|
138
|
+
_NONE,
|
|
139
|
+
),
|
|
140
|
+
combination(mode=_F, cleanfirst=True, drop_database=True, force=True): (
|
|
141
|
+
RestoreMode.FILES_CLEAN,
|
|
142
|
+
_FILES,
|
|
143
|
+
),
|
|
144
|
+
# --mode db
|
|
145
|
+
combination(mode=_D, cleanfirst=False, drop_database=False, force=False): (
|
|
146
|
+
RestoreMode.DATABASE,
|
|
147
|
+
_NONE,
|
|
148
|
+
),
|
|
149
|
+
combination(mode=_D, cleanfirst=False, drop_database=False, force=True): (
|
|
150
|
+
RestoreMode.DATABASE,
|
|
151
|
+
_DATABASE,
|
|
152
|
+
),
|
|
153
|
+
combination(mode=_D, cleanfirst=False, drop_database=True, force=False): (
|
|
154
|
+
RestoreMode.DATABASE_CLEAN,
|
|
155
|
+
_NONE,
|
|
156
|
+
),
|
|
157
|
+
combination(mode=_D, cleanfirst=False, drop_database=True, force=True): (
|
|
158
|
+
RestoreMode.DATABASE_CLEAN,
|
|
159
|
+
_DATABASE,
|
|
160
|
+
),
|
|
161
|
+
combination(
|
|
162
|
+
mode=_D, cleanfirst=True, drop_database=False, force=False
|
|
163
|
+
): _DATABASE_CLEAN,
|
|
164
|
+
combination(
|
|
165
|
+
mode=_D, cleanfirst=True, drop_database=False, force=True
|
|
166
|
+
): _DATABASE_CLEAN,
|
|
167
|
+
combination(
|
|
168
|
+
mode=_D, cleanfirst=True, drop_database=True, force=False
|
|
169
|
+
): _DATABASE_CLEAN,
|
|
170
|
+
combination(
|
|
171
|
+
mode=_D, cleanfirst=True, drop_database=True, force=True
|
|
172
|
+
): _DATABASE_CLEAN,
|
|
173
|
+
# --mode all
|
|
174
|
+
combination(mode=_A, cleanfirst=False, drop_database=False, force=False): (
|
|
175
|
+
RestoreMode.ALL,
|
|
176
|
+
_NONE,
|
|
177
|
+
),
|
|
178
|
+
combination(mode=_A, cleanfirst=False, drop_database=False, force=True): (
|
|
179
|
+
RestoreMode.ALL,
|
|
180
|
+
_BOTH,
|
|
181
|
+
),
|
|
182
|
+
combination(mode=_A, cleanfirst=True, drop_database=True, force=False): (
|
|
183
|
+
RestoreMode.ALL_CLEAN,
|
|
184
|
+
_NONE,
|
|
185
|
+
),
|
|
186
|
+
combination(mode=_A, cleanfirst=True, drop_database=True, force=True): (
|
|
187
|
+
RestoreMode.ALL_CLEAN,
|
|
188
|
+
_BOTH,
|
|
189
|
+
),
|
|
190
|
+
# Owner decision 63: --cleanfirst alone selects all-clean, and
|
|
191
|
+
# --drop-database changes nothing.
|
|
192
|
+
combination(mode=_A, cleanfirst=True, drop_database=False, force=False): (
|
|
193
|
+
RestoreMode.ALL_CLEAN,
|
|
194
|
+
_NONE,
|
|
195
|
+
),
|
|
196
|
+
combination(mode=_A, cleanfirst=True, drop_database=False, force=True): (
|
|
197
|
+
RestoreMode.ALL_CLEAN,
|
|
198
|
+
_BOTH,
|
|
199
|
+
),
|
|
200
|
+
combination(mode=_A, cleanfirst=False, drop_database=True, force=False): (
|
|
201
|
+
RestoreMode.ALL,
|
|
202
|
+
_NONE,
|
|
203
|
+
),
|
|
204
|
+
combination(mode=_A, cleanfirst=False, drop_database=True, force=True): (
|
|
205
|
+
RestoreMode.ALL,
|
|
206
|
+
_BOTH,
|
|
207
|
+
),
|
|
208
|
+
}
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def resolve_flags(flags: FlagCombination) -> tuple[RestoreMode, OverrideSet]:
|
|
213
|
+
"""AD-25's one total function: the Mode and waivers ``flags`` express.
|
|
214
|
+
|
|
215
|
+
Raises :class:`FlagCombinationRejectedError` (``USAGE``) for a
|
|
216
|
+
combination that expresses no Mode, and ``KeyError`` for one outside the
|
|
217
|
+
table — there is no default.
|
|
218
|
+
"""
|
|
219
|
+
entry = FLAG_MAP[flags]
|
|
220
|
+
if isinstance(entry, FlagRejection):
|
|
221
|
+
raise FlagCombinationRejectedError(
|
|
222
|
+
reason=entry.reason, use_instead=entry.use_instead
|
|
223
|
+
)
|
|
224
|
+
return entry
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def authorizing_combination(mode: RestoreMode, part: RestorePart) -> FlagCombination:
|
|
228
|
+
"""The combination that selects ``mode`` and waives ``part``'s refusal.
|
|
229
|
+
|
|
230
|
+
Derived from :data:`FLAG_MAP`, so a refusal message names the waiving flag
|
|
231
|
+
from the table rather than from text typed into the message. Raises
|
|
232
|
+
``ValueError`` when ``mode`` does not restore ``part``, since then there
|
|
233
|
+
is no refusal to waive. A combination without ``--drop-database`` is
|
|
234
|
+
preferred (owner decision 63: the flag changes nothing).
|
|
235
|
+
"""
|
|
236
|
+
ordered = sorted(
|
|
237
|
+
FLAG_MAP.items(), key=lambda item: dict(item[0])["drop_database"] is True
|
|
238
|
+
)
|
|
239
|
+
for flags, entry in ordered:
|
|
240
|
+
if isinstance(entry, FlagRejection):
|
|
241
|
+
continue
|
|
242
|
+
entry_mode, overrides = entry
|
|
243
|
+
if entry_mode is mode and overrides.waives(part):
|
|
244
|
+
return flags
|
|
245
|
+
msg = f"Restore Mode {mode.value!r} does not restore the {part}"
|
|
246
|
+
raise ValueError(msg)
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""AD-18.1 — the Run Journal's values (ADR-0020, ADR-0021).
|
|
2
|
+
|
|
3
|
+
Pure values; the durable write sequence and the file layout are
|
|
4
|
+
``adapters/journal.py``'s (Epic 3, E003-S02-005).
|
|
5
|
+
|
|
6
|
+
:class:`JournalHeader` is what a restore records before its first effect.
|
|
7
|
+
``cutover_targets`` and ``prior_constants`` may hold only
|
|
8
|
+
:data:`JOURNALED_CONSTANTS`, so ``DB_PASSWORD`` cannot be journaled; the
|
|
9
|
+
header's mappings are read-only, so no key is added after that check.
|
|
10
|
+
``phase_undo`` is Epic 7's per-phase undo text and ``saved_config`` the path of
|
|
11
|
+
the config copy ``--overwrite-wp-config`` saves, so a later Run derives the
|
|
12
|
+
undo from the record alone.
|
|
13
|
+
|
|
14
|
+
:class:`JournalEntry` stores no undo: ``step`` names the step a
|
|
15
|
+
``step_completed`` entry completed, and the undo is derived when the journal is
|
|
16
|
+
read (ADR-0020).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from collections.abc import Mapping
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from pathlib import PurePosixPath
|
|
24
|
+
from typing import Final, Literal, NoReturn, TypeVar
|
|
25
|
+
|
|
26
|
+
from l3io.wp.backup.domain.restore import RestoreMode, RestorePhase
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"JOURNALED_CONSTANTS",
|
|
30
|
+
"JournalEntry",
|
|
31
|
+
"JournalEvent",
|
|
32
|
+
"JournalHeader",
|
|
33
|
+
"JournalRecord",
|
|
34
|
+
"ReadOnlyDict",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
#: The only ``wp-config.php`` constants a journal may hold. Never a secret.
|
|
38
|
+
JOURNALED_CONSTANTS: Final = frozenset({"DB_NAME", "DB_HOST", "DB_USER"})
|
|
39
|
+
|
|
40
|
+
#: What a journal entry records.
|
|
41
|
+
JournalEvent = Literal["phase_started", "step_completed", "phase_completed"]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
_K = TypeVar("_K")
|
|
45
|
+
_V = TypeVar("_V")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ReadOnlyDict(dict[_K, _V]):
|
|
49
|
+
"""A ``dict`` no one can change after it is built.
|
|
50
|
+
|
|
51
|
+
Why not ``types.MappingProxyType``: ``dataclasses.asdict`` deep-copies any
|
|
52
|
+
value that is not a ``dict``, and a ``mappingproxy`` cannot be copied, so
|
|
53
|
+
the header would no longer round-trip (the story's testability AC). Python
|
|
54
|
+
3.11 has no ``frozendict``. Being a ``dict``, ``asdict`` rebuilds it with
|
|
55
|
+
``type(obj)(pairs)``; ``__reduce__`` lets ``copy``/``pickle`` do the same.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
__slots__ = ()
|
|
59
|
+
|
|
60
|
+
def _refuse(self, *_: object, **__: object) -> NoReturn:
|
|
61
|
+
msg = "a journal mapping is read-only"
|
|
62
|
+
raise TypeError(msg)
|
|
63
|
+
|
|
64
|
+
__setitem__ = __delitem__ = _refuse
|
|
65
|
+
clear = pop = popitem = setdefault = update = _refuse
|
|
66
|
+
__ior__ = _refuse
|
|
67
|
+
|
|
68
|
+
def __hash__(self) -> int: # type: ignore[override]
|
|
69
|
+
return hash(frozenset(self.items()))
|
|
70
|
+
|
|
71
|
+
def __reduce__(self) -> tuple[type[ReadOnlyDict[_K, _V]], tuple[dict[_K, _V]]]:
|
|
72
|
+
return (type(self), (dict(self),))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _journaled(field: str, keys: Mapping[str, object]) -> None:
|
|
76
|
+
refused = sorted(set(keys) - JOURNALED_CONSTANTS)
|
|
77
|
+
if refused:
|
|
78
|
+
msg = (
|
|
79
|
+
f"{field} may hold only {sorted(JOURNALED_CONSTANTS)}; "
|
|
80
|
+
f"refused keys: {refused}"
|
|
81
|
+
)
|
|
82
|
+
raise ValueError(msg)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(frozen=True, slots=True)
|
|
86
|
+
class JournalHeader:
|
|
87
|
+
"""What a restore records before its first effect (AD-18.1).
|
|
88
|
+
|
|
89
|
+
``live_database_before`` is the Site Descriptor's ``DB_NAME`` and
|
|
90
|
+
``load_database`` the name the plan would load into, in every Mode: the
|
|
91
|
+
live database itself, or a relocation's (owner decision 49).
|
|
92
|
+
``staging_database`` is always ``None`` since the direct load; it stays
|
|
93
|
+
in the record so a journal an earlier version wrote, which named one,
|
|
94
|
+
is read back unchanged. A files-only Mode loads nothing: it records them all
|
|
95
|
+
the same, and the undo text never reads them, since it has no
|
|
96
|
+
``LOAD_DUMP`` phase.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
run_id: str
|
|
100
|
+
started_at: str
|
|
101
|
+
site_root: PurePosixPath
|
|
102
|
+
restore_mode: RestoreMode
|
|
103
|
+
archive: str
|
|
104
|
+
snapshot_path: str | None
|
|
105
|
+
live_database_before: str
|
|
106
|
+
staging_database: str | None
|
|
107
|
+
wp_config_path: str
|
|
108
|
+
load_database: str
|
|
109
|
+
cutover_keys: tuple[str, ...]
|
|
110
|
+
cutover_targets: Mapping[str, str]
|
|
111
|
+
prior_constants: Mapping[str, str | None]
|
|
112
|
+
phase_undo: Mapping[RestorePhase, str]
|
|
113
|
+
saved_config: str | None
|
|
114
|
+
|
|
115
|
+
def __post_init__(self) -> None:
|
|
116
|
+
# Key names only in the message: a refused key's value is never shown.
|
|
117
|
+
_journaled("cutover_targets", self.cutover_targets)
|
|
118
|
+
_journaled("prior_constants", self.prior_constants)
|
|
119
|
+
# Read-only copies: neither the caller's later mutation nor a write
|
|
120
|
+
# through the header can add a key after the check above.
|
|
121
|
+
object.__setattr__(self, "cutover_keys", tuple(self.cutover_keys))
|
|
122
|
+
for name in ("cutover_targets", "prior_constants", "phase_undo"):
|
|
123
|
+
object.__setattr__(self, name, ReadOnlyDict(getattr(self, name)))
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass(frozen=True, slots=True)
|
|
127
|
+
class JournalEntry:
|
|
128
|
+
"""One journal event. No undo is stored; it is derived on read (ADR-0020)."""
|
|
129
|
+
|
|
130
|
+
event: JournalEvent
|
|
131
|
+
phase: RestorePhase
|
|
132
|
+
at: str
|
|
133
|
+
target: str | None
|
|
134
|
+
step: str | None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@dataclass(frozen=True, slots=True)
|
|
138
|
+
class JournalRecord:
|
|
139
|
+
"""A journal as read back: its header and its entries, in order."""
|
|
140
|
+
|
|
141
|
+
header: JournalHeader
|
|
142
|
+
entries: tuple[JournalEntry, ...]
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""AD-19 — the Site Lock's value types: who holds a Site.
|
|
2
|
+
|
|
3
|
+
:class:`LockHolder` is the record a holding Run leaves beside its lock file
|
|
4
|
+
(``adapters/lock.py``) so a refused Run can name it. It carries no credential
|
|
5
|
+
and no Site content — only the Run ID, PID, start time and hostname.
|
|
6
|
+
``started_at`` is rendered UTC ISO-8601 with the ``Z`` designator, the one
|
|
7
|
+
spelling (E002-S01-001).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import Mapping
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from datetime import UTC, datetime
|
|
15
|
+
|
|
16
|
+
__all__ = ["LockHolder", "utc_z"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def utc_z(moment: datetime) -> str:
|
|
20
|
+
"""``moment`` as UTC ISO-8601 with the ``Z`` designator."""
|
|
21
|
+
return moment.astimezone(UTC).isoformat().replace("+00:00", "Z")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True, slots=True)
|
|
25
|
+
class LockHolder:
|
|
26
|
+
"""The Run holding a Site Lock."""
|
|
27
|
+
|
|
28
|
+
run_id: str
|
|
29
|
+
pid: int
|
|
30
|
+
started_at: datetime
|
|
31
|
+
hostname: str
|
|
32
|
+
|
|
33
|
+
def as_record(self, site_root: str) -> dict[str, object]:
|
|
34
|
+
"""The holder record's fields, with the canonical Site Root in clear."""
|
|
35
|
+
return {
|
|
36
|
+
"run_id": self.run_id,
|
|
37
|
+
"pid": self.pid,
|
|
38
|
+
"started_at": utc_z(self.started_at),
|
|
39
|
+
"hostname": self.hostname,
|
|
40
|
+
"site_root": site_root,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
def from_record(cls, record: Mapping[str, object]) -> LockHolder:
|
|
45
|
+
"""The holder a record names; ``TypeError``/``ValueError`` if not one."""
|
|
46
|
+
run_id, pid = record.get("run_id"), record.get("pid")
|
|
47
|
+
started, hostname = record.get("started_at"), record.get("hostname")
|
|
48
|
+
if not (
|
|
49
|
+
isinstance(run_id, str)
|
|
50
|
+
and isinstance(pid, int)
|
|
51
|
+
and not isinstance(pid, bool)
|
|
52
|
+
and isinstance(started, str)
|
|
53
|
+
and isinstance(hostname, str)
|
|
54
|
+
):
|
|
55
|
+
msg = "not a Site Lock holder record"
|
|
56
|
+
raise TypeError(msg)
|
|
57
|
+
moment = datetime.fromisoformat(started.replace("Z", "+00:00"))
|
|
58
|
+
if moment.tzinfo is None:
|
|
59
|
+
msg = "holder record started_at has no timezone"
|
|
60
|
+
raise ValueError(msg)
|
|
61
|
+
return cls(run_id=run_id, pid=pid, started_at=moment, hostname=hostname)
|