scalio-backup 0.1.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.
- scalio_backup/__init__.py +24 -0
- scalio_backup/age.py +237 -0
- scalio_backup/engine.py +495 -0
- scalio_backup/spec.py +60 -0
- scalio_backup-0.1.0.dist-info/METADATA +80 -0
- scalio_backup-0.1.0.dist-info/RECORD +8 -0
- scalio_backup-0.1.0.dist-info/WHEEL +5 -0
- scalio_backup-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Encrypted export and import of a Scalio app instance (backup, restore, self-hosted <-> SaaS move)."""
|
|
2
|
+
from .age import AgeError, WrongPassword
|
|
3
|
+
from .engine import (
|
|
4
|
+
Bundle,
|
|
5
|
+
BundleError,
|
|
6
|
+
IncompatibleBundle,
|
|
7
|
+
apply_import,
|
|
8
|
+
blank_paths,
|
|
9
|
+
check_compatible,
|
|
10
|
+
check_password,
|
|
11
|
+
export_instance,
|
|
12
|
+
open_bundle,
|
|
13
|
+
purge,
|
|
14
|
+
recover,
|
|
15
|
+
rollback,
|
|
16
|
+
status,
|
|
17
|
+
)
|
|
18
|
+
from .spec import CACHE, CLASSES, CONFIG, DATA, EPHEMERAL, INSTANCE, AppSpec, Preserve
|
|
19
|
+
|
|
20
|
+
__version__ = "0.1.0"
|
|
21
|
+
__all__ = ["AgeError", "AppSpec", "Bundle", "BundleError", "CACHE", "CLASSES", "CONFIG", "DATA", "EPHEMERAL",
|
|
22
|
+
"INSTANCE", "IncompatibleBundle", "Preserve", "WrongPassword", "apply_import", "blank_paths",
|
|
23
|
+
"check_compatible", "check_password", "export_instance", "open_bundle", "purge", "recover",
|
|
24
|
+
"rollback", "status"]
|
scalio_backup/age.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""age v1 file format with a passphrase (scrypt recipient), streaming in both directions.
|
|
2
|
+
|
|
3
|
+
Spec: https://age-encryption.org/v1 . Files written here open with the `age` tool (`age -d file`) and files
|
|
4
|
+
from `age -p` open here, so a customer can always reach the data without Scalio. Only the scrypt
|
|
5
|
+
recipient is supported, and it must be the only stanza (as the spec requires).
|
|
6
|
+
"""
|
|
7
|
+
import base64
|
|
8
|
+
import hashlib
|
|
9
|
+
import hmac
|
|
10
|
+
import io
|
|
11
|
+
import os
|
|
12
|
+
|
|
13
|
+
from cryptography.exceptions import InvalidTag
|
|
14
|
+
from cryptography.hazmat.primitives import hashes
|
|
15
|
+
from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
|
|
16
|
+
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
|
17
|
+
from cryptography.hazmat.primitives.kdf.scrypt import Scrypt
|
|
18
|
+
|
|
19
|
+
INTRO = b"age-encryption.org/v1\n"
|
|
20
|
+
SCRYPT_LABEL = b"age-encryption.org/v1/scrypt"
|
|
21
|
+
CHUNK = 64 * 1024
|
|
22
|
+
TAG = 16
|
|
23
|
+
DEFAULT_LOG_N = 18 # same work factor as the age tool on a current machine (256 MiB, about 1 s)
|
|
24
|
+
MAX_LOG_N = 20 # refuse files that would make us spend more memory than that
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AgeError(Exception):
|
|
28
|
+
"""The file is not a valid age file, or it was changed."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class WrongPassword(AgeError):
|
|
32
|
+
"""The passphrase does not open this file."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _b64(data: bytes) -> bytes:
|
|
36
|
+
return base64.b64encode(data).rstrip(b"=")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _unb64(data: bytes) -> bytes:
|
|
40
|
+
if b"=" in data or b"\r" in data:
|
|
41
|
+
raise AgeError("bad base64 in header")
|
|
42
|
+
try:
|
|
43
|
+
out = base64.b64decode(data + b"=" * (-len(data) % 4), validate=True)
|
|
44
|
+
except ValueError as e:
|
|
45
|
+
raise AgeError("bad base64 in header") from e
|
|
46
|
+
if _b64(out) != data:
|
|
47
|
+
raise AgeError("non-canonical base64 in header")
|
|
48
|
+
return out
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _hkdf(ikm: bytes, salt: bytes, info: bytes) -> bytes:
|
|
52
|
+
return HKDF(algorithm=hashes.SHA256(), length=32, salt=salt, info=info).derive(ikm)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _scrypt(password: bytes, salt: bytes, log_n: int) -> bytes:
|
|
56
|
+
return Scrypt(salt=SCRYPT_LABEL + salt, length=32, n=1 << log_n, r=8, p=1).derive(password)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _nonce(counter: int, last: bool) -> bytes:
|
|
60
|
+
if counter >= 1 << 88:
|
|
61
|
+
raise AgeError("file too large")
|
|
62
|
+
return counter.to_bytes(11, "big") + (b"\x01" if last else b"\x00")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _pw(password) -> bytes:
|
|
66
|
+
if isinstance(password, str):
|
|
67
|
+
password = password.encode("utf-8")
|
|
68
|
+
if not password:
|
|
69
|
+
raise ValueError("empty passphrase")
|
|
70
|
+
return password
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class EncryptWriter(io.RawIOBase):
|
|
74
|
+
"""File-like object: write() plaintext, close() finishes the file. Writes the age file to `out`."""
|
|
75
|
+
|
|
76
|
+
def __init__(self, out, password, log_n: int = DEFAULT_LOG_N):
|
|
77
|
+
super().__init__()
|
|
78
|
+
self._out = out
|
|
79
|
+
file_key = os.urandom(16)
|
|
80
|
+
salt = os.urandom(16)
|
|
81
|
+
wrap = ChaCha20Poly1305(_scrypt(_pw(password), salt, log_n)).encrypt(b"\x00" * 12, file_key, None)
|
|
82
|
+
header = INTRO + b"-> scrypt " + _b64(salt) + b" " + str(log_n).encode() + b"\n" + _b64(wrap) + b"\n---"
|
|
83
|
+
mac = hmac.new(_hkdf(file_key, b"", b"header"), header, hashlib.sha256).digest()
|
|
84
|
+
out.write(header + b" " + _b64(mac) + b"\n")
|
|
85
|
+
nonce = os.urandom(16)
|
|
86
|
+
out.write(nonce)
|
|
87
|
+
self._aead = ChaCha20Poly1305(_hkdf(file_key, nonce, b"payload"))
|
|
88
|
+
self._buf = bytearray()
|
|
89
|
+
self._counter = 0
|
|
90
|
+
self._done = False
|
|
91
|
+
|
|
92
|
+
def writable(self):
|
|
93
|
+
return True
|
|
94
|
+
|
|
95
|
+
def write(self, data) -> int:
|
|
96
|
+
if self._done:
|
|
97
|
+
raise ValueError("write to closed age writer")
|
|
98
|
+
self._buf += data
|
|
99
|
+
# keep at least one byte back: the last chunk is only known on close
|
|
100
|
+
while len(self._buf) > CHUNK:
|
|
101
|
+
self._emit(bytes(self._buf[:CHUNK]), last=False)
|
|
102
|
+
del self._buf[:CHUNK]
|
|
103
|
+
return len(data)
|
|
104
|
+
|
|
105
|
+
def _emit(self, chunk: bytes, last: bool):
|
|
106
|
+
self._out.write(self._aead.encrypt(_nonce(self._counter, last), chunk, None))
|
|
107
|
+
self._counter += 1
|
|
108
|
+
|
|
109
|
+
def close(self):
|
|
110
|
+
if not self._done:
|
|
111
|
+
self._emit(bytes(self._buf), last=True)
|
|
112
|
+
self._buf.clear()
|
|
113
|
+
self._done = True
|
|
114
|
+
super().close()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _read_line(src, limit: int = 4096) -> bytes:
|
|
118
|
+
line = bytearray()
|
|
119
|
+
while True:
|
|
120
|
+
c = src.read(1)
|
|
121
|
+
if not c:
|
|
122
|
+
raise AgeError("unexpected end of header")
|
|
123
|
+
line += c
|
|
124
|
+
if c == b"\n":
|
|
125
|
+
return bytes(line)
|
|
126
|
+
if len(line) > limit:
|
|
127
|
+
raise AgeError("header line too long")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class DecryptReader(io.RawIOBase):
|
|
131
|
+
"""File-like object over an age file: read() returns verified plaintext. A changed chunk raises AgeError
|
|
132
|
+
at the read that reaches it; nothing unverified is ever returned."""
|
|
133
|
+
|
|
134
|
+
def __init__(self, src, password, max_log_n: int = MAX_LOG_N):
|
|
135
|
+
super().__init__()
|
|
136
|
+
self._src = src
|
|
137
|
+
if _read_line(src) != INTRO:
|
|
138
|
+
raise AgeError("not an age v1 file")
|
|
139
|
+
header = bytearray(INTRO)
|
|
140
|
+
stanzas = []
|
|
141
|
+
while True:
|
|
142
|
+
line = _read_line(src)
|
|
143
|
+
if line.startswith(b"---"):
|
|
144
|
+
break
|
|
145
|
+
if not line.startswith(b"-> "):
|
|
146
|
+
raise AgeError("bad header")
|
|
147
|
+
header += line
|
|
148
|
+
args = line[3:-1].split(b" ")
|
|
149
|
+
body = bytearray()
|
|
150
|
+
while True:
|
|
151
|
+
bl = _read_line(src)
|
|
152
|
+
header += bl
|
|
153
|
+
chunk = bl[:-1]
|
|
154
|
+
if len(chunk) > 64:
|
|
155
|
+
raise AgeError("bad stanza body")
|
|
156
|
+
body += chunk
|
|
157
|
+
if len(chunk) < 64:
|
|
158
|
+
break
|
|
159
|
+
stanzas.append((args, _unb64(bytes(body))))
|
|
160
|
+
if not line.startswith(b"--- ") or not line.endswith(b"\n"):
|
|
161
|
+
raise AgeError("bad header end")
|
|
162
|
+
header += b"---"
|
|
163
|
+
mac = _unb64(line[4:-1])
|
|
164
|
+
if len(stanzas) != 1 or stanzas[0][0][:1] != [b"scrypt"]:
|
|
165
|
+
raise AgeError("this file is not protected by a passphrase only")
|
|
166
|
+
args, body = stanzas[0]
|
|
167
|
+
if len(args) != 3:
|
|
168
|
+
raise AgeError("bad scrypt stanza")
|
|
169
|
+
salt = _unb64(args[1])
|
|
170
|
+
if len(salt) != 16 or not args[2].isdigit() or args[2].startswith(b"0") or len(body) != 32:
|
|
171
|
+
raise AgeError("bad scrypt stanza")
|
|
172
|
+
log_n = int(args[2])
|
|
173
|
+
if log_n > max_log_n:
|
|
174
|
+
raise AgeError(f"work factor 2^{log_n} is above the allowed 2^{max_log_n}")
|
|
175
|
+
try:
|
|
176
|
+
file_key = ChaCha20Poly1305(_scrypt(_pw(password), salt, log_n)).decrypt(b"\x00" * 12, body, None)
|
|
177
|
+
except InvalidTag as e:
|
|
178
|
+
raise WrongPassword("wrong password") from e
|
|
179
|
+
if not hmac.compare_digest(hmac.new(_hkdf(file_key, b"", b"header"), bytes(header), hashlib.sha256).digest(),
|
|
180
|
+
mac):
|
|
181
|
+
raise AgeError("header was changed")
|
|
182
|
+
nonce = src.read(16)
|
|
183
|
+
if len(nonce) != 16:
|
|
184
|
+
raise AgeError("file is truncated")
|
|
185
|
+
self._aead = ChaCha20Poly1305(_hkdf(file_key, nonce, b"payload"))
|
|
186
|
+
self._counter = 0
|
|
187
|
+
self._plain = b""
|
|
188
|
+
self._pos = 0
|
|
189
|
+
self._eof = False
|
|
190
|
+
self._next = src.read(CHUNK + TAG) # one chunk read ahead to know which is the last
|
|
191
|
+
|
|
192
|
+
def readable(self):
|
|
193
|
+
return True
|
|
194
|
+
|
|
195
|
+
def _decrypt_next(self):
|
|
196
|
+
cur = self._next
|
|
197
|
+
if not cur:
|
|
198
|
+
raise AgeError("file is truncated")
|
|
199
|
+
nxt = self._src.read(CHUNK + TAG) if len(cur) == CHUNK + TAG else b""
|
|
200
|
+
last = not nxt
|
|
201
|
+
try:
|
|
202
|
+
plain = self._aead.decrypt(_nonce(self._counter, last), cur, None)
|
|
203
|
+
except InvalidTag as e:
|
|
204
|
+
raise AgeError("file was changed or is truncated") from e
|
|
205
|
+
if last and not plain and self._counter > 0:
|
|
206
|
+
raise AgeError("empty last chunk")
|
|
207
|
+
self._counter += 1
|
|
208
|
+
self._next = nxt
|
|
209
|
+
self._eof = last
|
|
210
|
+
self._plain, self._pos = plain, 0
|
|
211
|
+
|
|
212
|
+
def readinto(self, b) -> int:
|
|
213
|
+
while self._pos >= len(self._plain):
|
|
214
|
+
if self._eof:
|
|
215
|
+
return 0
|
|
216
|
+
self._decrypt_next()
|
|
217
|
+
n = min(len(b), len(self._plain) - self._pos)
|
|
218
|
+
b[:n] = self._plain[self._pos:self._pos + n]
|
|
219
|
+
self._pos += n
|
|
220
|
+
return n
|
|
221
|
+
|
|
222
|
+
def read_all_and_verify(self):
|
|
223
|
+
"""Read to the end; raises AgeError if anything was changed."""
|
|
224
|
+
while self.read(CHUNK):
|
|
225
|
+
pass
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def encrypt_bytes(data: bytes, password, log_n: int = DEFAULT_LOG_N) -> bytes:
|
|
229
|
+
out = io.BytesIO()
|
|
230
|
+
w = EncryptWriter(out, password, log_n)
|
|
231
|
+
w.write(data)
|
|
232
|
+
w.close()
|
|
233
|
+
return out.getvalue()
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def decrypt_bytes(data: bytes, password, max_log_n: int = MAX_LOG_N) -> bytes:
|
|
237
|
+
return DecryptReader(io.BytesIO(data), password, max_log_n).read()
|
scalio_backup/engine.py
ADDED
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
"""Export a MongoDB database into an encrypted bundle and import one with full overwrite.
|
|
2
|
+
|
|
3
|
+
Bundle = age file (passphrase) around a tar stream:
|
|
4
|
+
manifest.json first member; format, product, versions, source, options, parts with SHA-256
|
|
5
|
+
collections/<name>.jsonl one document per line, MongoDB Extended JSON (canonical, types stay exact)
|
|
6
|
+
meta.jsonl the exported documents of the mixed meta collection
|
|
7
|
+
files/<dir>.index.jsonl {path, size, sha256} per file of an exported directory
|
|
8
|
+
files/<dir>/<path> the files
|
|
9
|
+
|
|
10
|
+
Import = open_bundle() (decrypt, verify every hash, check product and version, nothing changed yet), then
|
|
11
|
+
apply_import() (stage, swap, keep a rollback copy) inside the app's maintenance window. rollback(),
|
|
12
|
+
recover() (call on startup) and purge() handle the rollback copy. Concept: https://claude.ai/artifact/Ej8DFrgi4hcFDQ69dvKWZ7
|
|
13
|
+
"""
|
|
14
|
+
import hashlib
|
|
15
|
+
import io
|
|
16
|
+
import json
|
|
17
|
+
import shutil
|
|
18
|
+
import tarfile
|
|
19
|
+
import tempfile
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from datetime import datetime, timedelta, timezone
|
|
22
|
+
from pathlib import Path, PurePosixPath
|
|
23
|
+
|
|
24
|
+
from bson import json_util
|
|
25
|
+
from packaging.version import InvalidVersion, Version
|
|
26
|
+
|
|
27
|
+
from .age import DEFAULT_LOG_N, MAX_LOG_N, AgeError, DecryptReader, EncryptWriter
|
|
28
|
+
from .spec import JOURNAL, ROLLBACK_PREFIX, STAGING_PREFIX, AppSpec, Preserve
|
|
29
|
+
|
|
30
|
+
FORMAT = "scalio-backup/1"
|
|
31
|
+
MANIFEST = "manifest.json"
|
|
32
|
+
BATCH = 500
|
|
33
|
+
ROLLBACK_DIR = ".preimport"
|
|
34
|
+
_JSON = json_util.CANONICAL_JSON_OPTIONS
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class BundleError(Exception):
|
|
38
|
+
"""The bundle is damaged, changed, or not a Scalio backup."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class IncompatibleBundle(BundleError):
|
|
42
|
+
"""The bundle is valid but cannot be imported here (other product, newer version)."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _now() -> datetime:
|
|
46
|
+
return datetime.now(timezone.utc)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _iso(dt: datetime) -> str:
|
|
50
|
+
return dt.isoformat()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# ----------------------------------------------------------------------------------------------- helpers ---
|
|
54
|
+
def _blank(value):
|
|
55
|
+
if isinstance(value, dict):
|
|
56
|
+
return {}
|
|
57
|
+
if isinstance(value, list):
|
|
58
|
+
return []
|
|
59
|
+
return ""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def blank_paths(doc: dict, paths) -> list[str]:
|
|
63
|
+
"""Blank the dotted paths in doc (in place). Returns the paths that held a value."""
|
|
64
|
+
hit = []
|
|
65
|
+
for path in paths:
|
|
66
|
+
node, parts = doc, path.split(".")
|
|
67
|
+
for p in parts[:-1]:
|
|
68
|
+
node = node.get(p) if isinstance(node, dict) else None
|
|
69
|
+
if node is None:
|
|
70
|
+
break
|
|
71
|
+
if isinstance(node, dict) and node.get(parts[-1]):
|
|
72
|
+
node[parts[-1]] = _blank(node[parts[-1]])
|
|
73
|
+
hit.append(path)
|
|
74
|
+
return hit
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class _HashWriter:
|
|
78
|
+
def __init__(self, path: Path):
|
|
79
|
+
self.f = open(path, "wb")
|
|
80
|
+
self.h = hashlib.sha256()
|
|
81
|
+
self.size = 0
|
|
82
|
+
|
|
83
|
+
def write(self, data: bytes):
|
|
84
|
+
self.f.write(data)
|
|
85
|
+
self.h.update(data)
|
|
86
|
+
self.size += len(data)
|
|
87
|
+
|
|
88
|
+
def close(self):
|
|
89
|
+
self.f.close()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _sha256_file(path: Path) -> str:
|
|
93
|
+
h = hashlib.sha256()
|
|
94
|
+
with open(path, "rb") as f:
|
|
95
|
+
for block in iter(lambda: f.read(1 << 20), b""):
|
|
96
|
+
h.update(block)
|
|
97
|
+
return h.hexdigest()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _safe_member(name: str) -> PurePosixPath:
|
|
101
|
+
p = PurePosixPath(name)
|
|
102
|
+
if p.is_absolute() or ".." in p.parts or not p.parts or "\\" in name or "\x00" in name:
|
|
103
|
+
raise BundleError(f"unsafe path in bundle: {name!r}")
|
|
104
|
+
return p
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
async def _existing(db) -> set[str]:
|
|
108
|
+
return set(await db.list_collection_names())
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# ------------------------------------------------------------------------------------------------ export ---
|
|
112
|
+
async def export_instance(db, spec: AppSpec, out, password, *, include_data: bool = True,
|
|
113
|
+
include_secrets: bool = True, created_by: str = "", source: dict | None = None,
|
|
114
|
+
log_n: int = DEFAULT_LOG_N, work_dir: str | None = None) -> dict:
|
|
115
|
+
"""Write the encrypted bundle to the binary file object `out`. Returns the manifest."""
|
|
116
|
+
with tempfile.TemporaryDirectory(prefix="scalio-backup-", dir=work_dir) as tmp:
|
|
117
|
+
tmp = Path(tmp)
|
|
118
|
+
(tmp / "collections").mkdir()
|
|
119
|
+
(tmp / "files").mkdir()
|
|
120
|
+
parts, blanked = [], []
|
|
121
|
+
|
|
122
|
+
async def dump(name: str, cursor, rel: str, kind: str):
|
|
123
|
+
w = _HashWriter(tmp / rel)
|
|
124
|
+
count = 0
|
|
125
|
+
async for doc in cursor:
|
|
126
|
+
if spec.export_hook:
|
|
127
|
+
doc = await spec.export_hook(name, doc)
|
|
128
|
+
if not include_secrets and name in spec.secret_fields:
|
|
129
|
+
for path in blank_paths(doc, spec.secret_fields[name]):
|
|
130
|
+
if f"{name}:{path}" not in blanked:
|
|
131
|
+
blanked.append(f"{name}:{path}")
|
|
132
|
+
w.write(json_util.dumps(doc, json_options=_JSON).encode() + b"\n")
|
|
133
|
+
count += 1
|
|
134
|
+
w.close()
|
|
135
|
+
parts.append({"kind": kind, "name": name, "path": rel, "count": count, "bytes": w.size,
|
|
136
|
+
"sha256": w.h.hexdigest()})
|
|
137
|
+
|
|
138
|
+
for coll in spec.exported_collections(include_data):
|
|
139
|
+
await dump(coll, db[coll].find({}), f"collections/{coll}.jsonl", "collection")
|
|
140
|
+
if spec.meta_collection:
|
|
141
|
+
ids = spec.exported_meta_ids(include_data)
|
|
142
|
+
await dump(spec.meta_collection, db[spec.meta_collection].find({spec.meta_key: {"$in": ids}}),
|
|
143
|
+
"meta.jsonl", "meta")
|
|
144
|
+
|
|
145
|
+
files = [] # (archive path, source path)
|
|
146
|
+
if include_data:
|
|
147
|
+
for name, directory in sorted(spec.file_dirs.items()):
|
|
148
|
+
rows, base = [], Path(directory)
|
|
149
|
+
if base.is_dir():
|
|
150
|
+
for src in sorted(base.rglob("*")):
|
|
151
|
+
rel = src.relative_to(base)
|
|
152
|
+
if rel.parts[0] == ROLLBACK_DIR or not src.is_file() or src.is_symlink():
|
|
153
|
+
continue
|
|
154
|
+
arc = f"files/{name}/{rel.as_posix()}"
|
|
155
|
+
_safe_member(arc)
|
|
156
|
+
rows.append({"path": rel.as_posix(), "size": src.stat().st_size,
|
|
157
|
+
"sha256": _sha256_file(src)})
|
|
158
|
+
files.append((arc, src))
|
|
159
|
+
idx = tmp / "files" / f"{name}.index.jsonl"
|
|
160
|
+
idx.write_bytes(b"".join(json.dumps(r, sort_keys=True).encode() + b"\n" for r in rows))
|
|
161
|
+
parts.append({"kind": "files", "name": name, "path": f"files/{name}.index.jsonl",
|
|
162
|
+
"count": len(rows), "bytes": sum(r["size"] for r in rows),
|
|
163
|
+
"sha256": _sha256_file(idx)})
|
|
164
|
+
|
|
165
|
+
manifest = {
|
|
166
|
+
"format": FORMAT, "product": spec.product, "app_version": spec.app_version,
|
|
167
|
+
"schema_version": await spec.schema_version(db) if spec.schema_version else None,
|
|
168
|
+
"created_at": _iso(_now()), "created_by": created_by, "source": source or {},
|
|
169
|
+
"options": {"include_data": include_data, "include_secrets": include_secrets},
|
|
170
|
+
"blanked_secrets": blanked, "parts": parts,
|
|
171
|
+
}
|
|
172
|
+
writer = EncryptWriter(out, password, log_n)
|
|
173
|
+
with tarfile.open(fileobj=writer, mode="w|", format=tarfile.PAX_FORMAT) as tar:
|
|
174
|
+
def add_bytes(name: str, data: bytes):
|
|
175
|
+
info = tarfile.TarInfo(name)
|
|
176
|
+
info.size = len(data)
|
|
177
|
+
info.mtime = 0
|
|
178
|
+
tar.addfile(info, io.BytesIO(data))
|
|
179
|
+
add_bytes(MANIFEST, json.dumps(manifest, indent=1, sort_keys=True).encode())
|
|
180
|
+
for p in parts:
|
|
181
|
+
tar.add(tmp / p["path"], arcname=p["path"], recursive=False)
|
|
182
|
+
for arc, src in files:
|
|
183
|
+
tar.add(src, arcname=arc, recursive=False)
|
|
184
|
+
writer.close()
|
|
185
|
+
return manifest
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# ------------------------------------------------------------------------------------------------ import ---
|
|
189
|
+
def _version(v: str):
|
|
190
|
+
try:
|
|
191
|
+
return Version(v)
|
|
192
|
+
except (InvalidVersion, TypeError):
|
|
193
|
+
return None
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@dataclass
|
|
197
|
+
class Bundle:
|
|
198
|
+
"""A decrypted, verified bundle in a work directory. Nothing in the database has changed yet."""
|
|
199
|
+
manifest: dict
|
|
200
|
+
path: Path
|
|
201
|
+
warnings: list[str] = field(default_factory=list)
|
|
202
|
+
|
|
203
|
+
@property
|
|
204
|
+
def include_data(self) -> bool:
|
|
205
|
+
return bool(self.manifest["options"].get("include_data"))
|
|
206
|
+
|
|
207
|
+
def counts(self) -> dict[str, int]:
|
|
208
|
+
return {f'{p["kind"]}:{p["name"]}': p["count"] for p in self.manifest["parts"]}
|
|
209
|
+
|
|
210
|
+
def preview(self) -> dict:
|
|
211
|
+
m = self.manifest
|
|
212
|
+
return {"product": m["product"], "app_version": m["app_version"], "schema_version": m["schema_version"],
|
|
213
|
+
"created_at": m["created_at"], "created_by": m["created_by"], "source": m["source"],
|
|
214
|
+
"options": m["options"], "blanked_secrets": m["blanked_secrets"], "counts": self.counts(),
|
|
215
|
+
"warnings": list(self.warnings)}
|
|
216
|
+
|
|
217
|
+
def docs(self, rel: str):
|
|
218
|
+
with open(self.path / rel, "rb") as f:
|
|
219
|
+
for line in f:
|
|
220
|
+
if line.strip():
|
|
221
|
+
yield json_util.loads(line, json_options=_JSON)
|
|
222
|
+
|
|
223
|
+
def cleanup(self):
|
|
224
|
+
shutil.rmtree(self.path, ignore_errors=True)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def check_compatible(manifest: dict, spec: AppSpec, target_schema: int | None) -> list[str]:
|
|
228
|
+
"""Raise IncompatibleBundle when the bundle cannot go into this app; return warnings otherwise."""
|
|
229
|
+
if manifest.get("format") != FORMAT:
|
|
230
|
+
raise IncompatibleBundle(f"unknown bundle format {manifest.get('format')!r}")
|
|
231
|
+
if manifest.get("product") != spec.product:
|
|
232
|
+
raise IncompatibleBundle(f"bundle is for {manifest.get('product')!r}, this is {spec.product!r}")
|
|
233
|
+
src, dst = _version(manifest.get("app_version")), _version(spec.app_version)
|
|
234
|
+
if src and dst and src > dst:
|
|
235
|
+
raise IncompatibleBundle(f"bundle comes from version {src}, this instance runs {dst}; update first")
|
|
236
|
+
sv = manifest.get("schema_version")
|
|
237
|
+
if sv is not None and target_schema is not None and sv > target_schema:
|
|
238
|
+
raise IncompatibleBundle(f"bundle has schema {sv}, this instance has {target_schema}; update first")
|
|
239
|
+
warnings = []
|
|
240
|
+
if not (src and dst):
|
|
241
|
+
warnings.append("version could not be compared")
|
|
242
|
+
if manifest.get("blanked_secrets"):
|
|
243
|
+
warnings.append("bundle was exported without secrets; enter them again after the import")
|
|
244
|
+
return warnings
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def open_bundle(src, password, spec: AppSpec, *, target_schema: int | None = None, work_dir: str | None = None,
|
|
248
|
+
max_log_n: int = MAX_LOG_N) -> Bundle:
|
|
249
|
+
"""Decrypt the bundle from the binary file object `src` into a new work directory and verify it."""
|
|
250
|
+
path = Path(tempfile.mkdtemp(prefix="scalio-import-", dir=work_dir))
|
|
251
|
+
try:
|
|
252
|
+
reader = DecryptReader(src, password, max_log_n) # WrongPassword / AgeError
|
|
253
|
+
seen, manifest = {}, None
|
|
254
|
+
try:
|
|
255
|
+
with tarfile.open(fileobj=reader, mode="r|") as tar:
|
|
256
|
+
for member in tar:
|
|
257
|
+
name = _safe_member(member.name)
|
|
258
|
+
if manifest is None:
|
|
259
|
+
if member.name != MANIFEST or not member.isfile():
|
|
260
|
+
raise BundleError("bundle does not start with a manifest")
|
|
261
|
+
manifest = json.loads(tar.extractfile(member).read())
|
|
262
|
+
if not isinstance(manifest, dict) or not isinstance(manifest.get("parts"), list):
|
|
263
|
+
raise BundleError("bad manifest")
|
|
264
|
+
continue
|
|
265
|
+
allowed = name.parts[0] in ("collections", "files") or member.name == "meta.jsonl"
|
|
266
|
+
if not member.isfile() or not allowed:
|
|
267
|
+
raise BundleError(f"unexpected entry in bundle: {member.name!r}")
|
|
268
|
+
if member.name in seen:
|
|
269
|
+
raise BundleError(f"duplicate entry in bundle: {member.name!r}")
|
|
270
|
+
dest = path.joinpath(*name.parts)
|
|
271
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
272
|
+
h = hashlib.sha256()
|
|
273
|
+
with tar.extractfile(member) as f, open(dest, "wb") as out:
|
|
274
|
+
for block in iter(lambda: f.read(1 << 20), b""):
|
|
275
|
+
h.update(block)
|
|
276
|
+
out.write(block)
|
|
277
|
+
seen[member.name] = h.hexdigest()
|
|
278
|
+
reader.read_all_and_verify() # the tar end is not the file end; verify the last chunk too
|
|
279
|
+
except tarfile.TarError as e:
|
|
280
|
+
raise BundleError(f"bundle content is not readable: {e}") from e
|
|
281
|
+
except AgeError as e:
|
|
282
|
+
raise BundleError(str(e)) from e
|
|
283
|
+
if manifest is None:
|
|
284
|
+
raise BundleError("bundle is empty")
|
|
285
|
+
expected = {}
|
|
286
|
+
for p in manifest["parts"]:
|
|
287
|
+
expected[p["path"]] = p["sha256"]
|
|
288
|
+
if p["kind"] == "files":
|
|
289
|
+
with open(path / p["path"], "rb") if p["path"] in seen else io.BytesIO() as f:
|
|
290
|
+
for line in f:
|
|
291
|
+
row = json.loads(line)
|
|
292
|
+
expected[f'files/{p["name"]}/{row["path"]}'] = row["sha256"]
|
|
293
|
+
if set(seen) != set(expected):
|
|
294
|
+
missing, extra = sorted(set(expected) - set(seen)), sorted(set(seen) - set(expected))
|
|
295
|
+
raise BundleError(f"bundle content does not match its manifest (missing {missing[:5]}, extra {extra[:5]})")
|
|
296
|
+
bad = [k for k, v in expected.items() if seen[k] != v]
|
|
297
|
+
if bad:
|
|
298
|
+
raise BundleError(f"checksum mismatch: {bad[:5]}")
|
|
299
|
+
warnings = check_compatible(manifest, spec, target_schema)
|
|
300
|
+
return Bundle(manifest=manifest, path=path, warnings=warnings)
|
|
301
|
+
except BaseException:
|
|
302
|
+
shutil.rmtree(path, ignore_errors=True)
|
|
303
|
+
raise
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
async def _journal(db, **fields):
|
|
307
|
+
await db[JOURNAL].update_one({"_id": "current"}, {"$set": fields}, upsert=True)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
async def _load(db, spec: AppSpec, name: str, target: str, docs) -> int:
|
|
311
|
+
await db[target].drop()
|
|
312
|
+
batch, n = [], 0
|
|
313
|
+
for doc in docs:
|
|
314
|
+
if spec.import_hook:
|
|
315
|
+
doc = await spec.import_hook(name, doc)
|
|
316
|
+
batch.append(doc)
|
|
317
|
+
if len(batch) >= BATCH:
|
|
318
|
+
await db[target].insert_many(batch)
|
|
319
|
+
n += len(batch)
|
|
320
|
+
batch = []
|
|
321
|
+
if batch:
|
|
322
|
+
await db[target].insert_many(batch)
|
|
323
|
+
n += len(batch)
|
|
324
|
+
return n
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
async def apply_import(db, spec: AppSpec, bundle: Bundle, *, preserve: tuple[Preserve, ...] = (),
|
|
328
|
+
imported_by: str = "") -> dict:
|
|
329
|
+
"""Replace every collection, meta document and file directory the bundle covers. Run it while the app
|
|
330
|
+
accepts no writes. Keeps the previous state as a rollback copy until purge()."""
|
|
331
|
+
include_data = bundle.include_data
|
|
332
|
+
colls = spec.exported_collections(include_data)
|
|
333
|
+
meta_ids = spec.exported_meta_ids(include_data) if spec.meta_collection else []
|
|
334
|
+
dirs = sorted(spec.file_dirs) if include_data else []
|
|
335
|
+
parts = {(p["kind"], p["name"]): p for p in bundle.manifest["parts"]}
|
|
336
|
+
current = await db[JOURNAL].find_one({"_id": "current"}) or {}
|
|
337
|
+
if current.get("state") in ("staging", "swapping"):
|
|
338
|
+
raise RuntimeError("an import is already running or was interrupted; call recover() first")
|
|
339
|
+
await _journal(db, state="staging", started_at=_iso(_now()), finished_at=None, imported_by=imported_by,
|
|
340
|
+
bundle_created_at=bundle.manifest["created_at"], source=bundle.manifest["source"],
|
|
341
|
+
collections=colls, meta_ids=meta_ids, dirs=dirs, had=[], swapped=[], meta_swapped=False,
|
|
342
|
+
dirs_swapped=[])
|
|
343
|
+
# a new import replaces the rollback copy of the previous one
|
|
344
|
+
for name in await _existing(db):
|
|
345
|
+
if name.startswith(ROLLBACK_PREFIX) or name.startswith(STAGING_PREFIX):
|
|
346
|
+
await db[name].drop()
|
|
347
|
+
|
|
348
|
+
counts = {}
|
|
349
|
+
for coll in colls:
|
|
350
|
+
part = parts.get(("collection", coll))
|
|
351
|
+
docs = bundle.docs(part["path"]) if part else iter(())
|
|
352
|
+
counts[coll] = await _load(db, spec, coll, STAGING_PREFIX + coll, docs)
|
|
353
|
+
for p in preserve:
|
|
354
|
+
if p.collection not in colls:
|
|
355
|
+
continue
|
|
356
|
+
staged = {d.get(p.key) async for d in db[STAGING_PREFIX + p.collection].find({}, {p.key: 1})}
|
|
357
|
+
async for doc in db[p.collection].find(p.query):
|
|
358
|
+
if doc.get(p.key) not in staged:
|
|
359
|
+
doc.pop("_id", None)
|
|
360
|
+
await db[STAGING_PREFIX + p.collection].insert_one(doc)
|
|
361
|
+
counts[p.collection] += 1
|
|
362
|
+
|
|
363
|
+
await _journal(db, state="swapping")
|
|
364
|
+
existing = await _existing(db)
|
|
365
|
+
had = [c for c in colls if c in existing]
|
|
366
|
+
await _journal(db, had=had)
|
|
367
|
+
for coll in colls:
|
|
368
|
+
await db[JOURNAL].update_one({"_id": "current"}, {"$push": {"swapped": coll}})
|
|
369
|
+
if coll in existing:
|
|
370
|
+
await db[coll].rename(ROLLBACK_PREFIX + coll, dropTarget=True)
|
|
371
|
+
else:
|
|
372
|
+
await db[ROLLBACK_PREFIX + coll].drop()
|
|
373
|
+
if counts[coll]:
|
|
374
|
+
await db[STAGING_PREFIX + coll].rename(coll)
|
|
375
|
+
else:
|
|
376
|
+
await db[STAGING_PREFIX + coll].drop()
|
|
377
|
+
if meta_ids:
|
|
378
|
+
m = spec.meta_collection
|
|
379
|
+
old = [d async for d in db[m].find({spec.meta_key: {"$in": meta_ids}})]
|
|
380
|
+
if old:
|
|
381
|
+
await db[ROLLBACK_PREFIX + m].insert_many(old)
|
|
382
|
+
await _journal(db, meta_swapped=True)
|
|
383
|
+
await db[m].delete_many({spec.meta_key: {"$in": meta_ids}})
|
|
384
|
+
part = parts.get(("meta", m))
|
|
385
|
+
new = []
|
|
386
|
+
for doc in bundle.docs(part["path"]) if part else ():
|
|
387
|
+
if doc.get(spec.meta_key) in meta_ids:
|
|
388
|
+
new.append(await spec.import_hook(m, doc) if spec.import_hook else doc)
|
|
389
|
+
if new:
|
|
390
|
+
await db[m].insert_many(new)
|
|
391
|
+
counts[m] = len(new)
|
|
392
|
+
for name in dirs:
|
|
393
|
+
target = Path(spec.file_dirs[name])
|
|
394
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
395
|
+
keep = target / ROLLBACK_DIR
|
|
396
|
+
shutil.rmtree(keep, ignore_errors=True)
|
|
397
|
+
await db[JOURNAL].update_one({"_id": "current"}, {"$push": {"dirs_swapped": name}})
|
|
398
|
+
keep.mkdir()
|
|
399
|
+
for entry in list(target.iterdir()):
|
|
400
|
+
if entry.name != ROLLBACK_DIR:
|
|
401
|
+
shutil.move(str(entry), str(keep / entry.name))
|
|
402
|
+
src = bundle.path / "files" / name
|
|
403
|
+
if src.is_dir():
|
|
404
|
+
for entry in list(src.iterdir()):
|
|
405
|
+
shutil.move(str(entry), str(target / entry.name))
|
|
406
|
+
counts[f"files:{name}"] = parts.get(("files", name), {}).get("count", 0)
|
|
407
|
+
await _journal(db, state="done", finished_at=_iso(_now()))
|
|
408
|
+
return {"counts": counts}
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
async def rollback(db, spec: AppSpec) -> bool:
|
|
412
|
+
"""Put back the state from before the last import. Returns False when there is nothing to roll back."""
|
|
413
|
+
j = await db[JOURNAL].find_one({"_id": "current"})
|
|
414
|
+
if not j or j.get("state") not in ("swapping", "done"):
|
|
415
|
+
return False
|
|
416
|
+
existing = await _existing(db)
|
|
417
|
+
had = set(j.get("had") or [])
|
|
418
|
+
for coll in j.get("swapped") or []:
|
|
419
|
+
if ROLLBACK_PREFIX + coll in existing:
|
|
420
|
+
await db[ROLLBACK_PREFIX + coll].rename(coll, dropTarget=True)
|
|
421
|
+
elif coll not in had:
|
|
422
|
+
await db[coll].drop()
|
|
423
|
+
for coll in j.get("collections") or []:
|
|
424
|
+
await db[STAGING_PREFIX + coll].drop()
|
|
425
|
+
ids = j.get("meta_ids") or []
|
|
426
|
+
if ids and spec.meta_collection and j.get("meta_swapped"):
|
|
427
|
+
m = spec.meta_collection
|
|
428
|
+
await db[m].delete_many({spec.meta_key: {"$in": ids}})
|
|
429
|
+
old = [d async for d in db[ROLLBACK_PREFIX + m].find({})]
|
|
430
|
+
if old:
|
|
431
|
+
await db[m].insert_many(old)
|
|
432
|
+
await db[ROLLBACK_PREFIX + m].drop()
|
|
433
|
+
for name in j.get("dirs_swapped") or []:
|
|
434
|
+
target = Path(spec.file_dirs[name])
|
|
435
|
+
keep = target / ROLLBACK_DIR
|
|
436
|
+
if not keep.is_dir():
|
|
437
|
+
continue
|
|
438
|
+
for entry in list(target.iterdir()):
|
|
439
|
+
if entry.name != ROLLBACK_DIR:
|
|
440
|
+
shutil.rmtree(entry) if entry.is_dir() and not entry.is_symlink() else entry.unlink()
|
|
441
|
+
for entry in list(keep.iterdir()):
|
|
442
|
+
shutil.move(str(entry), str(target / entry.name))
|
|
443
|
+
keep.rmdir()
|
|
444
|
+
await _journal(db, state="rolled_back", rolled_back_at=_iso(_now()))
|
|
445
|
+
return True
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
async def recover(db, spec: AppSpec) -> str | None:
|
|
449
|
+
"""Call on startup. Cleans up an import that stopped half way. Returns what it did, or None."""
|
|
450
|
+
j = await db[JOURNAL].find_one({"_id": "current"})
|
|
451
|
+
if not j:
|
|
452
|
+
return None
|
|
453
|
+
if j.get("state") == "staging":
|
|
454
|
+
for coll in j.get("collections") or []:
|
|
455
|
+
await db[STAGING_PREFIX + coll].drop()
|
|
456
|
+
await _journal(db, state="aborted")
|
|
457
|
+
return "aborted"
|
|
458
|
+
if j.get("state") == "swapping":
|
|
459
|
+
await rollback(db, spec)
|
|
460
|
+
return "rolled_back"
|
|
461
|
+
return None
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
async def status(db) -> dict | None:
|
|
465
|
+
"""The last import, for the UI (rollback possible while state is 'done')."""
|
|
466
|
+
j = await db[JOURNAL].find_one({"_id": "current"}, {"_id": 0})
|
|
467
|
+
return j
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
async def purge(db, spec: AppSpec, older_than: timedelta = timedelta(days=7), now: datetime | None = None) -> bool:
|
|
471
|
+
"""Drop the rollback copy once it is older than `older_than` (E8: 7 days)."""
|
|
472
|
+
j = await db[JOURNAL].find_one({"_id": "current"})
|
|
473
|
+
if not j or j.get("state") != "done" or not j.get("finished_at"):
|
|
474
|
+
return False
|
|
475
|
+
if datetime.fromisoformat(j["finished_at"]) > (now or _now()) - older_than:
|
|
476
|
+
return False
|
|
477
|
+
for coll in (j.get("collections") or []) + ([spec.meta_collection] if spec.meta_collection else []):
|
|
478
|
+
await db[ROLLBACK_PREFIX + coll].drop()
|
|
479
|
+
for name in j.get("dirs") or []:
|
|
480
|
+
shutil.rmtree(Path(spec.file_dirs[name]) / ROLLBACK_DIR, ignore_errors=True)
|
|
481
|
+
await _journal(db, state="purged", purged_at=_iso(now or _now()))
|
|
482
|
+
return True
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def check_password(password: str) -> str | None:
|
|
486
|
+
"""Minimum rule for a bundle password. Returns an error key or None."""
|
|
487
|
+
if not password or len(password) < 12:
|
|
488
|
+
return "too_short"
|
|
489
|
+
if len(set(password)) < 5:
|
|
490
|
+
return "too_simple"
|
|
491
|
+
return None
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
__all__ = ["Bundle", "BundleError", "IncompatibleBundle", "apply_import", "check_compatible", "check_password",
|
|
495
|
+
"export_instance", "open_bundle", "purge", "recover", "rollback", "status", "blank_paths"]
|
scalio_backup/spec.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""What an app exports: one class per collection and per meta document, plus the app's hooks."""
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from typing import Any, Awaitable, Callable, Optional
|
|
4
|
+
|
|
5
|
+
CONFIG = "config" # always exported
|
|
6
|
+
DATA = "data" # exported unless "configuration only"
|
|
7
|
+
CACHE = "cache" # never exported, rebuilt by the app after an import
|
|
8
|
+
EPHEMERAL = "ephemeral" # never exported (sessions, one-time links, running jobs, logs)
|
|
9
|
+
INSTANCE = "instance" # never exported, bound to this installation (license, keys, consent)
|
|
10
|
+
CLASSES = (CONFIG, DATA, CACHE, EPHEMERAL, INSTANCE)
|
|
11
|
+
|
|
12
|
+
# Collections the engine itself creates; apps must not use these prefixes.
|
|
13
|
+
STAGING_PREFIX = "_import_"
|
|
14
|
+
ROLLBACK_PREFIX = "_preimport_"
|
|
15
|
+
JOURNAL = "_backup_journal"
|
|
16
|
+
|
|
17
|
+
DocHook = Callable[[str, dict], Awaitable[dict]]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class Preserve:
|
|
22
|
+
"""Keep target documents the bundle does not contain, e.g. the account of the person importing (E7).
|
|
23
|
+
Documents in `collection` matching `query` are kept when no imported document has the same `key`."""
|
|
24
|
+
collection: str
|
|
25
|
+
query: dict
|
|
26
|
+
key: str = "id"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class AppSpec:
|
|
31
|
+
product: str # product code, e.g. "scalio-nexus"
|
|
32
|
+
app_version: str # running version, e.g. "1.2.5"
|
|
33
|
+
collections: dict[str, str] # name -> class
|
|
34
|
+
meta_collection: Optional[str] = "meta" # mixed collection classified per document
|
|
35
|
+
meta_key: str = "id"
|
|
36
|
+
meta_ids: dict[str, str] = field(default_factory=dict)
|
|
37
|
+
secret_fields: dict[str, tuple[str, ...]] = field(default_factory=dict) # dotted paths, blanked without secrets
|
|
38
|
+
file_dirs: dict[str, str] = field(default_factory=dict) # archive name -> absolute directory
|
|
39
|
+
schema_version: Optional[Callable[[Any], Awaitable[int]]] = None # async (db) -> int
|
|
40
|
+
export_hook: Optional[DocHook] = None # (collection, doc) -> doc, e.g. decrypt with the instance key
|
|
41
|
+
import_hook: Optional[DocHook] = None # (collection, doc) -> doc, e.g. encrypt with the target's key
|
|
42
|
+
|
|
43
|
+
def __post_init__(self):
|
|
44
|
+
bad = {k: v for k, v in {**self.collections, **self.meta_ids}.items() if v not in CLASSES}
|
|
45
|
+
if bad:
|
|
46
|
+
raise ValueError(f"unknown backup classes: {bad}")
|
|
47
|
+
for name in self.collections:
|
|
48
|
+
if name.startswith((STAGING_PREFIX, ROLLBACK_PREFIX)) or name == JOURNAL:
|
|
49
|
+
raise ValueError(f"collection name {name!r} uses a reserved prefix")
|
|
50
|
+
|
|
51
|
+
def classes_for(self, include_data: bool) -> tuple[str, ...]:
|
|
52
|
+
return (CONFIG, DATA) if include_data else (CONFIG,)
|
|
53
|
+
|
|
54
|
+
def exported_collections(self, include_data: bool = True) -> list[str]:
|
|
55
|
+
wanted = self.classes_for(include_data)
|
|
56
|
+
return sorted(c for c, k in self.collections.items() if k in wanted and c != self.meta_collection)
|
|
57
|
+
|
|
58
|
+
def exported_meta_ids(self, include_data: bool = True) -> list[str]:
|
|
59
|
+
wanted = self.classes_for(include_data)
|
|
60
|
+
return sorted(i for i, k in self.meta_ids.items() if k in wanted)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: scalio-backup
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Encrypted export and import (backup, restore, move) shared by the Scalio apps
|
|
5
|
+
License: Proprietary
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: cryptography>=42
|
|
9
|
+
Requires-Dist: pymongo>=4.6
|
|
10
|
+
Requires-Dist: packaging>=23
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
13
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
14
|
+
Requires-Dist: mongomock-motor>=0.0.29; extra == "dev"
|
|
15
|
+
Requires-Dist: typing-extensions>=4.8; extra == "dev"
|
|
16
|
+
Requires-Dist: pyrage>=1.1; extra == "dev"
|
|
17
|
+
Requires-Dist: motor>=3.3; extra == "dev"
|
|
18
|
+
Requires-Dist: ruff>=0.6; extra == "dev"
|
|
19
|
+
|
|
20
|
+
# scalio-backup
|
|
21
|
+
|
|
22
|
+
Encrypted export and import of a whole Scalio app instance, shared by the Scalio apps (Nexus first, then
|
|
23
|
+
Foundry, Rosetta, Citadel). Used for backup/restore and for moving an instance between self-hosted and SaaS.
|
|
24
|
+
Concept and decisions E1-E12: https://claude.ai/artifact/Ej8DFrgi4hcFDQ69dvKWZ7
|
|
25
|
+
|
|
26
|
+
Nothing in this package is secret. The password is the customer's; without it nobody (Scalio included) can
|
|
27
|
+
open a bundle.
|
|
28
|
+
|
|
29
|
+
## Bundle format (`.scaliobak`)
|
|
30
|
+
|
|
31
|
+
- Outside: standard [age v1](https://age-encryption.org/v1) with a passphrase (scrypt, work factor 2^18),
|
|
32
|
+
ChaCha20-Poly1305 in 64 KiB chunks. `age -d backup.scaliobak > backup.tar` opens it without Scalio.
|
|
33
|
+
- Inside: a tar stream. `manifest.json` first (format `scalio-backup/1`, product, app and schema version,
|
|
34
|
+
source, options, SHA-256 per part), then `collections/<name>.jsonl` (MongoDB Extended JSON, canonical, so
|
|
35
|
+
dates, Int64 and ObjectIds stay exact), `meta.jsonl`, and for file folders `files/<dir>.index.jsonl` plus the
|
|
36
|
+
files. The import checks every hash; a changed or incomplete bundle is refused before anything is written.
|
|
37
|
+
|
|
38
|
+
## Classes
|
|
39
|
+
|
|
40
|
+
Every collection (and every document of a mixed `meta` collection) has one class: `config` (always exported),
|
|
41
|
+
`data` (exported unless "configuration only"), `cache`, `ephemeral`, `instance` (never exported). Each app keeps
|
|
42
|
+
its classes in its own `backup_spec.py` and a test that fails on an unclassified collection.
|
|
43
|
+
|
|
44
|
+
## Use in an app
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from scalio_backup import AppSpec, Preserve, apply_import, export_instance, open_bundle, recover, rollback, purge
|
|
48
|
+
|
|
49
|
+
SPEC = AppSpec(product="scalio-nexus", app_version=VERSION, collections=..., meta_ids=...,
|
|
50
|
+
secret_fields={"settings": ("entra.clientSecret", ...)}, file_dirs={"hr": HR_UPLOAD_DIR},
|
|
51
|
+
schema_version=current_schema, export_hook=decrypt_instance_secrets, import_hook=encrypt_again)
|
|
52
|
+
|
|
53
|
+
# export (background job): writes the encrypted bundle to a file object
|
|
54
|
+
manifest = await export_instance(db, SPEC, out, password, include_data=True, include_secrets=True,
|
|
55
|
+
created_by=user_email, source={"mode": "selfhosted", "host": host, "tenant": tid})
|
|
56
|
+
|
|
57
|
+
# import, step 1: decrypt and verify into a work folder, nothing changes yet (WrongPassword, BundleError,
|
|
58
|
+
# IncompatibleBundle when the product differs or the bundle is newer than this instance)
|
|
59
|
+
bundle = open_bundle(upload, password, SPEC, target_schema=await current_schema(db))
|
|
60
|
+
show(bundle.preview()) # source, counts, warnings; the app adds its own (other tenant, license limits)
|
|
61
|
+
|
|
62
|
+
# import, step 2: inside the app's maintenance window (no writes, background jobs paused)
|
|
63
|
+
await apply_import(db, SPEC, bundle, preserve=(Preserve("app_users", {"email": me}),), imported_by=me)
|
|
64
|
+
bundle.cleanup()
|
|
65
|
+
# then: migrations, end all sessions, re-sync caches, show the checklist
|
|
66
|
+
|
|
67
|
+
await rollback(db, SPEC) # back to the state before the import (until purge)
|
|
68
|
+
await recover(db, SPEC) # on startup: cleans up an import that stopped half way
|
|
69
|
+
await purge(db, SPEC) # daily: drops the rollback copy after 7 days
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
How the swap works: documents go into `_import_<name>`, then each collection is renamed to
|
|
73
|
+
`_preimport_<name>` and the staging copy into its place. A journal (`_backup_journal`) records each step, so an
|
|
74
|
+
interrupted import is rolled back on the next start. File folders keep their old content in `.preimport/`.
|
|
75
|
+
|
|
76
|
+
## Development
|
|
77
|
+
|
|
78
|
+
`pip install -e ".[dev]"`, then `pytest -q` and `ruff check .`. Engine tests use mongomock; with `MONGO_URL` set
|
|
79
|
+
they run against a real MongoDB (CI does both). `tests/vector.scaliobak` is the shared test bundle
|
|
80
|
+
(`tests/make_vector.py`); the age layer is checked against rage (`pyrage`) in both directions.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
scalio_backup/__init__.py,sha256=2k8zfeAiI86k4Z6zFPv0XJJz73v_zMskS1zm3hZb-5w,849
|
|
2
|
+
scalio_backup/age.py,sha256=h5NS5gz8pvLX58jjKudLHgnhH8BN3-sk9eEWnVmegWg,8577
|
|
3
|
+
scalio_backup/engine.py,sha256=348YyhjPlobfBDxVhOBzG7xeDU0zGVPL5AuqWo_eBO0,22120
|
|
4
|
+
scalio_backup/spec.py,sha256=skw49bZQ4JoHh4ZqnK1OWoSG4es0RAjk7nCI2Y_3_0c,3087
|
|
5
|
+
scalio_backup-0.1.0.dist-info/METADATA,sha256=zhikMP46PB92gHnVnO92G24TdaRa6pNRKipP5FgesvM,4413
|
|
6
|
+
scalio_backup-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
7
|
+
scalio_backup-0.1.0.dist-info/top_level.txt,sha256=0oiEeRyQVTNh277vtUHnFzD7LNqlhBweyzfll7kexqg,14
|
|
8
|
+
scalio_backup-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
scalio_backup
|