"""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]]()