#!/usr/bin/env python3 """Reproduce both directions of a static release's asset boundary on loopback.""" import argparse import json import os import platform import shutil import socket import subprocess import tempfile import time from datetime import datetime, timezone from pathlib import Path from urllib.error import HTTPError, URLError from urllib.request import urlopen def main(): parser = argparse.ArgumentParser() parser.add_argument('--output', required=True) output = Path(parser.parse_args().output) if output.exists(): raise SystemExit('Existing evidence will not be overwritten.') binary = shutil.which('nginx') or '/usr/sbin/nginx' version = subprocess.run([binary, '-v'], capture_output=True, text=True, check=True, timeout=5).stderr.strip() with tempfile.TemporaryDirectory(prefix='punchblog-rollback-') as directory: root = Path(directory) for name, color in [('a', 'red'), ('b', 'blue'), ('a-retained', 'red')]: release = root / name (release / 'assets').mkdir(parents=True) asset = 'a' if name.startswith('a') else 'b' (release / 'index.html').write_text( f'release-{asset}') (release / 'assets' / (asset + '.css')).write_text(f'body{{color:{color}}}') # Prepare a distinct rollback candidate; do not mutate the original a. shutil.copy2(root / 'b/assets/b.css', root / 'a-retained/assets/b.css') shutil.copy2(root / 'a/assets/a.css', root / 'b/assets/a.css') (root / 'current').symlink_to(root / 'a') with socket.socket() as sock: sock.bind(('127.0.0.1', 0)) port = sock.getsockname()[1] config = root / 'nginx.conf' config.write_text(f'''pid {root}/nginx.pid; error_log {root}/error.log; daemon off; master_process off; events {{ worker_connections 16; }} http {{ access_log off; types {{ text/html html; text/css css; }} server {{ listen 127.0.0.1:{port}; root {root}/current; location / {{ try_files $uri $uri/ =404; }} }} }} ''') subprocess.run([binary, '-t', '-p', str(root) + '/', '-c', str(config)], capture_output=True, check=True, timeout=5) process = subprocess.Popen([binary, '-p', str(root) + '/', '-c', str(config)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) try: def fetch(path): try: response = urlopen(f'http://127.0.0.1:{port}' + path, timeout=3) except HTTPError as error: response = error with response: return {'status': response.status, 'body': response.read().decode(), 'content_type': response.headers.get('Content-Type')} for _ in range(50): if process.poll() is not None: raise RuntimeError('Isolated Nginx exited before readiness.') try: fetch('/') break except URLError: time.sleep(0.05) else: raise RuntimeError('Isolated Nginx did not become ready.') def switch(name): (root / 'next').symlink_to(root / name) os.replace(root / 'next', root / 'current') a_html = fetch('/') switch('b') b_html = fetch('/') forward_old_css = fetch('/assets/a.css') b_css = fetch('/assets/b.css') switch('a') rollback_html = fetch('/') rollback_missing = fetch('/assets/b.css') switch('a-retained') retained_html = fetch('/') rollback_retained = fetch('/assets/b.css') assert a_html['status'] == b_html['status'] == rollback_html['status'] == 200 assert a_html == rollback_html == retained_html assert 'release-b' in b_html['body'] and '/assets/b.css' in b_html['body'] assert forward_old_css['status'] == 200 and forward_old_css['body'] == 'body{color:red}' assert rollback_missing['status'] == 404 assert rollback_retained == b_css assert b_css['status'] == 200 and b_css['body'] == 'body{color:blue}' assert b_css['content_type'] == 'text/css' observations = dict(a_html=a_html, b_html=b_html, forward_old_css=forward_old_css, b_css=b_css, rollback_html=rollback_html, rollback_missing=rollback_missing, retained_html=retained_html, rollback_retained=rollback_retained, nginx_restarted=False) finally: if process.poll() is None: process.terminate() try: process.wait(timeout=5) except subprocess.TimeoutExpired: process.kill() process.wait(timeout=5) record = { 'observed_at': datetime.now(timezone.utc).isoformat(), 'environment': {'os': platform.system(), 'python': platform.python_version(), 'nginx': version}, 'method': '동일 파일시스템의 임시 릴리스 a→b→a→a-retained를 순차 전환. HTTP GET과 CSS 본문·Content-Type 대조. 기존 웹 서버 설정과 운영 릴리스는 변경하지 않음.', 'limits': '브라우저 병렬 로딩, CDN, open_file_cache, 부하, DB 롤백, 안전한 자산 삭제 기간은 측정하지 않았다.', 'observations': observations, 'tables': {'rollback': { 'headers': ['요청 순서', 'HTTP 상태', '확인 결과'], 'rows': [['b 전환 뒤 a.css', forward_old_css['status'], '이전 자산 보관: red'], ['b의 HTML과 b.css', b_css['status'], 'release-b / blue'], ['a로 롤백한 HTML', rollback_html['status'], 'release-a'], ['a 롤백 뒤 b.css', rollback_missing['status'], '새 HTML의 자산 누락'], ['b.css도 보관한 롤백 후보', rollback_retained['status'], 'blue / text/css']], }}, } output.parent.mkdir(parents=True, exist_ok=True) with output.open('x') as stream: json.dump(record, stream, ensure_ascii=False, indent=2) stream.write('\n') print(json.dumps({'output': str(output), 'tables': record['tables']}, ensure_ascii=False)) if __name__ == '__main__': main()