Files
site-servaki-online/deploy_beget.py
T

308 lines
14 KiB
Python

"""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 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'<span class="amount">{old}</span>'
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'<span class="amount">{new:,}</span>'.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}[sys.argv[1]]()