backend/storage.py (5761 bytes)
1 """Artifact storage. Local filesystem now, S3 for the public release. 2 3 Everything downstream deals in opaque storage keys, so swapping the backend 4 touches neither the API nor the job runner. 5 """ 6 7 import shutil 8 from abc import ABC, abstractmethod 9 from pathlib import Path 10 11 from .settings import settings 12 13 14 class Storage(ABC): 15 @abstractmethod 16 def put_file(self, key: str, source: Path) -> int: 17 """Store the file at `source` under `key`; return the byte size.""" 18 19 @abstractmethod 20 def open_stream(self, key: str): 21 """Return a binary file-like object for `key`.""" 22 23 @abstractmethod 24 def read_text(self, key: str) -> str: 25 ... 26 27 @abstractmethod 28 def delete_prefix(self, prefix: str) -> None: 29 ... 30 31 @abstractmethod 32 def list_prefix(self, prefix: str) -> list[str]: 33 """Every key under `prefix`. Used to move a job's inputs between machines.""" 34 35 def presigned_url(self, key: str, filename: str) -> str | None: 36 """S3 hands back a direct URL; local storage streams through the app.""" 37 return None 38 39 def exists(self, key: str) -> bool: 40 try: 41 self.open_stream(key).close() 42 return True 43 except Exception: 44 return False 45 46 def fetch_to(self, key: str, dest: Path) -> Path: 47 """Copy `key` out of storage onto local disk. 48 49 This is what lets a worker on another machine pick up a job whose files 50 were uploaded to a different API process. 51 """ 52 dest.parent.mkdir(parents=True, exist_ok=True) 53 src = self.open_stream(key) 54 try: 55 with dest.open("wb") as out: 56 while chunk := src.read(8 * 1024 * 1024): 57 out.write(chunk) 58 finally: 59 close = getattr(src, "close", None) 60 if close: 61 close() 62 return dest 63 64 65 class LocalStorage(Storage): 66 def __init__(self, root: Path): 67 self.root = root.resolve() 68 self.root.mkdir(parents=True, exist_ok=True) 69 70 def _path(self, key: str) -> Path: 71 # Keys are app-generated, but refuse traversal regardless. 72 p = (self.root / key).resolve() 73 if not str(p).startswith(str(self.root)): 74 raise ValueError(f"unsafe storage key: {key!r}") 75 return p 76 77 def path_for(self, key: str) -> Path: 78 """On-disk location of `key`. Local backend only - used to serve files.""" 79 return self._path(key) 80 81 def put_file(self, key: str, source: Path) -> int: 82 dest = self._path(key) 83 dest.parent.mkdir(parents=True, exist_ok=True) 84 shutil.copy2(source, dest) 85 return dest.stat().st_size 86 87 def open_stream(self, key: str): 88 return self._path(key).open("rb") 89 90 def read_text(self, key: str) -> str: 91 return self._path(key).read_text(encoding="utf-8") 92 93 def delete_prefix(self, prefix: str) -> None: 94 target = self._path(prefix) 95 if target.is_dir(): 96 shutil.rmtree(target, ignore_errors=True) 97 elif target.exists(): 98 target.unlink() 99 100 def list_prefix(self, prefix: str) -> list[str]: 101 target = self._path(prefix) 102 if not target.is_dir(): 103 return [prefix] if target.exists() else [] 104 return sorted( 105 p.relative_to(self.root).as_posix() 106 for p in target.rglob("*") 107 if p.is_file() 108 ) 109 110 111 class S3Storage(Storage): 112 """Public-release backend. Needs boto3 and SUBPLZ_WEB_S3_BUCKET.""" 113 114 def __init__(self, bucket: str, prefix: str): 115 import boto3 # lazy import so localhost never needs boto3 116 117 self.client = boto3.client("s3") 118 self.bucket = bucket 119 self.prefix = prefix.rstrip("/") + "/" if prefix else "" 120 121 def _key(self, key: str) -> str: 122 return f"{self.prefix}{key}" 123 124 def put_file(self, key: str, source: Path) -> int: 125 self.client.upload_file(str(source), self.bucket, self._key(key)) 126 return source.stat().st_size 127 128 def open_stream(self, key: str): 129 return self.client.get_object(Bucket=self.bucket, Key=self._key(key))["Body"] 130 131 def read_text(self, key: str) -> str: 132 return self.open_stream(key).read().decode("utf-8") 133 134 def delete_prefix(self, prefix: str) -> None: 135 paginator = self.client.get_paginator("list_objects_v2") 136 for page in paginator.paginate(Bucket=self.bucket, Prefix=self._key(prefix)): 137 keys = [{"Key": o["Key"]} for o in page.get("Contents", [])] 138 if keys: 139 self.client.delete_objects(Bucket=self.bucket, Delete={"Objects": keys}) 140 141 def list_prefix(self, prefix: str) -> list[str]: 142 paginator = self.client.get_paginator("list_objects_v2") 143 out: list[str] = [] 144 head = len(self.prefix) 145 for page in paginator.paginate(Bucket=self.bucket, Prefix=self._key(prefix)): 146 out += [o["Key"][head:] for o in page.get("Contents", [])] 147 return sorted(out) 148 149 def presigned_url(self, key: str, filename: str) -> str | None: 150 disposition = f'attachment; filename="{filename}"' 151 return self.client.generate_presigned_url( 152 "get_object", 153 Params={ 154 "Bucket": self.bucket, 155 "Key": self._key(key), 156 "ResponseContentDisposition": disposition, 157 }, 158 ExpiresIn=settings.download_url_ttl_seconds, 159 ) 160 161 162 def get_storage() -> Storage: 163 if settings.storage_backend == "s3": 164 if not settings.s3_bucket: 165 raise RuntimeError("storage_backend=s3 requires SUBPLZ_WEB_S3_BUCKET") 166 return S3Storage(settings.s3_bucket, settings.s3_prefix) 167 return LocalStorage(settings.data_dir / "artifacts") 168 169 170 storage = get_storage()