diff --git a/Dockerfile b/Dockerfile index 6f69266..e07fc95 100644 --- a/Dockerfile +++ b/Dockerfile @@ -48,7 +48,7 @@ EXPOSE 8000 # Probe the configured port, not a hardcoded 8000 — overriding PICGEN_PORT # used to leave a working container permanently "unhealthy". HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ - CMD python -c "import os, urllib.request; urllib.request.urlopen('http://127.0.0.1:%s/api/health' % os.environ.get('PICGEN_PORT', '8000'), timeout=3).read()" || exit 1 + CMD python -c "import os, urllib.request; urllib.request.urlopen('http://127.0.0.1:%s/api/ready' % os.environ.get('PICGEN_PORT', '8000'), timeout=3).read()" || exit 1 ENTRYPOINT ["python", "-m", "picgen.cli"] CMD ["--host", "0.0.0.0", "--port", "8000", "--workers", "1", "--log-format", "json"] diff --git a/README.md b/README.md index 74face6..c1ab2fb 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # PicGen Console -一个面向 OpenAI 兼容图像生成 / 编辑接口的本地工作台,当前版本 **0.1.66**。它把 +一个面向 OpenAI 兼容图像生成 / 编辑接口的本地工作台,当前版本 **0.1.67**。它把 `/v1/images/generations`、`/v1/images/edits` 与 `/v1/responses`(含 `image_generation` 工具) 包装成统一可观测的代理,前端是一套零依赖的 Web 控制台。 @@ -14,7 +14,10 @@ ![PicGen Console 主程序界面](demo1.png) -## 0.1.66 主要特性 +## 0.1.67 主要特性 + +- **数据库故障不再假健康**:`/api/ready` 会用短超时 `quick_check` 验证 SQLite,并对数据库与输出目录所在文件系统执行相邻临时文件的 write、fsync 和清理;任一依赖异常时返回 HTTP 503。完整性结果最长缓存 5 分钟,文件身份、大小或修改时间变化会立即重检。 +- **容器健康检查覆盖 SQLite**:Dockerfile 与 Compose 改探测 `/api/ready`;`/api/health` 继续只表示进程存活。就绪结果缓存 5 秒,限制高频请求造成的探针写放大。 - **简洁模式壳层统一**:顶栏、状态、预览和页脚使用既有 Arco token 重做;专业模式除模式按钮和“我的收藏”需求文案外保持原像素。 - **编辑 Logo 缩放检测闭环**:检测坐标按源画布到结果画布的宽度比例缩放,缺失尺寸会先补齐;保留判定记录匹配率、像素数、阈值和候选关联,便于追查误判。 @@ -90,7 +93,7 @@ - `CORSMiddleware` — 配置化跨域 - **原子化落盘**:图片与 sidecar JSON 用临时文件 + rename 写入,崩溃不留半截文件;核心生成、 反馈、分享和取图送达数据会进入 SQLite,便于管理员后续审计。 -- **健康分级**:`/api/health` 仅看进程存活;`/api/ready` 联动客户端、磁盘可写性、版本号。 +- **健康分级**:`/api/health` 仅看进程存活;`/api/ready` 联动客户端、输出存储、SQLite 与数据库文件系统、版本号。 - **Telegram 统一通知**:可配置 Telegram Bot 接收后台/上游异常、成功生图、Bug 反馈和找回密码申请; 消息以 `【PicGen|分类】` 开头,Telegram 列表里可直接区分事件类型和关键标题。 - **旅行提示词标签**:生成提示词下方的快捷标签改为“高级旅行 / 精致海报 / 酒店质感 / @@ -136,10 +139,10 @@ PICGEN_LOG_FORMAT=json \ ### Docker ```bash -docker build -t minorli/picgen:0.1.66 . +docker build -t minorli/picgen:0.1.67 . docker run --rm -p 8000:8000 \ -v picgen-data:/app/data \ - minorli/picgen:0.1.66 + minorli/picgen:0.1.67 ``` 或: @@ -154,13 +157,13 @@ docker compose up -d ./scripts/docker-build-push.sh ``` -默认会构建并推送 `minorli/picgen:0.1.66`。也可以覆盖: +默认会构建并推送 `minorli/picgen:0.1.67`。也可以覆盖: ```bash -IMAGE=minorli/picgen VERSION=0.1.66 PLATFORM=linux/amd64 ./scripts/docker-build-push.sh +IMAGE=minorli/picgen VERSION=0.1.67 PLATFORM=linux/amd64 ./scripts/docker-build-push.sh ``` -镜像不会包含 `.env`、本地用户库或历史图片。容器内置 `HEALTHCHECK` 探测 `/api/health`,以非 root +镜像不会包含 `.env`、本地用户库或历史图片。容器内置 `HEALTHCHECK` 探测 `/api/ready`,以非 root 用户 `picgen` 运行。`docker-compose.yml` 使用 `picgen-data` volume 保存 `/app/data`,因此注册用户、 用量统计和落盘结果会在容器重启后继续保留。 @@ -270,7 +273,7 @@ Bug 反馈和找回密码申请会先写入本地认证库,再优先发送到 ## 图像通道 -PicGen 0.1.66 把四类图像操作统一提交给 `/api/image-jobs`,实际通道由服务端决定: +PicGen 0.1.67 把四类图像操作统一提交给 `/api/image-jobs`,实际通道由服务端决定: | 用户操作 | 默认接口 | 默认模型 | | --- | --- | --- | @@ -290,7 +293,7 @@ Responses,并覆盖模型、接口和 reasoning effort。非 Images 原生精 | --- | --- | --- | | `/api/config` | GET | 返回前端用的配置(不含 key 明文) | | `/api/health` | GET | 进程健康 | -| `/api/ready` | GET | 联动健康(磁盘、HTTP 客户端、版本) | +| `/api/ready` | GET | 联动健康(输出存储、SQLite、数据库存储、HTTP 客户端、版本) | | `/api/image-jobs` | POST | 统一生成、参考图、延展和编辑入口,由服务端选择实际通道 | | `/api/generate` | POST | 调上游 Images 生成接口(默认通道) | | `/api/edit` | POST | 调上游 Images 编辑接口(默认通道,含参考图 / 延展 / 编辑) | @@ -403,7 +406,7 @@ uv run picgen --prune-now - 按需配置 `PICGEN_CORS_ALLOW_ORIGINS`。 - 收紧 `PICGEN_MAX_REQUEST_BODY_BYTES` / `PICGEN_MAX_IMAGE_BYTES` 以降低 DoS 面积。 -`/api/health` 与 `/api/ready` 默认绕过鉴权与限流,便于探针。 +`/api/health` 与 `/api/ready` 默认绕过鉴权与限流,便于容器探针;就绪结果会短时缓存。 ## 可观测性 diff --git a/docker-compose.yml b/docker-compose.yml index 7907d10..9909303 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,6 @@ services: picgen: - image: minorli/picgen:0.1.66 + image: minorli/picgen:0.1.67 build: context: . ports: @@ -29,7 +29,7 @@ services: - "CMD" - "python" - "-c" - - "import os, urllib.request; urllib.request.urlopen('http://127.0.0.1:%s/api/health' % os.environ.get('PICGEN_PORT', '8000'), timeout=3).read()" + - "import os, urllib.request; urllib.request.urlopen('http://127.0.0.1:%s/api/ready' % os.environ.get('PICGEN_PORT', '8000'), timeout=3).read()" interval: 30s timeout: 5s retries: 3 diff --git a/pyproject.toml b/pyproject.toml index 7453979..d2f2487 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "picgen" -version = "0.1.66" +version = "0.1.67" description = "Enterprise-grade local web console for OpenAI-compatible image generation and editing APIs." readme = "README.md" requires-python = ">=3.12" diff --git a/scripts/docker-build-push.sh b/scripts/docker-build-push.sh index 44e02f3..bfcd2ee 100755 --- a/scripts/docker-build-push.sh +++ b/scripts/docker-build-push.sh @@ -2,7 +2,7 @@ set -euo pipefail IMAGE="${IMAGE:-minorli/picgen}" -VERSION="${VERSION:-0.1.66}" +VERSION="${VERSION:-0.1.67}" PLATFORM="${PLATFORM:-linux/amd64}" docker buildx build \ diff --git a/src/picgen/auth.py b/src/picgen/auth.py index 78fc19f..5e6ebd2 100644 --- a/src/picgen/auth.py +++ b/src/picgen/auth.py @@ -3,9 +3,11 @@ import hashlib import hmac import json +import os import secrets import sqlite3 import threading +import time from contextlib import suppress from dataclasses import dataclass from datetime import UTC, datetime, timedelta @@ -13,6 +15,7 @@ from typing import Any from .config import DEFAULT_RESPONSES_MODEL, LEGACY_DEFAULT_RESPONSES_MODEL +from .storage import storage_is_writable _HASH_NAME = "pbkdf2_sha256" _HASH_ITERATIONS = 600_000 @@ -23,6 +26,7 @@ _SHARED_RESULT_OWNERSHIP_SCHEMA_VERSION = 15 _MAX_FAILED_LOGIN_ATTEMPTS = 5 _ACCOUNT_LOCK_MINUTES = 15 +_DATABASE_INTEGRITY_CACHE_SECONDS = 5 * 60.0 _PASSWORD_RESET_REQUEST_THROTTLE_MINUTES = 10 TEAM_CHAT_BOT_ID = "gpt-bot" TEAM_CHAT_BOT_NAME = "GPT-BOT" @@ -104,6 +108,65 @@ def __init__(self, db_path: Path) -> None: self.db_path = db_path self._lock = threading.RLock() self._initialized = False + self._integrity_lock = threading.Lock() + self._integrity_checked_at = 0.0 + self._integrity_ready = False + self._integrity_signature: tuple[int, int, int, int] | None = None + + def is_ready(self) -> bool: + """Check SQLite integrity and write headroom without mutating business data.""" + + try: + if not self.db_path.is_file(): + return False + if not os.access(self.db_path, os.R_OK | os.W_OK): + return False + if not storage_is_writable(self.db_path.parent): + return False + file_stat = self.db_path.stat() + with self.db_path.open("rb") as database: + if database.read(16) != b"SQLite format 3\0": + return False + signature = (file_stat.st_dev, file_stat.st_ino, file_stat.st_size, file_stat.st_mtime_ns) + return self._integrity_check_is_ready(signature) + except (OSError, sqlite3.Error): + return False + + def _integrity_check_is_ready(self, signature: tuple[int, int, int, int]) -> bool: + now = time.monotonic() + with self._integrity_lock: + if ( + self._integrity_ready + and self._integrity_signature == signature + and now - self._integrity_checked_at < _DATABASE_INTEGRITY_CACHE_SECONDS + ): + return True + connection: sqlite3.Connection | None = None + try: + database_uri = f"{self.db_path.resolve().as_uri()}?mode=ro" + connection = sqlite3.connect(database_uri, uri=True, timeout=0.25) + connection.execute("PRAGMA query_only=ON") + result = connection.execute("PRAGMA quick_check(1)").fetchone() + current_schema = connection.execute( + "SELECT 1 FROM schema_migrations WHERE version = ? LIMIT 1", + (_SCHEMA_VERSION,), + ).fetchone() + connection.execute("SELECT 1 FROM users LIMIT 1").fetchone() + ready = ( + result is not None + and str(result[0]).lower() == "ok" + and current_schema is not None + ) + except (OSError, sqlite3.Error): + ready = False + finally: + if connection is not None: + with suppress(sqlite3.Error): + connection.close() + self._integrity_ready = ready + self._integrity_signature = signature + self._integrity_checked_at = time.monotonic() + return ready def initialize(self) -> None: with self._lock: diff --git a/src/picgen/main.py b/src/picgen/main.py index 20533ce..330d92a 100644 --- a/src/picgen/main.py +++ b/src/picgen/main.py @@ -31,6 +31,7 @@ error_alert_notifications_enabled, send_error_alert_notification, ) +from .readiness import ReadinessProbe from .redaction import redact_sensitive_text from .routes import create_router, drain_notification_tasks from .storage import prune_old_outputs @@ -166,6 +167,8 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: ) app.state.settings = resolved_settings app.state.auth_store = auth_store + app.state.readiness_probe = ReadinessProbe() + app.state.readiness_limiter = anyio.CapacityLimiter(1) # add_middleware() prepends, so the LAST addition runs OUTERMOST. Runtime # order (outer → inner): CORS → RequestId → SecurityHeaders → RateLimit → diff --git a/src/picgen/readiness.py b/src/picgen/readiness.py new file mode 100644 index 0000000..d6fe10c --- /dev/null +++ b/src/picgen/readiness.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +import threading +import time +from dataclasses import dataclass +from pathlib import Path + +from .auth import AuthStore +from .storage import storage_is_writable + +_READINESS_CACHE_SECONDS = 5.0 + + +@dataclass(frozen=True) +class StorageReadiness: + storage_writable: bool + database_writable: bool + + +class ReadinessProbe: + """Cache durable storage probes to bound health-check write amplification.""" + + def __init__(self, cache_seconds: float = _READINESS_CACHE_SECONDS) -> None: + self._cache_seconds = cache_seconds + self._lock = threading.Lock() + self._checked_at = 0.0 + self._cached: StorageReadiness | None = None + + def check( + self, + *, + outputs_dir: Path, + auth_enabled: bool, + auth_store: AuthStore, + ) -> StorageReadiness: + now = time.monotonic() + with self._lock: + if self._cached is not None and now - self._checked_at < self._cache_seconds: + return self._cached + storage_writable = storage_is_writable(outputs_dir) + try: + database_writable = not auth_enabled or auth_store.is_ready() + except Exception: + database_writable = False + result = StorageReadiness( + storage_writable=storage_writable, + database_writable=database_writable, + ) + self._cached = result + self._checked_at = time.monotonic() + return result diff --git a/src/picgen/routes.py b/src/picgen/routes.py index e7f9a10..c7f3729 100644 --- a/src/picgen/routes.py +++ b/src/picgen/routes.py @@ -22,7 +22,7 @@ from urllib.parse import urlsplit import anyio -from fastapi import APIRouter, Body, Depends, Request +from fastapi import APIRouter, Body, Depends, Request, Response from fastapi.responses import FileResponse, JSONResponse from . import __version__ @@ -63,6 +63,7 @@ send_password_reset_request_notification, smtp_notifications_enabled, ) +from .readiness import ReadinessProbe from .recipes import list_prompt_recipes, recipe_public_dict from .redaction import redact_sensitive_text from .restricted_destinations import ( @@ -1954,20 +1955,29 @@ async def health() -> HealthResponse: @router.get("/api/ready", response_model=ReadinessResponse) async def ready( + request: Request, + response: Response, settings: Settings = Depends(get_settings), client: UpstreamClient = Depends(get_client), + auth_store: AuthStore = Depends(get_auth_store), ) -> ReadinessResponse: - storage_ok = True - try: - settings.outputs_dir.mkdir(parents=True, exist_ok=True) - probe = settings.outputs_dir / ".ready" - probe.write_text("ok") - probe.unlink(missing_ok=True) - except Exception: - storage_ok = False + readiness_probe: ReadinessProbe = request.app.state.readiness_probe + readiness_limiter: anyio.CapacityLimiter = request.app.state.readiness_limiter + storage = await anyio.to_thread.run_sync( + lambda: readiness_probe.check( + outputs_dir=settings.outputs_dir, + auth_enabled=settings.auth_enabled, + auth_store=auth_store, + ), + limiter=readiness_limiter, + ) + ready_ok = storage.storage_writable and storage.database_writable and client is not None + if not ready_ok: + response.status_code = HTTPStatus.SERVICE_UNAVAILABLE return ReadinessResponse( - ok=storage_ok and client is not None, - storage_writable=storage_ok, + ok=ready_ok, + storage_writable=storage.storage_writable, + database_writable=storage.database_writable, upstream_client_ready=client is not None, version=__version__, ) diff --git a/src/picgen/schemas.py b/src/picgen/schemas.py index 7921673..be505cc 100644 --- a/src/picgen/schemas.py +++ b/src/picgen/schemas.py @@ -681,6 +681,7 @@ class HealthResponse(BaseModel): class ReadinessResponse(BaseModel): ok: bool storage_writable: bool + database_writable: bool upstream_client_ready: bool version: str diff --git a/src/picgen/storage.py b/src/picgen/storage.py index c03f2e3..2155c63 100644 --- a/src/picgen/storage.py +++ b/src/picgen/storage.py @@ -39,6 +39,38 @@ "image/webp": "WEBP", } MAX_EXACT_IMAGE_PIXELS = 8_294_400 +_READINESS_MIN_FREE_BYTES = 16 * 1024 * 1024 +_READINESS_PROBE_BYTES = 4 * 1024 + + +def storage_is_writable(path: Path) -> bool: + """Verify free-space headroom and a durable adjacent temporary write.""" + + probe_path: Path | None = None + write_succeeded = False + try: + if not path.is_dir() or not os.access(path, os.W_OK | os.X_OK): + return False + filesystem = os.statvfs(path) + block_size = filesystem.f_frsize or filesystem.f_bsize + if filesystem.f_blocks > 0 and filesystem.f_bavail * block_size < _READINESS_MIN_FREE_BYTES: + return False + if filesystem.f_files > 0 and filesystem.f_favail < 1: + return False + with tempfile.NamedTemporaryFile(prefix=".picgen-ready-", dir=path, delete=False) as probe: + probe_path = Path(probe.name) + probe.write(b"\0" * _READINESS_PROBE_BYTES) + probe.flush() + os.fsync(probe.fileno()) + write_succeeded = True + except OSError: + write_succeeded = False + if probe_path is not None: + try: + probe_path.unlink() + except OSError: + return False + return write_succeeded def extension_for_mime(image_mime: str) -> str: diff --git a/static/app.js b/static/app.js index 06121be..83e1969 100644 --- a/static/app.js +++ b/static/app.js @@ -4,14 +4,14 @@ import { chooseLogoPlacement, createLogoPreservationDiagnostic, scaleLogoDetectionPlacements, -} from "./logo-placement.mjs?v=0.1.66" +} from "./logo-placement.mjs?v=0.1.67" import { DEFAULT_RESPONSES_MODEL, RESPONSES_MODEL_STORAGE_VERSION, RESPONSES_REASONING_STORAGE_VERSION, migrateStoredResponsesReasoningSettings, migrateStoredResponsesSettings, -} from "./responses-settings.mjs?v=0.1.66" +} from "./responses-settings.mjs?v=0.1.67" const RESPONSES_REASONING_EFFORTS = new Set(["low", "medium", "high", "xhigh", "max", "ultra"]) const DEFAULT_RESPONSES_REASONING_EFFORT = "xhigh" diff --git a/static/index.html b/static/index.html index d69dc91..602cd24 100644 --- a/static/index.html +++ b/static/index.html @@ -5,7 +5,7 @@ PicGen Console - +
@@ -1065,7 +1065,7 @@

