"""Deploy the complete tested IKAR API build, preserving other containers.""" from pathlib import Path import hashlib import ipaddress import json import os import re import shutil import sqlite3 import subprocess import sys import tarfile import tempfile import time import urllib.request root = Path('/mnt/data/ikar_server') release_name = sys.argv[2] if len(sys.argv) > 2 else '20261007-komet' previous_name = sys.argv[3] if len(sys.argv) > 3 else '20261007-video' archive_name = sys.argv[4] if len(sys.argv) > 4 else 'server-fixes.tar.gz' if not all(re.fullmatch(r'[a-zA-Z0-9_.-]+', name) and name not in {'.', '..'} for name in (release_name, previous_name, archive_name)): raise ValueError('Invalid release or archive name') release = root / 'releases' / release_name current = root / 'current' previous = current.resolve(strict=True) expected_sha = sys.argv[1].lower() if not os.path.ismount('/mnt/data') or previous != root / 'releases' / previous_name or release.exists(): raise RuntimeError('Unexpected release or external disk state') def snapshot(): containers = subprocess.check_output(['docker', 'ps', '-q'], text=True).split() return {item['Name']: {'id': item['Id'], 'startedAt': item['State']['StartedAt']} for item in json.loads(subprocess.check_output(['docker', 'inspect', *containers], text=True)) if item['Name'] != '/ikar_server'} protected_before = snapshot() print('Creating database and attachment backup', flush=True) backup = Path(subprocess.check_output(['python3', str(previous / 'backup.py')], text=True).strip()) with tempfile.TemporaryDirectory() as temp: target = Path(temp) / 'ikar.db' with tarfile.open(backup) as archive: target.write_bytes(archive.extractfile('data/ikar.db').read()) with sqlite3.connect(target) as db: if db.execute('PRAGMA integrity_check').fetchone()[0] != 'ok': raise RuntimeError('Backup failed integrity verification') shutil.copytree(previous, release, ignore=shutil.ignore_patterns('publish', 'release.tar.gz')) with tarfile.open(root / archive_name) as archive: archive.extractall(release, filter='data') actual_sha = hashlib.sha256((release / 'publish/Ikar.Server.dll').read_bytes()).hexdigest() if actual_sha != expected_sha: raise RuntimeError('Uploaded server differs from verified local build') compose = release / 'compose.yaml' text = compose.read_text() old_image = f'image: ikar_server:{previous_name}' if text.count(old_image) != 1: raise RuntimeError('Unexpected API image in compose') text = text.replace(old_image, f'image: ikar_server:{release_name}', 1) # The loopback-bound Docker port reaches ASP.NET from the bridge gateway. # Trust that exact address so Caddy's HTTPS/IP headers work without trusting all peers. inspection = json.loads(subprocess.check_output(['docker', 'inspect', 'ikar_server'], text=True))[0] gateways = [str(ipaddress.ip_address(item['Gateway'])) for item in inspection['NetworkSettings']['Networks'].values() if item.get('Gateway')] if len(gateways) != 1: raise RuntimeError('Expected exactly one API bridge gateway') setting = ' ReverseProxy__KnownProxies:' if setting in text: text = re.sub(r'(?m)^ ReverseProxy__KnownProxies:.*$', f'{setting} "{gateways[0]}"', text) else: marker = ' IkarUpdates__LocalOnly: "true"' if text.count(marker) != 1: raise RuntimeError('Unexpected API environment section') text = text.replace(marker, marker + f'\n{setting} "{gateways[0]}"', 1) compose.write_text(text) print('Building IKAR API image', flush=True) with (root / f'build-{release_name}.log').open('w') as log: subprocess.run(['docker', 'build', '-f', 'Dockerfile.release', '-t', f'ikar_server:{release_name}', '.'], cwd=release, stdout=log, stderr=subprocess.STDOUT, check=True) def update(path): subprocess.run(['docker', 'compose', '--project-directory', str(path), '--env-file', str(root / 'shared/turn.env'), 'up', '-d', '--no-deps', 'ikar_server'], check=True) try: print('Updating API container', flush=True) update(release) health = None for attempt in range(30): try: with urllib.request.urlopen('http://127.0.0.1:5099/health', timeout=3) as response: health = json.load(response) if health.get('status') == 'ok': break except Exception: pass time.sleep(1) if health is None or health.get('status') != 'ok': raise RuntimeError('New API failed health verification') running_sha = subprocess.check_output(['docker', 'exec', 'ikar_server', 'sha256sum', '/app/Ikar.Server.dll'], text=True).split()[0] if running_sha != expected_sha: raise RuntimeError('Running server differs from verified build') with sqlite3.connect(f'file:{root}/shared/data/ikar.db?mode=ro', uri=True) as db: integrity = db.execute('PRAGMA integrity_check').fetchone()[0] columns = {row[1] for row in db.execute('PRAGMA table_info(Users)')} if integrity != 'ok' or 'PublicLastSeenUnixMs' not in columns: raise RuntimeError('Database or presence schema verification failed') subprocess.run(['docker', 'exec', 'ikar_server', 'ffmpeg', '-version'], stdout=subprocess.DEVNULL, check=True) request = urllib.request.Request('http://127.0.0.1:5099/admin/api/session', headers={'X-Forwarded-Proto': 'https'}) with urllib.request.urlopen(request, timeout=10) as response: admin_state = json.load(response) if not admin_state.get('configured') or not admin_state.get('csrfToken'): raise RuntimeError('Production admin login or HTTPS proxy configuration failed') pending = root / f'current.next-{release_name}' pending.symlink_to(release, target_is_directory=True) os.replace(pending, current) except Exception: print('Rolling back API to previous release', flush=True) update(previous) raise report = { 'passed': True, 'release': str(release), 'previousRelease': str(previous), 'backup': str(backup), 'backupIntegrity': 'ok', 'health': health, 'databaseIntegrity': integrity, 'publicPresenceColumn': True, 'serverSha256': running_sha, 'ffmpegAvailable': True, 'adminLoginConfigured': True, 'adminCsrfAvailable': True, 'trustedProxy': gateways[0], 'protectedServicesUnchanged': snapshot() == protected_before, 'protectedServices': protected_before, } (root / f'deployment-{release_name}.json').write_text(json.dumps(report, indent=2)) print(json.dumps(report), flush=True)