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.
@@ -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()
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ scalio_backup