File size: 9,986 Bytes
7494965
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
# backup.py - SAOS Cognitive Backup & Git-Archiver Daemon
import os
import sys
import time
import gzip
import shutil
import logging
import subprocess
from datetime import datetime

# Configure clean terminal logging
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s | %(levelname)-8s | %(name)s - %(message)s",
    handlers=[logging.StreamHandler(sys.stdout)],
)
logger = logging.getLogger("saos.backup")

# ── Cloud Credentials & Environment variables ──────────────────────────────────
R2_ACCESS_KEY = os.getenv("R2_ACCESS_KEY", "")
R2_SECRET_KEY = os.getenv("R2_SECRET_KEY", "")
R2_ACCOUNT_ID = os.getenv("R2_ACCOUNT_ID", "")
R2_BUCKET = os.getenv("R2_BUCKET", "saos-backups")

GITHUB_PAT = os.getenv("GITHUB_PAT", "")
GITHUB_USER = os.getenv("GITHUB_USER", "")
ACTIVE_REPO_NAME = os.getenv("GITHUB_REPO", "saos-backups-part-1")

REDIS_DATA_DIR = "/data"
REDIS_DUMP_PATH = os.path.join(REDIS_DATA_DIR, "dump.rdb")
COMPRESSED_DUMP_PATH = os.path.join(REDIS_DATA_DIR, "dump.rdb.gz")

# ── Cloudflare R2 Sync Helper ─────────────────────────────────────────────────
def compress_dump() -> bool:
    """Compresses dump.rdb to gzip with maximum compression (level 9)."""
    if not os.path.exists(REDIS_DUMP_PATH):
        logger.info("No active Redis dump.rdb file found to compress.")
        return False

    try:
        logger.info(f"Compressing {REDIS_DUMP_PATH} to {COMPRESSED_DUMP_PATH}...")
        with open(REDIS_DUMP_PATH, 'rb') as f_in:
            with gzip.open(COMPRESSED_DUMP_PATH, 'wb', compresslevel=9) as f_out:
                shutil.copyfileobj(f_in, f_out)
        logger.info("Successfully compressed Redis database dump!")
        return True
    except Exception as e:
        logger.error(f"Failed to compress Redis database dump: {e}")
        return False

# ── Cloudflare R2 Sync Helper ─────────────────────────────────────────────────
def upload_to_r2() -> bool:
    """Uploads pre-compressed dump.rdb.gz to Cloudflare R2 S3 bucket."""
    if not (R2_ACCESS_KEY and R2_SECRET_KEY and R2_ACCOUNT_ID):
        logger.warning("Cloudflare R2 credentials missing. Skipping R2 swap upload.")
        return False

    if not os.path.exists(COMPRESSED_DUMP_PATH):
        logger.info("No compressed dump.rdb.gz found to upload to R2.")
        return False

    try:
        # Upload to S3-compatible R2 endpoint
        import boto3
        from botocore.client import Config

        r2_endpoint = f"https://{R2_ACCOUNT_ID}.r2.cloudflarestorage.com"
        s3 = boto3.client(
            "s3",
            endpoint_url=r2_endpoint,
            aws_access_key_id=R2_ACCESS_KEY,
            aws_secret_access_key=R2_SECRET_KEY,
            config=Config(signature_version="s3v4")
        )

        logger.info(f"Uploading compressed cache to R2 bucket '{R2_BUCKET}'...")
        s3.upload_file(COMPRESSED_DUMP_PATH, R2_BUCKET, "vault/redis/dump.rdb.gz")
        logger.info("Successfully updated Cloudflare R2 state swap partition!")
        return True
    except Exception as e:
        logger.error(f"Failed to sync with R2 swap partition: {e}")
        return False

# ── GitHub Git-Archiver & Rollover Engine ──────────────────────────────────────
def run_git_command(args: list, cwd: str = REDIS_DATA_DIR) -> str:
    """Executes a subprocess git command and returns the stdout result."""
    result = subprocess.run(
        args,
        cwd=cwd,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        text=True,
        check=True
    )
    return result.stdout.strip()

def check_github_repo_size(repo_name: str) -> int:
    """Queries GitHub REST API to get repository size in Kilobytes."""
    if not (GITHUB_PAT and GITHUB_USER):
        return 0
    try:
        import urllib.request
        import json
        url = f"https://api.github.com/repos/{GITHUB_USER}/{repo_name}"
        req = urllib.request.Request(url)
        req.add_header("Authorization", f"token {GITHUB_PAT}")
        req.add_header("Accept", "application/vnd.github.v3+json")
        req.add_header("User-Agent", "SAOS-Backup-Daemon")

        with urllib.request.urlopen(req, timeout=8) as response:
            data = json.loads(response.read().decode())
            size_kb = data.get("size", 0)
            logger.info(f"Active GitHub backup repo size: {size_kb} KB ({size_kb / 1024:.2f} MB)")
            return size_kb
    except Exception as e:
        logger.error(f"Failed to query repository size: {e}")
        return 0