管理员高级设置

@@ -1375,6 +1375,6 @@

图片预览

- + diff --git a/tests/test_api.py b/tests/test_api.py index 1f46ef1..f105e9e 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -67,8 +67,22 @@ def test_health_endpoint_reports_ok(make_client): assert response.json() == {"ok": True} -def test_ready_endpoint_reports_dependencies(make_client): +def test_health_endpoint_does_not_probe_storage_or_database(make_client, monkeypatch): + from picgen.auth import AuthStore + + def fail_probe(*_args): + raise AssertionError("liveness must not probe dependencies") + + monkeypatch.setattr("picgen.readiness.storage_is_writable", fail_probe) + monkeypatch.setattr(AuthStore, "is_ready", fail_probe) client, _, _ = make_client() + + assert client.get("/api/health").json() == {"ok": True} + + +def test_ready_endpoint_reports_dependencies(make_client): + client, _, settings = make_client() + settings.outputs_dir.mkdir(parents=True) response = client.get("/api/ready") assert response.status_code == 200 payload = response.json() @@ -77,10 +91,65 @@ def test_ready_endpoint_reports_dependencies(make_client): expected_version = version_line.split('"', 2)[1] assert payload["ok"] is True assert payload["storage_writable"] is True + assert payload["database_writable"] is True assert payload["upstream_client_ready"] is True assert payload["version"] == expected_version +def test_ready_endpoint_checks_enabled_database(make_client, settings_factory): + client, _, _ = make_client(settings=settings_factory(auth_enabled=True)) + + with client: + response = client.get("/api/ready") + + assert response.status_code == 200 + assert response.json()["database_writable"] is True + + +def test_ready_endpoint_returns_503_when_database_storage_is_unwritable( + make_client, settings_factory, monkeypatch +): + from picgen.auth import AuthStore + + monkeypatch.setattr(AuthStore, "is_ready", lambda _self: False, raising=False) + client, _, settings = make_client(settings=settings_factory(auth_enabled=True)) + settings.outputs_dir.mkdir(parents=True) + + response = client.get("/api/ready") + + assert response.status_code == 503 + assert response.json()["ok"] is False + assert response.json()["database_writable"] is False + + +def test_ready_endpoint_returns_503_when_output_storage_is_full(make_client, monkeypatch): + monkeypatch.setattr("picgen.readiness.storage_is_writable", lambda _path: False) + client, _, _ = make_client() + + response = client.get("/api/ready") + + assert response.status_code == 503 + assert response.json()["ok"] is False + assert response.json()["storage_writable"] is False + + +def test_ready_endpoint_caches_storage_probe(make_client, monkeypatch): + probe_calls = 0 + + def probe(_path): + nonlocal probe_calls + probe_calls += 1 + return True + + monkeypatch.setattr("picgen.readiness.storage_is_writable", probe) + client, _, _ = make_client() + + assert client.get("/api/ready").status_code == 200 + assert client.get("/api/ready").status_code == 200 + assert probe_calls == 1 + assert client.app.state.readiness_limiter.total_tokens == 1 + + def test_config_reports_api_key_presence_without_leaking_value(make_client, settings_factory): settings = settings_factory( default_api_key="sk-secret", diff --git a/tests/test_auth.py b/tests/test_auth.py index 830ab98..f7fe3ef 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -15,6 +15,95 @@ USER_PASSWORD = "correct horse battery" +def test_auth_store_readiness_checks_sqlite_without_changing_database(tmp_path: Path): + from picgen.auth import AuthStore + + db_path = tmp_path / "auth.sqlite3" + store = AuthStore(db_path) + store.initialize() + before = db_path.read_bytes() + + assert store.is_ready() is True + assert db_path.read_bytes() == before + assert list(tmp_path.glob(".picgen-ready-*")) == [] + + +def test_auth_store_readiness_fails_when_database_directory_cannot_be_written(tmp_path: Path, monkeypatch): + from picgen.auth import AuthStore + + store = AuthStore(tmp_path / "auth.sqlite3") + store.initialize() + + monkeypatch.setattr("picgen.auth.storage_is_writable", lambda _path: False) + + assert store.is_ready() is False + + +def test_auth_store_readiness_rejects_corrupt_database(tmp_path: Path): + from picgen.auth import AuthStore + + db_path = tmp_path / "auth.sqlite3" + db_path.write_bytes(b"not a sqlite database") + + assert AuthStore(db_path).is_ready() is False + + +def test_auth_store_readiness_rejects_uninitialized_sqlite_database(tmp_path: Path): + from picgen.auth import AuthStore + + db_path = tmp_path / "auth.sqlite3" + with sqlite3.connect(db_path) as connection: + connection.execute("CREATE TABLE unrelated (value TEXT)") + + assert AuthStore(db_path).is_ready() is False + + +def test_auth_store_readiness_rejects_corrupt_business_table_page(tmp_path: Path): + from picgen.auth import AuthStore + + db_path = tmp_path / "auth.sqlite3" + store = AuthStore(db_path) + store.initialize() + with sqlite3.connect(db_path) as connection: + connection.execute("CREATE TABLE readiness_data (value TEXT NOT NULL)") + connection.execute("INSERT INTO readiness_data VALUES ('ok')") + root_page = int( + connection.execute( + "SELECT rootpage FROM sqlite_schema WHERE name = 'readiness_data'" + ).fetchone()[0] + ) + with sqlite3.connect(db_path) as connection: + connection.execute("PRAGMA wal_checkpoint(TRUNCATE)") + page_size_raw = int.from_bytes(db_path.read_bytes()[16:18], "big") + page_size = 65_536 if page_size_raw == 1 else page_size_raw + assert root_page > 1 + with db_path.open("r+b") as database: + database.seek((root_page - 1) * page_size) + database.write(b"\0") + + assert store.is_ready() is False + + +def test_auth_store_readiness_caches_unchanged_database_integrity(tmp_path: Path, monkeypatch): + from picgen.auth import AuthStore + + store = AuthStore(tmp_path / "auth.sqlite3") + store.initialize() + real_connect = sqlite3.connect + connection_count = 0 + + def tracked_connect(*args, **kwargs): + nonlocal connection_count + connection_count += 1 + return real_connect(*args, **kwargs) + + monkeypatch.setattr("picgen.auth.sqlite3.connect", tracked_connect) + + assert store.is_ready() is True + assert store.is_ready() is True + assert connection_count == 1 + + def test_auth_required_blocks_generation(make_client, settings_factory): settings = settings_factory(auth_enabled=True, default_api_key="sk-test") client, fake, _ = make_client(settings=settings) diff --git a/tests/test_static_assets.py b/tests/test_static_assets.py index 0a41a42..d2eb506 100644 --- a/tests/test_static_assets.py +++ b/tests/test_static_assets.py @@ -13,7 +13,7 @@ def test_legacy_responses_model_storage_is_migrated_once() -> None: settings_js = (ROOT_DIR / "static" / "responses-settings.mjs").read_text(encoding="utf-8") assert 'const DEPRECATED_RESPONSES_MODELS = new Set(["gpt-5.4"])' in app_js - assert 'from "./responses-settings.mjs?v=0.1.66"' in app_js + assert 'from "./responses-settings.mjs?v=0.1.67"' in app_js assert 'const LEGACY_DEFAULT_RESPONSES_MODEL = "gpt-5.5"' in settings_js assert "const RESPONSES_MODEL_STORAGE_VERSION = 4" in settings_js assert "function migrateStoredResponsesSettings" in settings_js @@ -38,7 +38,7 @@ def test_logo_overlay_uses_uploaded_asset_without_ai_guidance() -> None: assert 'const COMPANY_LOGO_URL = "6renyou.png"' in app_js assert "composeLogoOverlayForCandidates" in app_js assert "createOfficialLogoCanvas" in app_js - assert 'from "./logo-placement.mjs?v=0.1.66"' in app_js + assert 'from "./logo-placement.mjs?v=0.1.67"' in app_js assert "chooseLogoPlacement" in app_js assert "calculateLogoPlacementScore" in app_js assert "calculateOfficialLogoPixelMatch" in app_js @@ -1808,6 +1808,10 @@ def test_docker_packaging_excludes_local_env_and_persists_container_data() -> No assert "apt-get" not in dockerfile assert "curl" not in dockerfile assert "urllib.request.urlopen" in dockerfile + assert "/api/ready" in dockerfile + assert "/api/ready" in compose + assert "urlopen('http://127.0.0.1:%s/api/health'" not in dockerfile + assert "urlopen('http://127.0.0.1:%s/api/health'" not in compose assert "uv sync --frozen --no-dev" in dockerfile assert '"--workers", "1"' in dockerfile assert '"--workers", "2"' not in dockerfile diff --git a/tests/test_storage.py b/tests/test_storage.py index 2d9c73e..b064635 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -5,6 +5,7 @@ from http import HTTPStatus from io import BytesIO from pathlib import Path +from types import SimpleNamespace import pytest from PIL import Image @@ -20,6 +21,7 @@ resolve_storage_path, sanitize_filename, save_output_image, + storage_is_writable, ) @@ -37,6 +39,45 @@ def _oriented_jpeg_bytes(width: int, height: int, orientation: int) -> bytes: return output.getvalue() +def test_storage_is_writable_fsyncs_and_cleans_up_probe(tmp_path: Path) -> None: + assert storage_is_writable(tmp_path) is True + assert list(tmp_path.glob(".picgen-ready-*")) == [] + + +@pytest.mark.parametrize( + ("blocks_available", "inodes_available"), + ((0, 1), (4096, 0)), +) +def test_storage_is_writable_rejects_exhausted_filesystem( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + blocks_available: int, + inodes_available: int, +) -> None: + filesystem = SimpleNamespace( + f_blocks=1, + f_bavail=blocks_available, + f_frsize=4096, + f_bsize=4096, + f_files=1, + f_favail=inodes_available, + ) + monkeypatch.setattr("picgen.storage.os.statvfs", lambda _path: filesystem) + + assert storage_is_writable(tmp_path) is False + assert list(tmp_path.glob(".picgen-ready-*")) == [] + + +def test_storage_is_writable_cleans_up_after_fsync_failure(tmp_path: Path, monkeypatch) -> None: + def fail_fsync(_file_descriptor: int) -> None: + raise OSError("simulated disk failure") + + monkeypatch.setattr("picgen.storage.os.fsync", fail_fsync) + + assert storage_is_writable(tmp_path) is False + assert list(tmp_path.glob(".picgen-ready-*")) == [] + + def test_masked_edit_composite_preserves_every_pixel_outside_transparent_mask() -> None: source = Image.new("RGBA", (4, 4), (220, 30, 30, 255)) generated = Image.new("RGBA", (4, 4), (20, 80, 220, 255)) diff --git a/uv.lock b/uv.lock index bd579df..1c48f64 100644 --- a/uv.lock +++ b/uv.lock @@ -340,7 +340,7 @@ wheels = [ [[package]] name = "picgen" -version = "0.1.66" +version = "0.1.67" source = { editable = "." } dependencies = [ { name = "anyio" },