scalio-backup 0.1.0__tar.gz

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,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,61 @@
1
+ # scalio-backup
2
+
3
+ Encrypted export and import of a whole Scalio app instance, shared by the Scalio apps (Nexus first, then
4
+ Foundry, Rosetta, Citadel). Used for backup/restore and for moving an instance between self-hosted and SaaS.
5
+ Concept and decisions E1-E12: https://claude.ai/artifact/Ej8DFrgi4hcFDQ69dvKWZ7
6
+
7
+ Nothing in this package is secret. The password is the customer's; without it nobody (Scalio included) can
8
+ open a bundle.
9
+
10
+ ## Bundle format (`.scaliobak`)
11
+
12
+ - Outside: standard [age v1](https://age-encryption.org/v1) with a passphrase (scrypt, work factor 2^18),
13
+ ChaCha20-Poly1305 in 64 KiB chunks. `age -d backup.scaliobak > backup.tar` opens it without Scalio.
14
+ - Inside: a tar stream. `manifest.json` first (format `scalio-backup/1`, product, app and schema version,
15
+ source, options, SHA-256 per part), then `collections/<name>.jsonl` (MongoDB Extended JSON, canonical, so
16
+ dates, Int64 and ObjectIds stay exact), `meta.jsonl`, and for file folders `files/<dir>.index.jsonl` plus the
17
+ files. The import checks every hash; a changed or incomplete bundle is refused before anything is written.
18
+
19
+ ## Classes
20
+
21
+ Every collection (and every document of a mixed `meta` collection) has one class: `config` (always exported),
22
+ `data` (exported unless "configuration only"), `cache`, `ephemeral`, `instance` (never exported). Each app keeps
23
+ its classes in its own `backup_spec.py` and a test that fails on an unclassified collection.
24
+
25
+ ## Use in an app
26
+
27
+ ```python
28
+ from scalio_backup import AppSpec, Preserve, apply_import, export_instance, open_bundle, recover, rollback, purge
29
+
30
+ SPEC = AppSpec(product="scalio-nexus", app_version=VERSION, collections=..., meta_ids=...,
31
+ secret_fields={"settings": ("entra.clientSecret", ...)}, file_dirs={"hr": HR_UPLOAD_DIR},
32
+ schema_version=current_schema, export_hook=decrypt_instance_secrets, import_hook=encrypt_again)
33
+
34
+ # export (background job): writes the encrypted bundle to a file object
35
+ manifest = await export_instance(db, SPEC, out, password, include_data=True, include_secrets=True,
36
+ created_by=user_email, source={"mode": "selfhosted", "host": host, "tenant": tid})
37
+
38
+ # import, step 1: decrypt and verify into a work folder, nothing changes yet (WrongPassword, BundleError,
39
+ # IncompatibleBundle when the product differs or the bundle is newer than this instance)
40
+ bundle = open_bundle(upload, password, SPEC, target_schema=await current_schema(db))
41
+ show(bundle.preview()) # source, counts, warnings; the app adds its own (other tenant, license limits)
42
+
43
+ # import, step 2: inside the app's maintenance window (no writes, background jobs paused)
44
+ await apply_import(db, SPEC, bundle, preserve=(Preserve("app_users", {"email": me}),), imported_by=me)
45
+ bundle.cleanup()
46
+ # then: migrations, end all sessions, re-sync caches, show the checklist
47
+
48
+ await rollback(db, SPEC) # back to the state before the import (until purge)
49
+ await recover(db, SPEC) # on startup: cleans up an import that stopped half way
50
+ await purge(db, SPEC) # daily: drops the rollback copy after 7 days
51
+ ```
52
+
53
+ How the swap works: documents go into `_import_<name>`, then each collection is renamed to
54
+ `_preimport_<name>` and the staging copy into its place. A journal (`_backup_journal`) records each step, so an
55
+ interrupted import is rolled back on the next start. File folders keep their old content in `.preimport/`.
56
+
57
+ ## Development
58
+
59
+ `pip install -e ".[dev]"`, then `pytest -q` and `ruff check .`. Engine tests use mongomock; with `MONGO_URL` set
60
+ they run against a real MongoDB (CI does both). `tests/vector.scaliobak` is the shared test bundle
61
+ (`tests/make_vector.py`); the age layer is checked against rage (`pyrage`) in both directions.
@@ -0,0 +1,29 @@
1
+ [build-system]
2
+ requires = ["setuptools>=69"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "scalio-backup"
7
+ version = "0.1.0"
8
+ description = "Encrypted export and import (backup, restore, move) shared by the Scalio apps"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = { text = "Proprietary" }
12
+ dependencies = ["cryptography>=42", "pymongo>=4.6", "packaging>=23"]
13
+
14
+ [project.optional-dependencies]
15
+ dev = ["pytest>=8", "pytest-asyncio>=0.23", "mongomock-motor>=0.0.29", "typing-extensions>=4.8", "pyrage>=1.1", "motor>=3.3", "ruff>=0.6"]
16
+
17
+ [tool.setuptools.packages.find]
18
+ where = ["src"]
19
+
20
+ [tool.pytest.ini_options]
21
+ asyncio_mode = "auto"
22
+ testpaths = ["tests"]
23
+
24
+ [tool.ruff]
25
+ line-length = 120
26
+ target-version = "py311"
27
+
28
+ [tool.ruff.lint]
29
+ select = ["E", "F", "W", "I", "B"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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"]
@@ -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()