"""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):
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:
shifted = REMOTE_BACKUP + '/' + posixpath.basename(remote) + '.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 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}[sys.argv[1]]()