File size: 13,709 Bytes
2f1e7ff
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
"""Logical PostgreSQL snapshots with an explicit content-table allowlist."""
import hashlib
import json
import os
import subprocess
from contextlib import contextmanager
from pathlib import Path

import psycopg2
from psycopg2 import sql
from psycopg2.extras import Json

from .git import BackupError

TABLES = frozenset({
    "data_text_chunks", "data_observations", "data_structured_data", "data_kg_changes",
    "kg_nodes", "kg_relationships", "kg_hub_bridges", "kg_topic_sessions", "embedding_profiles",
    "vectors_nodes", "vectors_triplets", "vectors_observations", "vectors_data", "vectors_relationships",
})
STORES = {"nodes", "triplets", "observations", "data", "relationships"}


def canonical(value):
    return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()


class PostgreSQL:
    def __init__(self, settings, profile):
        self.settings = settings
        self.profile = profile
        self.system = settings.get("system_database") or settings["database"]

    @classmethod
    def configured(cls):
        from src.config import config
        from src.lib.embeddings.profile import index_profile
        if config.data_db != "postgresql" or config.vector_db != "postgresql" or config.graph_db != "networkx":
            raise BackupError("BACKUP_REQUIRES_POSTGRESQL_NETWORKX")
        from src.constants.embeddings import EMBEDDING_STORES_SIZES
        if set(EMBEDDING_STORES_SIZES.values()) != {512}:
            raise BackupError("BACKUP_REQUIRES_UNIFORM_512_DIMENSIONS")
        p = config.postgresql
        return cls(dict(host=p.host, port=p.port, user=p.username, password=p.password,
                        database=p.database, system_database=getattr(p, "system_database", None)), index_profile(config, 512))

    @contextmanager
    def connect(self, database=None, autocommit=False):
        args = {k: v for k, v in self.settings.items() if k not in {"database", "system_database"}}
        conn = psycopg2.connect(**args, dbname=database or self.system)
        try:
            conn.autocommit = autocommit
            yield conn
        finally:
            conn.close()

    def tool(self, program, database, *args, stdout=None):
        env = os.environ.copy()
        env.update(PGHOST=str(self.settings["host"]), PGPORT=str(self.settings["port"]),
                   PGUSER=self.settings["user"], PGPASSWORD=self.settings["password"], PGDATABASE=database)
        try:
            result = subprocess.run([program, *args], env=env, stdout=stdout or subprocess.PIPE,
                                    stderr=subprocess.PIPE, timeout=120)
        except (OSError, subprocess.TimeoutExpired):
            raise BackupError("BACKUP_POSTGRES_TOOL_UNAVAILABLE") from None
        if result.returncode:
            raise BackupError("BACKUP_POSTGRES_TOOL_FAILED")
        return result.stdout

    def major_version(self):
        with self.connect() as conn:
            return conn.server_version // 10000

    def inspect_secret_stream(self, stream):
        # Known deployment credentials and minted Brain PATs must never enter
        # registry metadata or content archives, even if pasted into documents.
        import re
        candidates = [os.getenv(key, "").encode() for key in (
            "SYSTEM_PAT", "BRAINPAT_TOKEN", "GITHUB_PAT", "HF_TOKEN",
            "POSTGRES_PASSWORD", "REDIS_PASSWORD")]
        candidates = [v for v in candidates if len(v) >= 8]
        tail = b""
        overlap = max([len(v) for v in candidates] + [256])
        for chunk in iter(lambda: stream.read(1024 * 1024), b""):
            data = tail + chunk
            if any(secret in data for secret in candidates) or re.search(rb"\bbpat_[A-Za-z0-9_-]{16,}", data):
                raise BackupError("BACKUP_SECRET_IN_CONTENT")
            tail = data[-overlap:]

    def inspect_secrets(self, archive, database):
        # Spool logical SQL rather than retain large plaintext in memory.
        import tempfile
        with tempfile.TemporaryFile() as stream:
            self.tool("pg_restore", database, "--file=-", str(archive), stdout=stream)
            stream.seek(0)
            self.inspect_secret_stream(stream)

    def registry(self):
        from src.constants.data import Brain, Workspace
        from src.services.brain_identity import validate_brain_id
        from src.lib.postgresql._naming import brain_db_name
        with self.connect() as conn, conn.cursor() as cur:
            cur.execute("SELECT id,name_key,document,created_at::text FROM data_brains ORDER BY name_key")
            brains = []
            for identity, name, document, created in cur.fetchall():
                validate_brain_id(name)
                # Explicit whitelist, never export legacy PAT/document credentials.
                if document.get("id") != identity or document.get("name_key") != name:
                    raise BackupError("BACKUP_REGISTRY_IDENTITY_MISMATCH")
                brains.append({"id": identity, "name_key": name, "created_at": created,
                               "document": Brain(id=identity, name_key=name).model_dump(mode="json")})
            physical = [brain_db_name(b["name_key"]) for b in brains]
            if len(set(physical)) != len(physical):
                raise BackupError("BACKUP_DATABASE_NAME_COLLISION")
            cur.execute("SELECT id,brain_id,slug,document,created_at::text FROM data_workspaces ORDER BY id")
            workspaces = []
            names = {b["name_key"] for b in brains}
            for identity, name, slug, document, created in cur.fetchall():
                if name not in names:
                    continue
                obj = Workspace.model_validate(document)
                if (obj.id, obj.brain_id, obj.slug) != (identity, name, slug):
                    raise BackupError("BACKUP_WORKSPACE_IDENTITY_MISMATCH")
                workspaces.append({"id": identity, "brain_id": name, "slug": slug,
                                   "document": obj.model_dump(mode="json"), "created_at": created})
            return {"brains": brains, "workspaces": workspaces}

    def tables(self, cur):
        cur.execute("SELECT tablename FROM pg_tables WHERE schemaname='public' ORDER BY tablename")
        tables = [r[0] for r in cur.fetchall()]
        # Unknown tables could contain credentials OR unmodelled content. Refuse
        # instead of silently losing it. Never discover unregistered databases.
        if set(tables) - TABLES:
            raise BackupError("BACKUP_UNKNOWN_BRAIN_TABLE")
        return tables

    def validate_profile(self, cur, tables):
        profiles = {}
        if "embedding_profiles" in tables:
            cur.execute("SELECT store,profile FROM embedding_profiles ORDER BY store")
            profiles = dict(cur.fetchall())
            if set(profiles) - STORES or any(p != self.profile for p in profiles.values()):
                raise BackupError("BACKUP_EMBEDDING_PROFILE_MISMATCH")
        for table in tables:
            if table.startswith("vectors_"):
                store = table.removeprefix("vectors_")
                cur.execute("SELECT atttypmod FROM pg_attribute WHERE attrelid=%s::regclass AND attname='embeddings'", (table,))
                row = cur.fetchone()
                if not row or row[0] != self.profile["stored_dimension"] or store not in profiles:
                    raise BackupError("BACKUP_EMBEDDING_PROFILE_MISMATCH")
        return profiles

    def snapshot(self, output):
        from src.lib.postgresql._naming import brain_db_name
        output = Path(output)
        (output / "system").mkdir(parents=True)
        (output / "brains").mkdir()
        registry = self.registry()
        import io
        self.inspect_secret_stream(io.BytesIO(canonical(registry)))
        (output / "system/registry.json").write_bytes(canonical(registry))
        fingerprints = {}
        for brain in registry["brains"]:
            name = brain["name_key"]
            database = brain_db_name(name)
            with self.connect(database) as conn, conn.cursor() as cur:
                cur.execute("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY")
                cur.execute("SELECT pg_export_snapshot()")
                snapshot = cur.fetchone()[0]
                tables = self.tables(cur)
                self.validate_profile(cur, tables)
                digest = hashlib.sha256()
                for table in tables:
                    digest.update(table.encode())
                    # Canonical row order makes no-op checks independent of VACUUM,
                    # heap order and pg_dump's archive timestamp.
                    with conn.cursor(name=f"backup_{table}") as rows:
                        rows.execute(sql.SQL("SELECT to_jsonb(t)::text FROM {} t ORDER BY to_jsonb(t)::text COLLATE \"C\"").format(sql.Identifier(table)))
                        for (row,) in rows:
                            digest.update(row.encode() + b"\n")
                table_args = [item for table in tables for item in ("--table", f"public.{table}")]
                if tables:
                    archive = output / "brains" / f"{name}.dump"
                    with archive.open("wb") as stream:
                        self.tool("pg_dump", database, "--format=custom", "--no-owner", "--no-privileges",
                                  f"--snapshot={snapshot}", *table_args, stdout=stream)
                    self.tool("pg_restore", database, "--list", str(archive))
                    self.inspect_secrets(archive, database)
                    # Include schema/index changes in semantic identity, without
                    # archive timestamps or pg_dump's random restriction nonce.
                    schema = self.tool("pg_dump", database, "--schema-only", "--no-owner", "--no-privileges",
                                       f"--snapshot={snapshot}", *table_args)
                    stable = b"\n".join(line for line in schema.splitlines()
                                          if not line.startswith((b"--", b"\\restrict", b"\\unrestrict")))
                    digest.update(stable)
                fingerprints[name] = digest.hexdigest()
        if self.registry() != registry:
            raise BackupError("BACKUP_REGISTRY_CHANGED_DURING_SNAPSHOT")
        return registry, fingerprints

    def has_local_content(self):
        return bool(self.registry()["brains"])

    def restore(self, source, registry):
        from src.lib.postgresql._naming import brain_db_name
        if self.has_local_content():
            raise BackupError("BACKUP_RESTORE_REQUIRES_EMPTY_REGISTRY")
        # Preflight ALL destination collisions before writing any DB. Orphans
        # are never resurrected or overwritten, even if their names match.
        names = [brain_db_name(b["name_key"]) for b in registry["brains"]]
        if len(set(names)) != len(names):
            raise BackupError("BACKUP_DATABASE_NAME_COLLISION")
        created = []
        try:
            with self.connect("postgres", autocommit=True) as admin, admin.cursor() as cur:
                cur.execute("SELECT datname FROM pg_database WHERE datname=ANY(%s)", (names,))
                if cur.fetchall():
                    raise BackupError("BACKUP_RESTORE_DATABASE_COLLISION")
                for brain, database in zip(registry["brains"], names):
                    cur.execute(sql.SQL("CREATE DATABASE {} TEMPLATE template1").format(sql.Identifier(database)))
                    created.append(database)
                    # Selective dumps do not include pgvector extension DDL.
                    with self.connect(database, autocommit=True) as conn, conn.cursor() as target:
                        target.execute("CREATE EXTENSION IF NOT EXISTS vector")
                    archive = Path(source) / "brains" / f"{brain['name_key']}.dump"
                    if archive.exists():
                        self.tool("pg_restore", database, "--exit-on-error", "--single-transaction",
                                  "--no-owner", "--no-privileges", "--dbname", database, str(archive))
                    with self.connect(database) as conn, conn.cursor() as target:
                        self.validate_profile(target, self.tables(target))
            # Publish canonical registry LAST in one transaction. API is still
            # stopped; any restore failure leaves no partially registered brain.
            with self.connect() as conn, conn.cursor() as cur:
                cur.execute("LOCK TABLE data_brains,data_workspaces IN ACCESS EXCLUSIVE MODE")
                cur.execute("SELECT count(*) FROM data_brains")
                if cur.fetchone()[0]:
                    raise BackupError("BACKUP_RESTORE_REQUIRES_EMPTY_REGISTRY")
                cur.execute("DELETE FROM data_workspaces")
                for b in registry["brains"]:
                    cur.execute("INSERT INTO data_brains(id,name_key,pat,document,created_at) VALUES(%s,%s,'',%s,%s)",
                                (b["id"], b["name_key"], Json(b["document"]), b["created_at"]))
                for w in registry["workspaces"]:
                    cur.execute("INSERT INTO data_workspaces(id,brain_id,slug,document,created_at) VALUES(%s,%s,%s,%s,%s)",
                                (w["id"], w["brain_id"], w["slug"], Json(w["document"]), w["created_at"]))
                conn.commit()
        except BaseException:
            with self.connect("postgres", autocommit=True) as admin, admin.cursor() as cur:
                for database in reversed(created):
                    cur.execute(sql.SQL("DROP DATABASE {} WITH (FORCE)").format(sql.Identifier(database)))
            raise