"""대상 버전 스냅샷 로드 → payload 재조립 → 렌더 → 게이트 → 공개 주소 전환""" import os import time import uuid from common.database.db_session_manager import DB_SESSION_MNG from common.database.model.models import places, site_publish_logs, site_versions, sites from common.enums import ( BuildStatus, DBWRType, ErrorType, PlaceCategory, PublishAction, PublishResult, SiteStatus, ) from common.logger import LOG from common.utils.gtime import GTime from crud.place_crud import PlaceCRUD from crud.site_crud import SiteCRUD from services import activity_feed, alert_service, azure_static, indexnow, publish_gate, render_service, site_payload from services.build_service import post_build_summary, ensure_site, load_channel_links _site_crud = SiteCRUD() _place_crud = PlaceCRUD() RENDER_TIMEOUT_SEC_ENV = "RENDER_TIMEOUT_SEC" class RollbackAborted(RuntimeError): """재시도해도 소용없는 중단 — 잡의 last_error 로 남는다.""" async def _log(site_id, version_id, result: PublishResult, gate=None, actor=None): row = site_publish_logs( site_id=site_id, site_version_id=version_id, action=PublishAction.ROLLBACK.value, result=result.value, reject_reason=(gate.reason.value if gate is not None and gate.reason else None), detail=(gate.as_log() if gate is not None and not gate.passed else None), actor_user_id=uuid.UUID(actor) if actor else None, ) await DB_SESSION_MNG.execute_lambda_run([site_publish_logs.DBType()], [lambda s: _site_crud.add_log(s, row)]) async def run_rollback(job: dict) -> dict: """ROLLBACK 잡 핸들러.""" started = time.monotonic() try: result = await _run_rollback(job) except Exception as ex: await activity_feed.post( "❌ 되돌리기 실패", [f"place_id={job['payload'].get('place_id')} → v{job['payload'].get('target_version')}", f"{type(ex).__name__}: {ex}", f"{time.monotonic() - started:.0f}초"], ) raise await post_build_summary( {**result, "version": result.get("target_version"), "mode": "되돌리기", "build_status": "BUILT" if result.get("rolled_back") else "FAILED"}, time.monotonic() - started, ) return result async def _run_rollback(job: dict) -> dict: payload = job["payload"] place_id = payload["place_id"] owner_user_id = payload["owner_user_id"] target_version = int(payload["target_version"]) render_timeout_sec = float(os.environ.get(RENDER_TIMEOUT_SEC_ENV) or 180) err, place = await DB_SESSION_MNG.execute_lambda( places.DBType(), DBWRType.DB_READ.value, lambda s: _place_crud.get_place(s, uuid.UUID(owner_user_id), uuid.UUID(place_id)), ) if err != ErrorType.SUCCESS or place is None: raise RollbackAborted(f"사업장을 찾을 수 없다: {place_id}") site = await ensure_site(place_id) v_err, version = await DB_SESSION_MNG.execute_lambda( site_versions.DBType(), DBWRType.DB_READ.value, lambda s: _site_crud.get_version_by_number(s, site.site_id, target_version), ) if v_err != ErrorType.SUCCESS or version is None: raise RollbackAborted(f"버전 {target_version} 을 찾을 수 없다") if version.build_status != BuildStatus.BUILT.value or not version.snapshot: raise RollbackAborted(f"버전 {target_version} 은 성공적으로 빌드된 적이 없어 되돌릴 수 없다") if site.current_version_id == version.site_version_id: raise RollbackAborted(f"이미 지금 공개 중인 버전이다(v{target_version})") result = { "place_id": place_id, "site_id": str(site.site_id), "target_version": target_version, "site_version_id": str(version.site_version_id), "label": activity_feed.place_label(place), } await activity_feed.post("🏗 되돌리기 시작", [f"{result['label']} → v{target_version}"]) async def _fail(reason: str, gate: publish_gate.GateResult | None = None): await _log(site.site_id, version.site_version_id, PublishResult.REJECTED if gate else PublishResult.FAILED, gate, payload.get("requested_by")) result["rolled_back"] = False result["error"] = reason LOG.w(f"[rollback] place={place_id} v{target_version} 실패: {reason}") # 게이트 반려는 알리지 않는다 — build_service._fail 과 같은 규칙(운영자를 부를 일이 아니다). if gate is None: await alert_service.send_alert( kind="build_failed", title=f"롤백 실패 — {place_id} → v{target_version}", detail=f"place_id={place_id} target_version={target_version}\n{reason}", dedupe_key=f"build_failed:{place_id}", ) return result # snapshot 을 그대로 옮긴다 — 재수집하지 않는다(파일 머리주석 참조). links = await load_channel_links(place_id) payload_dict = await site_payload.prepare_site_payload( place, version.snapshot, site, version, links, publish=True ) payload_path = site_payload.write_payload(payload_dict) result["payload_path"] = payload_path try: report = await render_service.render_site(payload_path, target_version, render_timeout_sec) except render_service.RenderFailed as ex: return await _fail(str(ex)) if report.get("media"): result["render_media"] = report["media"] mismatches = list(report.get("mismatches") or []) unique_count_raw = report.get("uniqueContentCount") # 렌더러가 그때와 달라졌을 수 있으므로 게이트를 다시 돈다(파일 머리주석 참조) — fact 검증 게이트(1차)는 건너뛴다. gate = publish_gate.evaluate( PlaceCategory(place.category), version.snapshot.get("facts") or [], unique_count_raw, mismatches ) result["gate"] = {"passed": gate.passed, **gate.as_log()} if not gate.passed: return await _fail(f"{gate.reason.name}: {gate.as_log()}", gate) if not report.get("ok"): return await _fail(str(report.get("error") or "렌더 실패")) slug = site_payload.publish_slug(place, site) try: await render_service.activate_site(slug, target_version) await azure_static.publish(slug) except (render_service.RenderFailed, azure_static.AzurePublishError) as ex: return await _fail(str(ex)) await indexnow.submit(slug) now = GTime.UTC() site_update = { "status": SiteStatus.PUBLISHED.value, "current_version_id": version.site_version_id, "published_at": now, } await DB_SESSION_MNG.execute_lambda_claim( sites.DBType(), lambda s: _site_crud.update_site(s, site.site_id, site_update) ) await _log(site.site_id, version.site_version_id, PublishResult.SUCCESS, None, payload.get("requested_by")) await alert_service.resolve_alert(f"build_failed:{place_id}", f"롤백 성공 — {place_id} → v{target_version}") result["rolled_back"] = True result["url"] = site_payload.publish_url(place, site) LOG.i(f"[rollback] place={place_id} v{target_version} 로 되돌림") return result