"""정적 사이트 산출물을 Azure Blob의 `$web` 컨테이너에 발행한다.""" import asyncio import mimetypes import os from pathlib import Path from azure.storage.blob import BlobServiceClient, ContentSettings DEFAULT_CONTAINER = "$web" DEFAULT_PREFIX = "ai-for-web" # 사이트별 산출물이 놓이는 디렉터리(out/s/). SITE_ROOT_DIR = "s" class AzurePublishError(RuntimeError): pass def is_configured() -> bool: return bool(os.environ.get("AZURE_STORAGE_CONNECTION_STRING", "").strip()) def output_dir() -> Path: return Path(os.environ.get("SITE_OUTPUT_DIR", "/app/solution/site/out")) def _content_type(path: Path) -> str: overrides = { ".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".css": "text/css; charset=utf-8", ".json": "application/json; charset=utf-8", ".xml": "application/xml; charset=utf-8", ".txt": "text/plain; charset=utf-8", # 노래. ".mp3": "audio/mpeg", } return overrides.get(path.suffix.lower()) or mimetypes.guess_type(path.name)[0] or "application/octet-stream" def _cache_control(path: Path) -> str: head = path.parts[0] if path.parts else "" # 번들은 파일명에 해시가 박혀 있다 — 내용이 바뀌면 이름이 바뀌므로 영구 캐시가 안전하다. if head == "assets": return "public, max-age=31536000, immutable" # 폰트는 이름이 고정이라 immutable 은 못 쓰지만(교체하면 갱신되어야 한다) 거의 안 바뀐다. if head == "fonts": return "public, max-age=604800" # 노래 파일명은 song_id(UUID)다 — 곡이 바뀌면 이름도 바뀌므로 영구 캐시가 안전하다. if path.suffix.lower() == ".mp3": return "public, max-age=31536000, immutable" # HTML · robots.txt · sitemap.xml — 발행하면 곧바로 반영되어야 한다. return "public, max-age=60, must-revalidate" def _upload_file(container, root: Path, file: Path, prefix: str) -> str: relative_name = file.relative_to(root).as_posix() blob_name = f"{prefix}/{relative_name}" if prefix else relative_name with file.open("rb") as stream: container.upload_blob( name=blob_name, data=stream, overwrite=True, content_settings=ContentSettings(content_type=_content_type(file)), cache_control=_cache_control(Path(relative_name)), ) return blob_name def _upload_tree(container, root: Path, relative_root: str, prefix: str) -> set[str]: base = root / relative_root if not base.is_dir(): raise AzurePublishError(f"정적 산출물 디렉터리가 없습니다: {base}") return { _upload_file(container, root, file, prefix) for file in base.rglob("*") if file.is_file() and not file.name.startswith(".") } def _upload_shared(container, root: Path, prefix: str) -> set[str]: """out/ 루트의 공용 산출물 — 사이트별 파일(`s/` 아래)을 뺀 전부.""" if not root.is_dir(): raise AzurePublishError(f"정적 산출물 디렉터리가 없습니다: {root}") uploaded: set[str] = set() for entry in sorted(root.iterdir()): # `s/` 는 사이트별 디렉터리다 — 발행한 사이트 하나만 따로 올린다. if entry.name.startswith(".") or entry.name in {SITE_ROOT_DIR, "versions"}: continue if entry.is_dir(): uploaded |= _upload_tree(container, root, entry.name, prefix) elif entry.is_file(): uploaded.add(_upload_file(container, root, entry, prefix)) return uploaded def _remove_stale_site_files(container, site_prefix: str, current: set[str]) -> int: stale = [blob.name for blob in container.list_blobs(name_starts_with=f"{site_prefix}/") if blob.name not in current] for blob_name in stale: container.delete_blob(blob_name) return len(stale) def _publish_sync(slug: str) -> dict: connection_string = os.environ["AZURE_STORAGE_CONNECTION_STRING"].strip() container_name = os.environ.get("AZURE_STORAGE_CONTAINER", DEFAULT_CONTAINER).strip() or DEFAULT_CONTAINER prefix = os.environ.get("AZURE_STORAGE_PREFIX", DEFAULT_PREFIX).strip().strip("/") root = output_dir() try: service = BlobServiceClient.from_connection_string(connection_string) container = service.get_container_client(container_name) # 공용 산출물은 덮어써도 안전하다(번들은 해시 파일이고, robots·사이트맵은 매 발행마다 프리렌더가 현재 발행본 전체를 보고 다시 쓴다). shared = _upload_shared(container, root, prefix) site = _upload_tree(container, root, f"{SITE_ROOT_DIR}/{slug}", prefix) site_prefix = "/".join(part for part in (prefix, SITE_ROOT_DIR, slug) if part) removed = _remove_stale_site_files(container, site_prefix, site) return { "container": container_name, "prefix": prefix, "shared_files": len(shared), "site_files": len(site), "removed_stale": removed, } except AzurePublishError: raise except Exception as ex: raise AzurePublishError(f"Azure 정적 파일 업로드 실패: {type(ex).__name__}: {ex}") from ex async def publish(slug: str) -> dict | None: """설정된 경우에만 업로드한다.""" if not is_configured(): return None return await asyncio.to_thread(_publish_sync, slug)