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 @@  -## 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 @@