308 lines
14 KiB
Python
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]]()
|