def create_new_github_repo(repo_name: str) -> bool:
    """Programmatically creates a new private GitHub repository via REST API."""
    logger.info(f"Creating fresh private GitHub repository: '{repo_name}'...")
    try:
        import urllib.request
        import json
        url = "https://api.github.com/user/repos"
        payload = json.dumps({
            "name": repo_name,
            "private": True,
            "description": "SAOS Database Swap & Transcripts cold-archive partition.",
            "auto_init": False
        }).encode("utf-8")

        req = urllib.request.Request(url, data=payload)
        req.add_header("Authorization", f"token {GITHUB_PAT}")
        req.add_header("Content-Type", "application/json")
        req.add_header("Accept", "application/vnd.github.v3+json")
        req.add_header("User-Agent", "SAOS-Backup-Daemon")

        with urllib.request.urlopen(req, timeout=10) as response:
            if response.status == 201:
                logger.info(f"Successfully generated new private repository '{repo_name}'!")
                return True
        return False
    except Exception as e:
        logger.critical(f"GitHub repository generation failed: {e}")
        return False

def sync_to_github():
    """Commits and pushes the latest database snapshot to the active private repository."""
    global ACTIVE_REPO_NAME

    if not (GITHUB_PAT and GITHUB_USER):
        logger.warning("GitHub credentials missing. Skipping Git-Archiver backup.")
        return

    # Check if active repo has filled up past 800MB (819,200 KB)
    current_size_kb = check_github_repo_size(ACTIVE_REPO_NAME)
    if current_size_kb >= 800000:
        logger.warning(f"Active repository '{ACTIVE_REPO_NAME}' is nearing 800MB limit. Running rollover...")
        # Increment index
        try:
            parts = ACTIVE_REPO_NAME.split("-part-")
            base = parts[0]
            num = int(parts[1]) if len(parts) > 1 else 1
            new_repo_name = f"{base}-part-{num + 1}"
        except Exception:
            new_repo_name = f"{ACTIVE_REPO_NAME}-part-2"

        # Create new repo and swap remote
        if create_new_github_repo(new_repo_name):
            ACTIVE_REPO_NAME = new_repo_name
            # Re-initialize git origin
            try:
                run_git_command(["git", "remote", "remove", "origin"])
            except Exception:
                pass
            
            origin_url = f"https://{GITHUB_USER}:{GITHUB_PAT}@github.com/{GITHUB_USER}/{new_repo_name}.git"
            run_git_command(["git", "remote", "add", "origin", origin_url])
            logger.info(f"Swarm backup targets successfully rolled over to new remote repository: {new_repo_name}")

    if not os.path.exists(COMPRESSED_DUMP_PATH):
        return

    try:
        # Initialize Git repo inside data directory if not already created
        if not os.path.exists(os.path.join(REDIS_DATA_DIR, ".git")):
            logger.info("Initializing Git repository inside /data directory...")
            run_git_command(["git", "init"])
            run_git_command(["git", "config", "user.name", "saos-backup-daemon"])
            run_git_command(["git", "config", "user.email", "saos-daemon@olympus.internal"])
            
            # Add secure remote
            origin_url = f"https://{GITHUB_USER}:{GITHUB_PAT}@github.com/{GITHUB_USER}/{ACTIVE_REPO_NAME}.git"
            run_git_command(["git", "remote", "add", "origin", origin_url])

        # Commit and force-push the snapshot
        logger.info("Archiving snapshot to private GitHub repository...")
        shutil.copy2(COMPRESSED_DUMP_PATH, os.path.join(REDIS_DATA_DIR, "dump.rdb.gz"))
        
        # Write small history commit
        run_git_command(["git", "add", "dump.rdb.gz"])
        timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
        run_git_command(["git", "commit", "-m", f"SAOS state sync snapshot: {timestamp} [skip ci]"])
        
        # Push to main
        run_git_command(["git", "branch", "-M", "main"])
        run_git_command(["git", "push", "-f", "origin", "main"])
        logger.info("GitHub Git-Archiver state commit successful!")
    except Exception as e:
        logger.error(f"Git-Archiver sync failed: {e}")

# ── Daemon Entrypoint Loop ────────────────────────────────────────────────────
if __name__ == "__main__":
    logger.info("SAOS Cognitive Backup Sync Daemon is starting...")
    # Sleep 30 seconds on initial boot to let Redis populate dumps
    time.sleep(30)
    
    while True:
        logger.info("Triggering scheduled cognitive backup sequence...")
        
        # 1. Compress active database dump
        compressed_ok = compress_dump()
        
        if compressed_ok:
            # 2. Upload compressed dump to Cloudflare R2 independently
            upload_to_r2()
            
            # 3. Push compressed dump to Private GitHub Archiver independently
            sync_to_github()
            
        # Run every 5 minutes (300 seconds)
        time.sleep(300)