"""Publish only the generated static site to the existing Beget site over SFTP. Credentials stay in the owner's separate access document and are never logged. Run audit before deploy; both phases pin the observed Beget SSH host key. """ from __future__ import annotations from pathlib import Path import base64 import hashlib import json import posixpath import re import sys import uuid ROOT = Path(__file__).resolve().parent sys.path.insert(0, str(ROOT / '.deps')) import paramiko ACCESS_DOC = Path(r'D:\YandexDisk\servaki.online\1 MPO_Inform_AI_Design_System\ops\BEGET-ACCESS.md') HOST = 'infogypa.beget.tech' USER = 'infogypa_0407' HOST_FINGERPRINT = 'SHA256:9vc74idZ9y0Vw8Ai6TS/3PqEn2A8a0VZcAdy5AYlQoc' SITE = '/home/i/infogypa/servaki.online' WEBROOT = SITE + '/public_html' BACKUP = ROOT / 'release-backups' / '2026-09-28-before-landing' REMOTE_BACKUP = SITE + '/backup-2026-09-28-before-landing' CORE = ('index.html', 'sitemap.xml', 'robots.txt') def digest(data: bytes) -> str: return hashlib.sha256(data).hexdigest() class PinnedHost(paramiko.MissingHostKeyPolicy): def missing_host_key(self, client, hostname, key): actual = 'SHA256:' + base64.b64encode(hashlib.sha256(key.asbytes()).digest()).decode().rstrip('=') if hostname != HOST or actual != HOST_FINGERPRINT: raise paramiko.SSHException('Beget SSH host key changed; deployment stopped') client.get_host_keys().add(hostname, key.get_name(), key) def connect(): access = ACCESS_DOC.read_text(encoding='utf-8') match = re.search(r'\*\*Пароль:\*\*\s*`([^`]+)`', access) if not match: raise RuntimeError('FTP/SFTP credential entry not found') ssh = paramiko.SSHClient() ssh.set_missing_host_key_policy(PinnedHost()) ssh.connect(HOST, username=USER, password=match.group(1), look_for_keys=False, allow_agent=False, timeout=20, auth_timeout=20, banner_timeout=20) sftp = ssh.open_sftp() if sftp.normalize(WEBROOT) != WEBROOT: raise RuntimeError('Unexpected Beget document root') return ssh, sftp def exists(sftp, path): try: return sftp.stat(path) except FileNotFoundError: return None def remote_bytes(sftp, path): with sftp.open(path, 'rb') as handle: return handle.read() def mkdirs(sftp, directory): missing = [] while not exists(sftp, directory): missing.append(directory) directory = posixpath.dirname(directory) for item in reversed(missing): sftp.mkdir(item) def manifest(): public = ROOT / 'public' return {p.relative_to(public).as_posix(): digest(p.read_bytes()) for p in public.rglob('*') if p.is_file()} def audit(): ssh, sftp = connect() try: before = {'host_fingerprint': HOST_FINGERPRINT, 'webroot': WEBROOT, 'files': {}, 'top_entries': sorted(sftp.listdir(WEBROOT)), 'release_files': manifest()} live_index = remote_bytes(sftp, WEBROOT + '/index.html') expected = (ROOT / 'reference' / 'original.html').read_bytes() if live_index != expected: raise RuntimeError('Live index differs from the reviewed source; merge before release') for name in CORE: path = WEBROOT + '/' + name data = remote_bytes(sftp, path) if exists(sftp, path) else None before['files'][name] = {'existed': data is not None, 'sha256': digest(data) if data is not None else None} if data is not None: BACKUP.mkdir(parents=True, exist_ok=True) target = BACKUP / name if target.exists() and target.read_bytes() != data: raise RuntimeError('Local backup differs from current Beget file: ' + name) target.write_bytes(data) for dirname in ('approach', 'services', 'industries'): if exists(sftp, WEBROOT + '/' + dirname): raise RuntimeError('Existing remote directory needs review: ' + dirname) if exists(sftp, WEBROOT + '/assets/editorial'): raise RuntimeError('Existing editorial assets need review') BACKUP.mkdir(parents=True, exist_ok=True) (BACKUP / 'before.json').write_text(json.dumps(before, ensure_ascii=False, indent=2), encoding='utf-8') print('Audit passed: live homepage matches source; new editorial paths are clear.') print('Backed up existing core files:', [k for k, v in before['files'].items() if v['existed']]) print('Planned static files:', len(before['release_files'])) finally: sftp.close(); ssh.close() def put_atomic(sftp, local, remote, replace=False, backup_dir=REMOTE_BACKUP): mkdirs(sftp, posixpath.dirname(remote)) temp = remote + '.codex-upload-' + uuid.uuid4().hex[:10] sftp.put(str(local), temp, confirm=True) if digest(remote_bytes(sftp, temp)) != digest(local.read_bytes()): raise RuntimeError('Upload verification failed: ' + remote) if replace: try: sftp.posix_rename(temp, remote) except OSError: relative = posixpath.relpath(remote, WEBROOT) shifted = backup_dir + '/' + relative + '.shifted' mkdirs(sftp, posixpath.dirname(shifted)) sftp.rename(remote, shifted) try: sftp.rename(temp, remote) except Exception: sftp.rename(shifted, remote) raise else: sftp.rename(temp, remote) def deploy(): before = json.loads((BACKUP / 'before.json').read_text(encoding='utf-8')) current = manifest() if current != before['release_files']: raise RuntimeError('Built site changed after audit; rerun audit') ssh, sftp = connect() try: if digest(remote_bytes(sftp, WEBROOT + '/index.html')) != before['files']['index.html']['sha256']: raise RuntimeError('Live homepage changed after audit; deployment stopped') # Refuse to overwrite any unreviewed route or asset. for rel, expected_hash in current.items(): if rel in CORE: continue path = WEBROOT + '/' + rel if exists(sftp, path) and digest(remote_bytes(sftp, path)) != expected_hash: raise RuntimeError('Existing remote file differs: ' + rel) mkdirs(sftp, REMOTE_BACKUP) for name, state in before['files'].items(): if state['existed']: target = REMOTE_BACKUP + '/' + name if not exists(sftp, target): put_atomic(sftp, BACKUP / name, target) elif digest(remote_bytes(sftp, target)) != state['sha256']: raise RuntimeError('Remote backup mismatch: ' + name) public = ROOT / 'public' ordered = sorted((rel for rel in current if rel not in CORE), key=lambda rel: (not rel.startswith('assets/'), rel)) for rel in ordered: target = WEBROOT + '/' + rel if not exists(sftp, target): put_atomic(sftp, public / rel, target) for rel in ('robots.txt', 'sitemap.xml', 'index.html'): target = WEBROOT + '/' + rel if digest(remote_bytes(sftp, target)) == current[rel] if exists(sftp, target) else False: continue put_atomic(sftp, public / rel, target, replace=bool(exists(sftp, target))) for rel, expected_hash in current.items(): if digest(remote_bytes(sftp, WEBROOT + '/' + rel)) != expected_hash: raise RuntimeError('Final remote checksum mismatch: ' + rel) (BACKUP / 'deployed.json').write_text(json.dumps({'files':len(current),'sha256_index':current['index.html']},indent=2),encoding='utf-8') print('Published and verified over SFTP:', len(current), 'files; backup outside public_html:', REMOTE_BACKUP) finally: sftp.close(); ssh.close() def verify(): current = manifest() ssh, sftp = connect() try: failures = [rel for rel, expected in current.items() if not exists(sftp, WEBROOT + '/' + rel) or digest(remote_bytes(sftp, WEBROOT + '/' + rel)) != expected] if failures: raise RuntimeError('Remote file check failed: ' + ', '.join(failures[:5])) print('Remote checksum verification passed:', len(current), 'files') finally: sftp.close(); ssh.close() def update_motion(): """Publish the illustration motion release only after an exact live baseline check.""" previous = ROOT.parent / 'site-servaki-online-git' / 'public' if not previous.is_dir(): raise RuntimeError('Previous Gitea release is required for the baseline check') old = {p.relative_to(previous).as_posix(): digest(p.read_bytes()) for p in previous.rglob('*') if p.is_file()} new = manifest() removed = set(old) - set(new) changed = {rel for rel in new if new[rel] != old.get(rel)} html = {p.relative_to(ROOT / 'public').as_posix() for p in (ROOT / 'public').rglob('*.html')} allowed = html | {'assets/editorial/motion.css', 'assets/editorial/motion.js'} if removed or changed != allowed: raise RuntimeError(f'Unexpected release scope: removed={sorted(removed)}, changed={sorted(changed ^ allowed)}') local_backup = ROOT / 'release-backups' / '2026-09-28-before-motion' remote_backup = SITE + '/backup-2026-09-28-before-motion' ssh, sftp = connect() try: for rel, expected in old.items(): target = WEBROOT + '/' + rel if not exists(sftp, target) or digest(remote_bytes(sftp, target)) != expected: raise RuntimeError('Published baseline changed; stopped before writing: ' + rel) for rel in changed - set(old): if exists(sftp, WEBROOT + '/' + rel): raise RuntimeError('New motion asset already exists: ' + rel) mkdirs(sftp, remote_backup) for rel in sorted(changed & set(old)): data = remote_bytes(sftp, WEBROOT + '/' + rel) destination = local_backup / rel destination.parent.mkdir(parents=True, exist_ok=True) if destination.exists() and destination.read_bytes() != data: raise RuntimeError('Local backup conflict: ' + rel) destination.write_bytes(data) remote_copy = remote_backup + '/' + rel if exists(sftp, remote_copy): if digest(remote_bytes(sftp, remote_copy)) != old[rel]: raise RuntimeError('Remote backup conflict: ' + rel) else: put_atomic(sftp, destination, remote_copy) for rel in sorted(changed, key=lambda item: (item == 'index.html', not item.startswith('assets/'), item)): put_atomic(sftp, ROOT / 'public' / rel, WEBROOT + '/' + rel, replace=rel in old, backup_dir=remote_backup) for rel in changed: if digest(remote_bytes(sftp, WEBROOT + '/' + rel)) != new[rel]: raise RuntimeError('Published motion file failed checksum: ' + rel) print('Published animation update; changed files:', len(changed), 'verified previous files:', len(old), 'backup:', remote_backup) finally: sftp.close(); ssh.close() def update_localized_photos(): """Publish four Russian-lettered photos under new cache-safe asset names.""" previous = ROOT.parent / 'site-servaki-online-git' / 'public' if not previous.is_dir(): raise RuntimeError('Previous Gitea release is required for the baseline check') old = {p.relative_to(previous).as_posix(): digest(p.read_bytes()) for p in previous.rglob('*') if p.is_file()} new = manifest() slugs = ('video-surveillance', 'horeca', 'agriculture', 'construction') pages = {'services/video-surveillance/index.html', *(f'industries/{slug}/index.html' for slug in slugs if slug != 'video-surveillance')} assets = {f'assets/editorial/photo-{slug}-ru-{width}.webp' for slug in slugs for width in (480, 960, 1536)} changed = {rel for rel in new if new[rel] != old.get(rel)} if set(old) - set(new) or changed != pages | assets: raise RuntimeError('Unexpected Russian-photo release scope: ' + repr(sorted(changed ^ (pages | assets)))) local_backup = ROOT / 'release-backups' / '2026-09-28-before-russian-photos' remote_backup = SITE + '/backup-2026-09-28-before-russian-photos' ssh, sftp = connect() try: for rel, expected in old.items(): target = WEBROOT + '/' + rel if not exists(sftp, target) or digest(remote_bytes(sftp, target)) != expected: raise RuntimeError('Published baseline changed; stopped before writing: ' + rel) for rel in assets: if exists(sftp, WEBROOT + '/' + rel): raise RuntimeError('New localized asset already exists: ' + rel) mkdirs(sftp, remote_backup) for rel in sorted(pages): data = remote_bytes(sftp, WEBROOT + '/' + rel) destination = local_backup / rel destination.parent.mkdir(parents=True, exist_ok=True) if destination.exists() and destination.read_bytes() != data: raise RuntimeError('Local backup conflict: ' + rel) destination.write_bytes(data) remote_copy = remote_backup + '/' + rel if exists(sftp, remote_copy): if digest(remote_bytes(sftp, remote_copy)) != old[rel]: raise RuntimeError('Remote backup conflict: ' + rel) else: put_atomic(sftp, destination, remote_copy) for rel in sorted(assets): put_atomic(sftp, ROOT / 'public' / rel, WEBROOT + '/' + rel) for rel in sorted(pages): put_atomic(sftp, ROOT / 'public' / rel, WEBROOT + '/' + rel, replace=True, backup_dir=remote_backup) for rel in changed: if digest(remote_bytes(sftp, WEBROOT + '/' + rel)) != new[rel]: raise RuntimeError('Published localized photo failed checksum: ' + rel) print('Published Russian-lettered photos; changed files:', len(changed), 'verified previous files:', len(old), 'backup:', remote_backup) finally: sftp.close(); ssh.close() def reprice(): """Replace only the published homepage after verifying the six price tokens.""" before = json.loads((BACKUP / 'deployed.json').read_text(encoding='utf-8')) new_index = (ROOT / 'public' / 'index.html').read_bytes() new_hash = digest(new_index) price_backup = ROOT / 'release-backups' / '2026-09-28-before-tripled-prices' remote_price_backup = SITE + '/backup-2026-09-28-before-tripled-prices' ssh, sftp = connect() try: live_index = remote_bytes(sftp, WEBROOT + '/index.html') if digest(live_index) == new_hash: print('Tariff update already published:', new_hash) return if digest(live_index) != before['sha256_index']: raise RuntimeError('Live homepage changed since the previous release; reprice stopped') expected = live_index.decode('utf-8') for old in (3000, 7000, 15000): structured = f'"price": "{old}"' visible = f'{old}' if expected.count(structured) != 1 or expected.count(visible) != 1: raise RuntimeError(f'Unexpected original tariff markup: {old}') new = old * 3 expected = expected.replace(structured, f'"price": "{new}"') expected = expected.replace(visible, f'{new:,}'.replace(',', ' ')) if expected.encode('utf-8') != new_index: raise RuntimeError('Built homepage has changes beyond the six tariff values') price_backup.mkdir(parents=True, exist_ok=True) local_copy = price_backup / 'index.html' if local_copy.exists() and local_copy.read_bytes() != live_index: raise RuntimeError('Existing local tariff backup differs from live homepage') local_copy.write_bytes(live_index) mkdirs(sftp, remote_price_backup) remote_copy = remote_price_backup + '/index.html' if exists(sftp, remote_copy): if digest(remote_bytes(sftp, remote_copy)) != digest(live_index): raise RuntimeError('Existing remote tariff backup differs from live homepage') else: put_atomic(sftp, local_copy, remote_copy) put_atomic(sftp, ROOT / 'public' / 'index.html', WEBROOT + '/index.html', replace=True) if digest(remote_bytes(sftp, WEBROOT + '/index.html')) != new_hash: raise RuntimeError('Published tariff homepage checksum mismatch') (price_backup / 'deployed.json').write_text(json.dumps({ 'before_sha256': digest(live_index), 'after_sha256': new_hash, 'remote_backup': remote_copy, 'prices_rub_month': [9000, 21000, 45000] }, ensure_ascii=False, indent=2), encoding='utf-8') print('Published tripled tariffs; homepage checksum:', new_hash) finally: sftp.close(); ssh.close() if __name__ == '__main__': {'audit': audit, 'deploy': deploy, 'verify': verify, 'reprice': reprice, 'update-motion': update_motion, 'update-localized-photos': update_localized_photos}[sys.argv[1]]()