Spaces:
Running
Running
gpt-image-playground deploy commited on
Commit ·
ecb6084
1
Parent(s): 5166446
Deploy 6590d91 to Docker Space
Browse files- .env.example +7 -0
- .github/workflows/ci.yml +18 -6
- Dockerfile +5 -1
- README.md +17 -14
- docker-compose.postgres.yml +2 -17
- docker-compose.yml +8 -2
- docs/deployment/huggingface-space-free.md +1 -1
- docs/reviews/CR-DEPLOYMENT-HARDENING-2026-07-27.md +38 -0
- public/hf-space-deploy-marker.json +3 -3
- scripts/command-center.test.mjs +170 -5
- scripts/deploy-hf-space.mjs +5 -4
- scripts/deploy-hf-space.test.mjs +87 -0
- scripts/deploy-local.mjs +170 -22
- scripts/docker-build-context.test.mjs +27 -6
- scripts/docker-entrypoint.mjs +34 -0
- scripts/docker-entrypoint.test.mjs +39 -0
- scripts/smoke-hf-space-memory.mjs +9 -2
- scripts/smoke-hf-space-memory.test.mjs +34 -0
- skills/gpt-image-playground-agent/SKILL.md +1 -1
- src/app/api/deploy-marker/route.ts +1 -1
.env.example
CHANGED
|
@@ -151,6 +151,11 @@ OPENAI_API_BASE_URL=
|
|
| 151 |
# 可选:给网页加一个访问码。公网部署时建议一定要设置。
|
| 152 |
APP_PASSWORD=
|
| 153 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
# 可选:给 /api/agent/* 使用的 Bearer token。公网或内网共享部署时建议设置。
|
| 155 |
# AGENT_API_TOKEN=
|
| 156 |
|
|
@@ -158,6 +163,8 @@ APP_PASSWORD=
|
|
| 158 |
# AGENT_STATE_BACKEND=sqlite
|
| 159 |
# AGENT_SQLITE_PATH=generated-images/.agent-state/agent.sqlite
|
| 160 |
# AGENT_DATABASE_URL=postgres://gpt_image:<database-password>@localhost:5432/gpt_image_playground
|
|
|
|
|
|
|
| 161 |
# AGENT_REQUEST_LEASE_MS=600000
|
| 162 |
# AGENT_REQUEST_TTL_SECONDS=86400
|
| 163 |
|
|
|
|
| 151 |
# 可选:给网页加一个访问码。公网部署时建议一定要设置。
|
| 152 |
APP_PASSWORD=
|
| 153 |
|
| 154 |
+
# Compose 默认只发布到 127.0.0.1:4783。需要局域网或公网访问时,在 shell 或 Compose 的 .env
|
| 155 |
+
# 文件显式设置非回环地址,并同时在 .env.local 设置上面的 APP_PASSWORD;未设置访问码时容器会拒绝启动。
|
| 156 |
+
# GIP_BIND_HOST=0.0.0.0
|
| 157 |
+
# GIP_PORT=4783
|
| 158 |
+
|
| 159 |
# 可选:给 /api/agent/* 使用的 Bearer token。公网或内网共享部署时建议设置。
|
| 160 |
# AGENT_API_TOKEN=
|
| 161 |
|
|
|
|
| 163 |
# AGENT_STATE_BACKEND=sqlite
|
| 164 |
# AGENT_SQLITE_PATH=generated-images/.agent-state/agent.sqlite
|
| 165 |
# AGENT_DATABASE_URL=postgres://gpt_image:<database-password>@localhost:5432/gpt_image_playground
|
| 166 |
+
# docker-compose.postgres.yml 从 shell 或 Compose 的 .env 读取此 Secret,不要把真实值写进 .env.local 或仓库文件。
|
| 167 |
+
# GPT_IMAGE_POSTGRES_PASSWORD=
|
| 168 |
# AGENT_REQUEST_LEASE_MS=600000
|
| 169 |
# AGENT_REQUEST_TTL_SECONDS=86400
|
| 170 |
|
.github/workflows/ci.yml
CHANGED
|
@@ -96,22 +96,34 @@ jobs:
|
|
| 96 |
persist-credentials: false
|
| 97 |
|
| 98 |
- name: Build production image
|
| 99 |
-
run: docker build --tag gpt-image-playground-customer:ci .
|
| 100 |
|
| 101 |
- name: Start production container
|
| 102 |
-
run:
|
|
|
|
|
|
|
|
|
|
| 103 |
|
| 104 |
- name: Verify production endpoint
|
| 105 |
shell: bash
|
| 106 |
run: |
|
| 107 |
set -euo pipefail
|
| 108 |
-
for attempt in {1..
|
| 109 |
-
|
|
|
|
| 110 |
node --input-type=module -e "import { readFileSync } from 'node:fs'; const response = JSON.parse(readFileSync('/tmp/auth-status.json', 'utf8')); if (response.passwordRequired !== false) throw new Error('Expected the CI container auth-status endpoint to report passwordRequired=false.');"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
exit 0
|
| 112 |
fi
|
| 113 |
-
printf 'Waiting for auth-status endpoint (attempt %s/
|
| 114 |
-
|
|
|
|
|
|
|
| 115 |
done
|
| 116 |
docker logs gpt-image-playground-customer-ci
|
| 117 |
exit 1
|
|
|
|
| 96 |
persist-credentials: false
|
| 97 |
|
| 98 |
- name: Build production image
|
| 99 |
+
run: docker build --build-arg VCS_REF="${GITHUB_SHA}" --tag gpt-image-playground-customer:ci .
|
| 100 |
|
| 101 |
- name: Start production container
|
| 102 |
+
run: >-
|
| 103 |
+
docker run --detach --name gpt-image-playground-customer-ci
|
| 104 |
+
--env GIP_COMPOSE_DEPLOYMENT=true --env GIP_BIND_HOST=127.0.0.1
|
| 105 |
+
--publish 127.0.0.1:4783:4783 gpt-image-playground-customer:ci
|
| 106 |
|
| 107 |
- name: Verify production endpoint
|
| 108 |
shell: bash
|
| 109 |
run: |
|
| 110 |
set -euo pipefail
|
| 111 |
+
for attempt in {1..120}; do
|
| 112 |
+
health_status="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}missing{{end}}' gpt-image-playground-customer-ci)"
|
| 113 |
+
if [[ "$health_status" == "healthy" ]] && curl --fail --silent --max-time 5 http://127.0.0.1:4783/api/auth-status >/tmp/auth-status.json; then
|
| 114 |
node --input-type=module -e "import { readFileSync } from 'node:fs'; const response = JSON.parse(readFileSync('/tmp/auth-status.json', 'utf8')); if (response.passwordRequired !== false) throw new Error('Expected the CI container auth-status endpoint to report passwordRequired=false.');"
|
| 115 |
+
image_revision="$(docker image inspect --format '{{ index .Config.Labels "org.opencontainers.image.revision" }}' gpt-image-playground-customer:ci)"
|
| 116 |
+
if [[ "$image_revision" != "$GITHUB_SHA" ]]; then
|
| 117 |
+
printf 'Image revision mismatch: expected %s, received %s\n' "$GITHUB_SHA" "$image_revision"
|
| 118 |
+
docker logs gpt-image-playground-customer-ci
|
| 119 |
+
exit 1
|
| 120 |
+
fi
|
| 121 |
exit 0
|
| 122 |
fi
|
| 123 |
+
printf 'Waiting for healthy production container and auth-status endpoint (attempt %s/120, health=%s)\n' "$attempt" "$health_status"
|
| 124 |
+
if (( attempt < 120 )); then
|
| 125 |
+
sleep 1
|
| 126 |
+
fi
|
| 127 |
done
|
| 128 |
docker logs gpt-image-playground-customer-ci
|
| 129 |
exit 1
|
Dockerfile
CHANGED
|
@@ -18,19 +18,23 @@ RUN npm run build
|
|
| 18 |
FROM node:26-alpine AS runner
|
| 19 |
WORKDIR /app
|
| 20 |
ARG NEXT_PUBLIC_IMAGE_STORAGE_MODE=fs
|
|
|
|
| 21 |
ENV NODE_ENV=production
|
| 22 |
ENV NEXT_TELEMETRY_DISABLED=1
|
| 23 |
ENV NEXT_PUBLIC_IMAGE_STORAGE_MODE=${NEXT_PUBLIC_IMAGE_STORAGE_MODE}
|
| 24 |
ENV PORT=4783
|
| 25 |
ENV HOSTNAME=0.0.0.0
|
|
|
|
| 26 |
|
| 27 |
COPY --from=builder --chown=node:node /app/.next/standalone ./
|
| 28 |
COPY --from=builder --chown=node:node /app/.next/static ./.next/static
|
| 29 |
COPY --from=builder --chown=node:node /app/public ./public
|
| 30 |
COPY --from=builder --chown=node:node /app/node_modules/next/dist/compiled/next-server ./node_modules/next/dist/compiled/next-server
|
|
|
|
| 31 |
|
| 32 |
RUN mkdir -p /app/generated-images && chown node:node /app/generated-images
|
| 33 |
USER node
|
| 34 |
|
| 35 |
EXPOSE 4783
|
| 36 |
-
CMD ["node", "
|
|
|
|
|
|
| 18 |
FROM node:26-alpine AS runner
|
| 19 |
WORKDIR /app
|
| 20 |
ARG NEXT_PUBLIC_IMAGE_STORAGE_MODE=fs
|
| 21 |
+
ARG VCS_REF=unknown
|
| 22 |
ENV NODE_ENV=production
|
| 23 |
ENV NEXT_TELEMETRY_DISABLED=1
|
| 24 |
ENV NEXT_PUBLIC_IMAGE_STORAGE_MODE=${NEXT_PUBLIC_IMAGE_STORAGE_MODE}
|
| 25 |
ENV PORT=4783
|
| 26 |
ENV HOSTNAME=0.0.0.0
|
| 27 |
+
LABEL org.opencontainers.image.revision=$VCS_REF
|
| 28 |
|
| 29 |
COPY --from=builder --chown=node:node /app/.next/standalone ./
|
| 30 |
COPY --from=builder --chown=node:node /app/.next/static ./.next/static
|
| 31 |
COPY --from=builder --chown=node:node /app/public ./public
|
| 32 |
COPY --from=builder --chown=node:node /app/node_modules/next/dist/compiled/next-server ./node_modules/next/dist/compiled/next-server
|
| 33 |
+
COPY --from=builder --chown=node:node /app/scripts/docker-entrypoint.mjs ./scripts/docker-entrypoint.mjs
|
| 34 |
|
| 35 |
RUN mkdir -p /app/generated-images && chown node:node /app/generated-images
|
| 36 |
USER node
|
| 37 |
|
| 38 |
EXPOSE 4783
|
| 39 |
+
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 CMD ["node", "-e", "const port = process.env.PORT || '4783'; fetch(`http://127.0.0.1:${port}/api/auth-status`, { signal: AbortSignal.timeout(4000) }).then((response) => process.exit(response.ok ? 0 : 1)).catch(() => process.exit(1));"]
|
| 40 |
+
CMD ["node", "scripts/docker-entrypoint.mjs"]
|
README.md
CHANGED
|
@@ -12,7 +12,7 @@ app_port: 4783
|
|
| 12 |
本地 AI 图片创作工作台,面向中文内容运营、设计草图和自动化生图流程。支持 `gpt-image-2`、OpenAI 兼容图片接口、文生图、图生图、遮罩编辑、批量任务、历史复用、费用追踪和 Agent API。
|
| 13 |
|
| 14 |
<p align="center">
|
| 15 |
-
<img src="https://raw.githubusercontent.com/MisonL/gpt-image-playground-customer/
|
| 16 |
</p>
|
| 17 |
|
| 18 |
## 快速开始
|
|
@@ -30,10 +30,10 @@ npm run first-run -- --base-url https://your-space.hf.space
|
|
| 30 |
npm run first-run -- --json --base-url https://your-space.hf.space
|
| 31 |
```
|
| 32 |
|
| 33 |
-
本地服务推荐用
|
| 34 |
|
| 35 |
```bash
|
| 36 |
-
|
| 37 |
```
|
| 38 |
|
| 39 |
打开:
|
|
@@ -83,13 +83,13 @@ start-windows.bat
|
|
| 83 |
遮罩编辑示例:
|
| 84 |
|
| 85 |
<p align="center">
|
| 86 |
-
<img src="https://raw.githubusercontent.com/MisonL/gpt-image-playground-customer/
|
| 87 |
</p>
|
| 88 |
|
| 89 |
历史与费用示例:
|
| 90 |
|
| 91 |
<p align="center">
|
| 92 |
-
<img src="https://raw.githubusercontent.com/MisonL/gpt-image-playground-customer/
|
| 93 |
</p>
|
| 94 |
|
| 95 |
## 配置
|
|
@@ -98,6 +98,7 @@ start-windows.bat
|
|
| 98 |
|
| 99 |
| 场景 | 变量 | 说明 |
|
| 100 |
| ------------------ | --------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
|
|
| 101 |
| 默认上游 | `OPENAI_API_KEY`、`OPENAI_API_BASE_URL` | 服务端默认 OpenAI 或兼容接口配置。页面 `API 设置` 优先级更高。 |
|
| 102 |
| 上游代理 | `OPENAI_UPSTREAM_PROXY_URL`、`OPENAI_CHANNEL_N_PROXY_URL` | 可选。只用于服务端到图片上游的出站请求;渠道级地址优先于全局地址。仅接受无认证、无路径、无查询参数和无片段的 `http://` / `https://` 根代理地址,不支持 SOCKS。运行态和 Agent 诊断只公开是否启用及协议,不公开代理主机或端口。 |
|
| 103 |
| 页面访问码 | `APP_PASSWORD` | 设置后访问页面和受保护图片需要访问码。公网部署建议开启。 |
|
|
@@ -316,16 +317,16 @@ node skills/gpt-image-playground-agent/scripts/diagnose-request.mjs \
|
|
| 316 |
|
| 317 |
## Docker 与部署
|
| 318 |
|
| 319 |
-
默认 Compose 使用 SQLite 状态库和本地图片目录:
|
| 320 |
|
| 321 |
```bash
|
| 322 |
-
|
| 323 |
```
|
| 324 |
|
| 325 |
-
本
|
| 326 |
|
| 327 |
```bash
|
| 328 |
-
npm run deploy:local
|
| 329 |
```
|
| 330 |
|
| 331 |
本仓库的 Compose 服务只挂载 `generated-images/`。不要用 `docker run -v "$PWD:/workspace"` 启动本地图片上游 fixture;这会把 `.git/`、`node_modules/` 和 `.next/` 暴露给 Docker Desktop 文件共享层,可能触发文件事件风暴。本地 fixture gate 使用进程内服务:
|
|
@@ -342,11 +343,13 @@ npm run docker:cleanup-fixtures
|
|
| 342 |
|
| 343 |
常见部署模式:
|
| 344 |
|
| 345 |
-
| 模式 | 命令
|
| 346 |
-
| ---------- | -----------------------------------------
|
| 347 |
-
| SQLite | `
|
| 348 |
-
| Memory | `
|
| 349 |
-
| PostgreSQL | `
|
|
|
|
|
|
|
| 350 |
|
| 351 |
图片默认保存在:
|
| 352 |
|
|
|
|
| 12 |
本地 AI 图片创作工作台,面向中文内容运营、设计草图和自动化生图流程。支持 `gpt-image-2`、OpenAI 兼容图片接口、文生图、图生图、遮罩编辑、批量任务、历史复用、费用追踪和 Agent API。
|
| 13 |
|
| 14 |
<p align="center">
|
| 15 |
+
<img src="https://raw.githubusercontent.com/MisonL/gpt-image-playground-customer/6590d91c9e6e0a3314fa16bd8e5667b2d9e855cd/readme-images/interface.jpg?v=20260608-07b596b" alt="GPT Image Playground 界面" width="900"/>
|
| 16 |
</p>
|
| 17 |
|
| 18 |
## 快速开始
|
|
|
|
| 30 |
npm run first-run -- --json --base-url https://your-space.hf.space
|
| 31 |
```
|
| 32 |
|
| 33 |
+
本地服务推荐使用带健康检查和镜像 revision 核验的部署脚本:
|
| 34 |
|
| 35 |
```bash
|
| 36 |
+
npm run deploy:local
|
| 37 |
```
|
| 38 |
|
| 39 |
打开:
|
|
|
|
| 83 |
遮罩编辑示例:
|
| 84 |
|
| 85 |
<p align="center">
|
| 86 |
+
<img src="https://raw.githubusercontent.com/MisonL/gpt-image-playground-customer/6590d91c9e6e0a3314fa16bd8e5667b2d9e855cd/readme-images/mask-creation.jpg?v=20260608-07b596b" alt="遮罩创建" width="900"/>
|
| 87 |
</p>
|
| 88 |
|
| 89 |
历史与费用示例:
|
| 90 |
|
| 91 |
<p align="center">
|
| 92 |
+
<img src="https://raw.githubusercontent.com/MisonL/gpt-image-playground-customer/6590d91c9e6e0a3314fa16bd8e5667b2d9e855cd/readme-images/history.jpg?v=20260608-07b596b" alt="历史面板" width="900"/>
|
| 93 |
</p>
|
| 94 |
|
| 95 |
## 配置
|
|
|
|
| 98 |
|
| 99 |
| 场景 | 变量 | 说明 |
|
| 100 |
| ------------------ | --------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
| 101 |
+
| Docker 监听地址 | `GIP_BIND_HOST`、`GIP_PORT` | Compose 默认仅发布到 `127.0.0.1:4783`。需要局域网或公网访问时显式设置非回环 `GIP_BIND_HOST`,并同时设置 `APP_PASSWORD`;容器会拒绝未设置访问码的非回环发布。 |
|
| 102 |
| 默认上游 | `OPENAI_API_KEY`、`OPENAI_API_BASE_URL` | 服务端默认 OpenAI 或兼容接口配置。页面 `API 设置` 优先级更高。 |
|
| 103 |
| 上游代理 | `OPENAI_UPSTREAM_PROXY_URL`、`OPENAI_CHANNEL_N_PROXY_URL` | 可选。只用于服务端到图片上游的出站请求;渠道级地址优先于全局地址。仅接受无认证、无路径、无查询参数和无片段的 `http://` / `https://` 根代理地址,不支持 SOCKS。运行态和 Agent 诊断只公开是否启用及协议,不公开代理主机或端口。 |
|
| 104 |
| 页面访问码 | `APP_PASSWORD` | 设置后访问页面和受保护图片需要访问码。公网部署建议开启。 |
|
|
|
|
| 317 |
|
| 318 |
## Docker 与部署
|
| 319 |
|
| 320 |
+
默认 Compose 使用 SQLite 状态库和本地图片目录,并且只绑定本机回环地址:
|
| 321 |
|
| 322 |
```bash
|
| 323 |
+
npm run deploy:local
|
| 324 |
```
|
| 325 |
|
| 326 |
+
部署脚本会拒绝脏工作区、重建镜像、等待 Docker healthcheck、探测真实端点,并确认容器镜像的 revision 与当前 Git 提交一致。默认模式会断言 `sqlite/fs` 生效,`--memory` 会断言 `memory/indexeddb`,`--postgres` 会断言 `postgres/fs`。若确实需要局域网访问,先在 `.env.local` 设置 `APP_PASSWORD`,再显式指定绑定地址:
|
| 327 |
|
| 328 |
```bash
|
| 329 |
+
GIP_BIND_HOST=0.0.0.0 npm run deploy:local
|
| 330 |
```
|
| 331 |
|
| 332 |
本仓库的 Compose 服务只挂载 `generated-images/`。不要用 `docker run -v "$PWD:/workspace"` 启动本地图片上游 fixture;这会把 `.git/`、`node_modules/` 和 `.next/` 暴露给 Docker Desktop 文件共享层,可能触发文件事件风暴。本地 fixture gate 使用进程内服务:
|
|
|
|
| 343 |
|
| 344 |
常见部署模式:
|
| 345 |
|
| 346 |
+
| 模式 | 命令 | 适用场景 |
|
| 347 |
+
| ---------- | ----------------------------------------- | ------------------------------------- |
|
| 348 |
+
| SQLite | `npm run deploy:local` | 本地单实例和长期本地服务。 |
|
| 349 |
+
| Memory | `npm run deploy:local -- --memory` | Hugging Face Space 或临时演示。 |
|
| 350 |
+
| PostgreSQL | `npm run deploy:local -- --postgres` | 高并发、多实例或集中状态库。 |
|
| 351 |
+
|
| 352 |
+
PostgreSQL 模式要求在运行 Compose 的 shell 或 Compose `.env` 中提供 `GPT_IMAGE_POSTGRES_PASSWORD`。不要把该 Secret 写入 `.env.local`;overlay 会显式清空直连密码变量,确保应用只读取 Docker secret 文件。
|
| 353 |
|
| 354 |
图片默认保存在:
|
| 355 |
|
docker-compose.postgres.yml
CHANGED
|
@@ -20,36 +20,21 @@ services:
|
|
| 20 |
- postgres-data:/var/lib/postgresql/data
|
| 21 |
|
| 22 |
gpt-image-playground:
|
| 23 |
-
build:
|
| 24 |
-
context: .
|
| 25 |
-
image: gpt-image-playground-customer:postgres
|
| 26 |
-
container_name: gpt-image-playground-customer
|
| 27 |
-
restart: unless-stopped
|
| 28 |
-
env_file:
|
| 29 |
-
- path: .env.local
|
| 30 |
-
required: false
|
| 31 |
environment:
|
| 32 |
-
NODE_ENV: production
|
| 33 |
-
NEXT_TELEMETRY_DISABLED: "1"
|
| 34 |
-
NEXT_PUBLIC_IMAGE_STORAGE_MODE: fs
|
| 35 |
AGENT_STATE_BACKEND: postgres
|
| 36 |
-
# Force Docker secret fields below even if .env.local contains
|
| 37 |
AGENT_DATABASE_URL: ""
|
|
|
|
| 38 |
AGENT_DB_HOST: postgres
|
| 39 |
AGENT_DB_PORT: "5432"
|
| 40 |
AGENT_DB_NAME: gpt_image_playground
|
| 41 |
AGENT_DB_USER: gpt_image
|
| 42 |
AGENT_DB_PASSWORD_FILE: /run/secrets/postgres_password
|
| 43 |
-
PORT: "4783"
|
| 44 |
secrets:
|
| 45 |
- postgres_password
|
| 46 |
depends_on:
|
| 47 |
postgres:
|
| 48 |
condition: service_healthy
|
| 49 |
-
ports:
|
| 50 |
-
- "4783:4783"
|
| 51 |
-
volumes:
|
| 52 |
-
- ./generated-images:/app/generated-images
|
| 53 |
|
| 54 |
volumes:
|
| 55 |
postgres-data:
|
|
|
|
| 20 |
- postgres-data:/var/lib/postgresql/data
|
| 21 |
|
| 22 |
gpt-image-playground:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
environment:
|
|
|
|
|
|
|
|
|
|
| 24 |
AGENT_STATE_BACKEND: postgres
|
| 25 |
+
# Force Docker secret fields below even if .env.local contains direct database credentials.
|
| 26 |
AGENT_DATABASE_URL: ""
|
| 27 |
+
AGENT_DB_PASSWORD: ""
|
| 28 |
AGENT_DB_HOST: postgres
|
| 29 |
AGENT_DB_PORT: "5432"
|
| 30 |
AGENT_DB_NAME: gpt_image_playground
|
| 31 |
AGENT_DB_USER: gpt_image
|
| 32 |
AGENT_DB_PASSWORD_FILE: /run/secrets/postgres_password
|
|
|
|
| 33 |
secrets:
|
| 34 |
- postgres_password
|
| 35 |
depends_on:
|
| 36 |
postgres:
|
| 37 |
condition: service_healthy
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
|
| 39 |
volumes:
|
| 40 |
postgres-data:
|
docker-compose.yml
CHANGED
|
@@ -4,7 +4,9 @@ services:
|
|
| 4 |
gpt-image-playground:
|
| 5 |
build:
|
| 6 |
context: .
|
| 7 |
-
|
|
|
|
|
|
|
| 8 |
container_name: gpt-image-playground-customer
|
| 9 |
restart: unless-stopped
|
| 10 |
env_file:
|
|
@@ -17,7 +19,11 @@ services:
|
|
| 17 |
AGENT_STATE_BACKEND: sqlite
|
| 18 |
NEXT_PUBLIC_IMAGE_STORAGE_MODE: fs
|
| 19 |
PORT: "4783"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
ports:
|
| 21 |
-
- "4783:4783"
|
| 22 |
volumes:
|
| 23 |
- ./generated-images:/app/generated-images
|
|
|
|
| 4 |
gpt-image-playground:
|
| 5 |
build:
|
| 6 |
context: .
|
| 7 |
+
args:
|
| 8 |
+
VCS_REF: ${GIP_IMAGE_REVISION:-unknown}
|
| 9 |
+
image: gpt-image-playground-customer:${GIP_IMAGE_TAG:-local}
|
| 10 |
container_name: gpt-image-playground-customer
|
| 11 |
restart: unless-stopped
|
| 12 |
env_file:
|
|
|
|
| 19 |
AGENT_STATE_BACKEND: sqlite
|
| 20 |
NEXT_PUBLIC_IMAGE_STORAGE_MODE: fs
|
| 21 |
PORT: "4783"
|
| 22 |
+
GIP_COMPOSE_DEPLOYMENT: "true"
|
| 23 |
+
GIP_BIND_HOST: "${GIP_BIND_HOST:-127.0.0.1}"
|
| 24 |
+
labels:
|
| 25 |
+
org.opencontainers.image.revision: "${GIP_IMAGE_REVISION:-unknown}"
|
| 26 |
ports:
|
| 27 |
+
- "${GIP_BIND_HOST:-127.0.0.1}:${GIP_PORT:-4783}:4783"
|
| 28 |
volumes:
|
| 29 |
- ./generated-images:/app/generated-images
|
docs/deployment/huggingface-space-free.md
CHANGED
|
@@ -85,7 +85,7 @@ npm run agent:doctor
|
|
| 85 |
- `status`:只读输出 git、Node、固定 Space 目标、Agent capabilities 路径和 Skill 入口。
|
| 86 |
- `doctor`:统一诊断入口,默认包含 HF Space 只读远端检查,并校验当前 npm 是否支持严格安装脚本策略、本地 `node_modules` 隐藏锁文件和直接依赖版本是否与根锁文件一致。
|
| 87 |
- `verify`:提交前基线,先核对锁文件安装脚本与 `allowScripts` 白名单、当前 npm 严格安装策略能力和已安装直接依赖,再执行测试、lint、脚本语法、构建和 `git diff --check`;需要真实 PostgreSQL gate 时加 `--postgres`。
|
| 88 |
-
- `deploy:local`:重建本地 Docker 服务并探测真实 HTTP 端点;加 `--memory` 会断言 memory/indexeddb overlay 生效。
|
| 89 |
- `deploy:space`:上传当前干净 git HEAD 到固定 HF Space,并做只读公网验证;已存在 Docker Space 根据远端元数据直接使用认证 Git 推送,其他类型才优先尝试 `hf upload`。
|
| 90 |
- `agent:doctor`:通过仓库 Skill 脚本执行只读 Agent API 契约检查,不触发真实生图。
|
| 91 |
|
|
|
|
| 85 |
- `status`:只读输出 git、Node、固定 Space 目标、Agent capabilities 路径和 Skill 入口。
|
| 86 |
- `doctor`:统一诊断入口,默认包含 HF Space 只读远端检查,并校验当前 npm 是否支持严格安装脚本策略、本地 `node_modules` 隐藏锁文件和直接依赖版本是否与根锁文件一致。
|
| 87 |
- `verify`:提交前基线,先核对锁文件安装脚本与 `allowScripts` 白名单、当前 npm 严格安装策略能力和已安装直接依赖,再执行测试、lint、脚本语法、构建和 `git diff --check`;需要真实 PostgreSQL gate 时加 `--postgres`。
|
| 88 |
+
- `deploy:local`:拒绝脏工作区后重建本地 Docker 服务,等待 healthcheck、核验镜像 revision 并探测真实 HTTP 端点;加 `--memory` 会断言 memory/indexeddb overlay 生效,加 `--postgres` 会断言 postgres/fs overlay 生效且要求通过 shell 或 Compose `.env` 提供 `GPT_IMAGE_POSTGRES_PASSWORD`。默认 Compose 只绑定 `127.0.0.1:4783`;需要非回环发布时必须显式设置 `GIP_BIND_HOST` 和 `APP_PASSWORD`。
|
| 89 |
- `deploy:space`:上传当前干净 git HEAD 到固定 HF Space,并做只读公网验证;已存在 Docker Space 根据远端元数据直接使用认证 Git 推送,其他类型才优先尝试 `hf upload`。
|
| 90 |
- `agent:doctor`:通过仓库 Skill 脚本执行只读 Agent API 契约检查,不触发真实生图。
|
| 91 |
|
docs/reviews/CR-DEPLOYMENT-HARDENING-2026-07-27.md
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 部署加固回归门禁
|
| 2 |
+
|
| 3 |
+
日期: 2026-07-27
|
| 4 |
+
|
| 5 |
+
范围: Docker 本地部署、Hugging Face Space memory smoke、PostgreSQL overlay、CI 运行时检查、部署脚本和部署文档。
|
| 6 |
+
|
| 7 |
+
## 已审查变更
|
| 8 |
+
|
| 9 |
+
- 默认 Compose 发布限制为 `127.0.0.1:4783`。
|
| 10 |
+
- 非回环 Compose 发布必须设置非空 `APP_PASSWORD`。
|
| 11 |
+
- Docker 镜像提供 OCI revision label 和 healthcheck。
|
| 12 |
+
- 本地部署校验干净的 Git revision、镜像身份、发布端口和选定的状态/存储模式。
|
| 13 |
+
- PostgreSQL overlay 在使用 Docker secret 文件前清空直连数据库凭证变量。
|
| 14 |
+
- HF Space 和本地端点轮询不会在最后一次失败后继续等待。
|
| 15 |
+
- CI 校验实际 Docker 入口点的回环分支、健康状态、端点响应和镜像 revision。
|
| 16 |
+
|
| 17 |
+
## 自动化证据
|
| 18 |
+
|
| 19 |
+
| 命令 | 退出码 | 结果 |
|
| 20 |
+
| --- | --- | --- |
|
| 21 |
+
| `npm run verify` | 0 | 版本、安装策略、依赖、测试、lint、脚本语法、生产构建和 diff 检查均通过。 |
|
| 22 |
+
| `npm run test:postgres` | 0 | 101 个测试通过,包含真实 PostgreSQL 并发和 schema 契约。 |
|
| 23 |
+
| `npm run smoke:hf-space-local` | 0 | 最新 Docker 镜像通过 memory/indexeddb 运行态和非计费 Agent 契约检查。 |
|
| 24 |
+
| `docker build --check .` | 0 | 无 Dockerfile 警告。 |
|
| 25 |
+
| `docker compose ... config --quiet` | 0 | SQLite、memory 和 PostgreSQL Compose 配置均成功渲染。 |
|
| 26 |
+
| CI 固定 digest 的 actionlint 容器 | 0 | GitHub Actions 工作流语法和语义通过 actionlint。 |
|
| 27 |
+
| 非回环 Docker 入口点检查 | 预期退出码 1 | 容器拒绝 `GIP_COMPOSE_DEPLOYMENT= TRUE `、`GIP_BIND_HOST=0.0.0.0` 且未设置 `APP_PASSWORD` 的启动。 |
|
| 28 |
+
|
| 29 |
+
## 审查证据
|
| 30 |
+
|
| 31 |
+
- CodeRabbit 审查全部已修改和未跟踪文件后未发现问题。
|
| 32 |
+
- Claude Code 使用默认模型且未传 `--model`,报告未发现 P0-P3 问题。
|
| 33 |
+
- OMP 仅识别出本地部署和 CI 的最后一次轮询延迟。两条路径均已改为仅在仍有下一次尝试时等待,并为本地探针补充回归覆盖。
|
| 34 |
+
|
| 35 |
+
## 范围边界
|
| 36 |
+
|
| 37 |
+
- 本门禁不执行计费的图片生成或编辑请求。
|
| 38 |
+
- 真实本地 Docker 和 Hugging Face Space 发布检查属于独立部署验证步骤,因为它们需要干净的已提交 revision 和实时服务状态。
|
public/hf-space-deploy-marker.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
{
|
| 2 |
"schema_version": 1,
|
| 3 |
-
"local_sha": "
|
| 4 |
-
"created_at": "2026-07-
|
| 5 |
-
"deploy_id": "
|
| 6 |
}
|
|
|
|
| 1 |
{
|
| 2 |
"schema_version": 1,
|
| 3 |
+
"local_sha": "6590d91c9e6e0a3314fa16bd8e5667b2d9e855cd",
|
| 4 |
+
"created_at": "2026-07-27T03:45:51.712Z",
|
| 5 |
+
"deploy_id": "d1603a14-8385-441d-adac-e4c5b059dde4"
|
| 6 |
}
|
scripts/command-center.test.mjs
CHANGED
|
@@ -7,7 +7,17 @@ import {
|
|
| 7 |
summarizeDockerMounts
|
| 8 |
} from './cleanup-docker-fixtures.mjs';
|
| 9 |
import { fetchJsonWithTimeout, parseJsonPayload, pickFailureOutput, runCommand } from './command-center-utils.mjs';
|
| 10 |
-
import {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
import { buildFirstRunReport, formatFirstRunText } from './first-run.mjs';
|
| 12 |
import {
|
| 13 |
buildAdminCommands,
|
|
@@ -449,7 +459,19 @@ describe('Command center scripts', () => {
|
|
| 449 |
});
|
| 450 |
|
| 451 |
it('builds deterministic local deploy compose arguments', () => {
|
| 452 |
-
assert.deepEqual(buildDockerComposeArgs(), [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 453 |
assert.deepEqual(buildDockerComposeArgs({ memory: true }), [
|
| 454 |
'compose',
|
| 455 |
'-f',
|
|
@@ -458,8 +480,29 @@ describe('Command center scripts', () => {
|
|
| 458 |
'docker-compose.memory.yml',
|
| 459 |
'up',
|
| 460 |
'-d',
|
| 461 |
-
'--build'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 462 |
]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 463 |
});
|
| 464 |
|
| 465 |
it('uses plain compose progress for diagnosable local deploy output', () => {
|
|
@@ -467,6 +510,109 @@ describe('Command center scripts', () => {
|
|
| 467 |
PATH: '/bin',
|
| 468 |
COMPOSE_PROGRESS: 'plain'
|
| 469 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 470 |
});
|
| 471 |
|
| 472 |
it('detects only the legacy fixture whole-repository Docker mount', () => {
|
|
@@ -540,14 +686,33 @@ describe('Command center scripts', () => {
|
|
| 540 |
assert.equal(buildSkippedReport('custom', container.Mounts).removed, false);
|
| 541 |
});
|
| 542 |
|
| 543 |
-
it('
|
| 544 |
assert.doesNotThrow(() => assertLocalProbeMatchesMode({ stateBackend: 'sqlite', imageStorageMode: 'fs' }));
|
|
|
|
|
|
|
|
|
|
|
|
|
| 545 |
assert.doesNotThrow(() =>
|
| 546 |
assertLocalProbeMatchesMode({ stateBackend: 'memory', imageStorageMode: 'indexeddb' }, { memory: true })
|
| 547 |
);
|
| 548 |
assert.throws(
|
| 549 |
() => assertLocalProbeMatchesMode({ stateBackend: 'sqlite', imageStorageMode: 'fs' }, { memory: true }),
|
| 550 |
-
/Memory
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 551 |
);
|
| 552 |
});
|
| 553 |
|
|
|
|
| 7 |
summarizeDockerMounts
|
| 8 |
} from './cleanup-docker-fixtures.mjs';
|
| 9 |
import { fetchJsonWithTimeout, parseJsonPayload, pickFailureOutput, runCommand } from './command-center-utils.mjs';
|
| 10 |
+
import {
|
| 11 |
+
assertDeploymentImageIdentity,
|
| 12 |
+
assertLocalProbeMatchesMode,
|
| 13 |
+
buildDeploymentImageReference,
|
| 14 |
+
buildDeploymentImageTag,
|
| 15 |
+
buildDockerComposeArgs,
|
| 16 |
+
buildDockerComposeEnv,
|
| 17 |
+
buildLocalBaseUrl,
|
| 18 |
+
parsePublishedContainerPortBindings,
|
| 19 |
+
waitForLocalEndpoints
|
| 20 |
+
} from './deploy-local.mjs';
|
| 21 |
import { buildFirstRunReport, formatFirstRunText } from './first-run.mjs';
|
| 22 |
import {
|
| 23 |
buildAdminCommands,
|
|
|
|
| 459 |
});
|
| 460 |
|
| 461 |
it('builds deterministic local deploy compose arguments', () => {
|
| 462 |
+
assert.deepEqual(buildDockerComposeArgs(), [
|
| 463 |
+
'compose',
|
| 464 |
+
'-f',
|
| 465 |
+
'docker-compose.yml',
|
| 466 |
+
'up',
|
| 467 |
+
'-d',
|
| 468 |
+
'--build',
|
| 469 |
+
'--force-recreate',
|
| 470 |
+
'--remove-orphans',
|
| 471 |
+
'--wait',
|
| 472 |
+
'--wait-timeout',
|
| 473 |
+
'120'
|
| 474 |
+
]);
|
| 475 |
assert.deepEqual(buildDockerComposeArgs({ memory: true }), [
|
| 476 |
'compose',
|
| 477 |
'-f',
|
|
|
|
| 480 |
'docker-compose.memory.yml',
|
| 481 |
'up',
|
| 482 |
'-d',
|
| 483 |
+
'--build',
|
| 484 |
+
'--force-recreate',
|
| 485 |
+
'--remove-orphans',
|
| 486 |
+
'--wait',
|
| 487 |
+
'--wait-timeout',
|
| 488 |
+
'120'
|
| 489 |
]);
|
| 490 |
+
assert.deepEqual(buildDockerComposeArgs({ postgres: true }), [
|
| 491 |
+
'compose',
|
| 492 |
+
'-f',
|
| 493 |
+
'docker-compose.yml',
|
| 494 |
+
'-f',
|
| 495 |
+
'docker-compose.postgres.yml',
|
| 496 |
+
'up',
|
| 497 |
+
'-d',
|
| 498 |
+
'--build',
|
| 499 |
+
'--force-recreate',
|
| 500 |
+
'--remove-orphans',
|
| 501 |
+
'--wait',
|
| 502 |
+
'--wait-timeout',
|
| 503 |
+
'120'
|
| 504 |
+
]);
|
| 505 |
+
assert.throws(() => buildDockerComposeArgs({ memory: true, postgres: true }), /不能同时使用/);
|
| 506 |
});
|
| 507 |
|
| 508 |
it('uses plain compose progress for diagnosable local deploy output', () => {
|
|
|
|
| 510 |
PATH: '/bin',
|
| 511 |
COMPOSE_PROGRESS: 'plain'
|
| 512 |
});
|
| 513 |
+
assert.deepEqual(
|
| 514 |
+
buildDockerComposeEnv(
|
| 515 |
+
{ PATH: '/bin' },
|
| 516 |
+
{
|
| 517 |
+
revision: '0123456789abcdef0123456789abcdef01234567',
|
| 518 |
+
imageTag: 'local-0123456789abcdef0123456789abcdef01234567'
|
| 519 |
+
}
|
| 520 |
+
),
|
| 521 |
+
{
|
| 522 |
+
PATH: '/bin',
|
| 523 |
+
COMPOSE_PROGRESS: 'plain',
|
| 524 |
+
GIP_IMAGE_REVISION: '0123456789abcdef0123456789abcdef01234567',
|
| 525 |
+
GIP_IMAGE_TAG: 'local-0123456789abcdef0123456789abcdef01234567'
|
| 526 |
+
}
|
| 527 |
+
);
|
| 528 |
+
});
|
| 529 |
+
|
| 530 |
+
it('uses immutable full-revision Docker image tags for deploys', () => {
|
| 531 |
+
const revision = '0123456789abcdef0123456789abcdef01234567';
|
| 532 |
+
assert.equal(buildDeploymentImageTag(revision), `local-${revision}`);
|
| 533 |
+
assert.equal(buildDeploymentImageReference(revision), `gpt-image-playground-customer:local-${revision}`);
|
| 534 |
+
assert.throws(() => buildDeploymentImageTag('0123456'), /40/);
|
| 535 |
+
assert.throws(() => buildDeploymentImageTag('z'.repeat(40)), /40/);
|
| 536 |
+
});
|
| 537 |
+
|
| 538 |
+
it('builds local probe URLs from the published container port mapping', () => {
|
| 539 |
+
assert.equal(buildLocalBaseUrl('127.0.0.1', '4783'), 'http://127.0.0.1:4783');
|
| 540 |
+
assert.equal(buildLocalBaseUrl('0.0.0.0', '4784'), 'http://127.0.0.1:4784');
|
| 541 |
+
assert.equal(buildLocalBaseUrl('::1', '4783'), 'http://[::1]:4783');
|
| 542 |
+
assert.deepEqual(parsePublishedContainerPortBindings('[{"HostIp":"127.0.0.1","HostPort":"4783"}]'), {
|
| 543 |
+
bindHost: '127.0.0.1',
|
| 544 |
+
hostPort: '4783',
|
| 545 |
+
baseUrl: 'http://127.0.0.1:4783'
|
| 546 |
+
});
|
| 547 |
+
assert.deepEqual(
|
| 548 |
+
parsePublishedContainerPortBindings('[{"HostIp":"::","HostPort":"4784"},{"HostIp":"0.0.0.0","HostPort":"4784"}]'),
|
| 549 |
+
{
|
| 550 |
+
bindHost: '0.0.0.0',
|
| 551 |
+
hostPort: '4784',
|
| 552 |
+
baseUrl: 'http://127.0.0.1:4784'
|
| 553 |
+
}
|
| 554 |
+
);
|
| 555 |
+
assert.throws(() => buildLocalBaseUrl('127.0.0.1', '0'), /1 到 65535/);
|
| 556 |
+
assert.throws(() => parsePublishedContainerPortBindings('not-json'), /端口映射/);
|
| 557 |
+
assert.throws(() => parsePublishedContainerPortBindings('null'), /未发布/);
|
| 558 |
+
});
|
| 559 |
+
|
| 560 |
+
it('waits only between failed local endpoint probe attempts', async () => {
|
| 561 |
+
let requestCount = 0;
|
| 562 |
+
let sleepCount = 0;
|
| 563 |
+
|
| 564 |
+
await assert.rejects(
|
| 565 |
+
waitForLocalEndpoints('http://127.0.0.1:4783', {
|
| 566 |
+
attempts: 2,
|
| 567 |
+
intervalMs: 2000,
|
| 568 |
+
fetchJson: async () => {
|
| 569 |
+
requestCount += 1;
|
| 570 |
+
throw new Error('container not ready');
|
| 571 |
+
},
|
| 572 |
+
sleep: async () => {
|
| 573 |
+
sleepCount += 1;
|
| 574 |
+
}
|
| 575 |
+
}),
|
| 576 |
+
/container not ready/
|
| 577 |
+
);
|
| 578 |
+
|
| 579 |
+
assert.equal(requestCount, 2);
|
| 580 |
+
assert.equal(sleepCount, 1);
|
| 581 |
+
});
|
| 582 |
+
|
| 583 |
+
it('requires the running Docker image and revision label to match the deployed revision', () => {
|
| 584 |
+
const deployment = {
|
| 585 |
+
revision: '0123456789abcdef0123456789abcdef01234567',
|
| 586 |
+
imageTag: 'local-0123456789abcdef0123456789abcdef01234567'
|
| 587 |
+
};
|
| 588 |
+
assert.doesNotThrow(() =>
|
| 589 |
+
assertDeploymentImageIdentity(
|
| 590 |
+
{
|
| 591 |
+
image: 'gpt-image-playground-customer:local-0123456789abcdef0123456789abcdef01234567',
|
| 592 |
+
revision: deployment.revision
|
| 593 |
+
},
|
| 594 |
+
deployment
|
| 595 |
+
)
|
| 596 |
+
);
|
| 597 |
+
assert.throws(
|
| 598 |
+
() =>
|
| 599 |
+
assertDeploymentImageIdentity(
|
| 600 |
+
{ image: 'gpt-image-playground-customer:local', revision: deployment.revision },
|
| 601 |
+
deployment
|
| 602 |
+
),
|
| 603 |
+
/镜像不匹配/
|
| 604 |
+
);
|
| 605 |
+
assert.throws(
|
| 606 |
+
() =>
|
| 607 |
+
assertDeploymentImageIdentity(
|
| 608 |
+
{
|
| 609 |
+
image: 'gpt-image-playground-customer:local-0123456789abcdef0123456789abcdef01234567',
|
| 610 |
+
revision: 'different'
|
| 611 |
+
},
|
| 612 |
+
deployment
|
| 613 |
+
),
|
| 614 |
+
/revision 不匹配/
|
| 615 |
+
);
|
| 616 |
});
|
| 617 |
|
| 618 |
it('detects only the legacy fixture whole-repository Docker mount', () => {
|
|
|
|
| 686 |
assert.equal(buildSkippedReport('custom', container.Mounts).removed, false);
|
| 687 |
});
|
| 688 |
|
| 689 |
+
it('requires local deploy probes to match the selected state and storage backend', () => {
|
| 690 |
assert.doesNotThrow(() => assertLocalProbeMatchesMode({ stateBackend: 'sqlite', imageStorageMode: 'fs' }));
|
| 691 |
+
assert.throws(
|
| 692 |
+
() => assertLocalProbeMatchesMode({ stateBackend: 'memory', imageStorageMode: 'indexeddb' }),
|
| 693 |
+
/SQLite deployment mode did not take effect/
|
| 694 |
+
);
|
| 695 |
assert.doesNotThrow(() =>
|
| 696 |
assertLocalProbeMatchesMode({ stateBackend: 'memory', imageStorageMode: 'indexeddb' }, { memory: true })
|
| 697 |
);
|
| 698 |
assert.throws(
|
| 699 |
() => assertLocalProbeMatchesMode({ stateBackend: 'sqlite', imageStorageMode: 'fs' }, { memory: true }),
|
| 700 |
+
/Memory deployment mode did not take effect/
|
| 701 |
+
);
|
| 702 |
+
assert.doesNotThrow(() =>
|
| 703 |
+
assertLocalProbeMatchesMode({ stateBackend: 'postgres', imageStorageMode: 'fs' }, { postgres: true })
|
| 704 |
+
);
|
| 705 |
+
assert.throws(
|
| 706 |
+
() => assertLocalProbeMatchesMode({ stateBackend: 'sqlite', imageStorageMode: 'fs' }, { postgres: true }),
|
| 707 |
+
/PostgreSQL deployment mode did not take effect/
|
| 708 |
+
);
|
| 709 |
+
assert.throws(
|
| 710 |
+
() =>
|
| 711 |
+
assertLocalProbeMatchesMode(
|
| 712 |
+
{ stateBackend: 'memory', imageStorageMode: 'indexeddb' },
|
| 713 |
+
{ memory: true, postgres: true }
|
| 714 |
+
),
|
| 715 |
+
/不能同时使用/
|
| 716 |
);
|
| 717 |
});
|
| 718 |
|
scripts/deploy-hf-space.mjs
CHANGED
|
@@ -353,7 +353,7 @@ export async function waitForRunning(spaceCommitSha, deployMarker, options = {})
|
|
| 353 |
lastManagementError = error instanceof Error ? error.message : String(error);
|
| 354 |
if (!isRetryableSpaceInfoReadError(error)) throw error;
|
| 355 |
log(`attempt=${attempt} management_status=unavailable error=${lastManagementError}`);
|
| 356 |
-
await sleep(intervalMs);
|
| 357 |
continue;
|
| 358 |
}
|
| 359 |
lastStage = info.runtime?.stage || 'unknown';
|
|
@@ -368,15 +368,16 @@ export async function waitForRunning(spaceCommitSha, deployMarker, options = {})
|
|
| 368 |
log(`attempt=${attempt} marker_status=not_ready error=${lastMarkerError}`);
|
| 369 |
}
|
| 370 |
}
|
| 371 |
-
await sleep(intervalMs);
|
| 372 |
}
|
| 373 |
const marker = await verifyMarker(deployMarker);
|
| 374 |
return {
|
| 375 |
stage: lastStage,
|
| 376 |
sha: lastSha,
|
| 377 |
-
management_status: '
|
|
|
|
| 378 |
service_marker_verified: true,
|
| 379 |
-
warning: `
|
| 380 |
marker
|
| 381 |
};
|
| 382 |
}
|
|
|
|
| 353 |
lastManagementError = error instanceof Error ? error.message : String(error);
|
| 354 |
if (!isRetryableSpaceInfoReadError(error)) throw error;
|
| 355 |
log(`attempt=${attempt} management_status=unavailable error=${lastManagementError}`);
|
| 356 |
+
if (attempt < attempts) await sleep(intervalMs);
|
| 357 |
continue;
|
| 358 |
}
|
| 359 |
lastStage = info.runtime?.stage || 'unknown';
|
|
|
|
| 368 |
log(`attempt=${attempt} marker_status=not_ready error=${lastMarkerError}`);
|
| 369 |
}
|
| 370 |
}
|
| 371 |
+
if (attempt < attempts) await sleep(intervalMs);
|
| 372 |
}
|
| 373 |
const marker = await verifyMarker(deployMarker);
|
| 374 |
return {
|
| 375 |
stage: lastStage,
|
| 376 |
sha: lastSha,
|
| 377 |
+
management_status: 'target_commit_not_confirmed',
|
| 378 |
+
verification_source: 'service_marker_after_management_timeout',
|
| 379 |
service_marker_verified: true,
|
| 380 |
+
warning: `Hugging Face management status did not confirm RUNNING for ${spaceCommitSha}, but the public service returned the exact one-time deploy marker; last stage=${lastStage} sha=${lastSha} marker_error=${lastMarkerError} management_error=${lastManagementError}`,
|
| 381 |
marker
|
| 382 |
};
|
| 383 |
}
|
scripts/deploy-hf-space.test.mjs
CHANGED
|
@@ -344,6 +344,93 @@ describe('HF Space deploy script', () => {
|
|
| 344 |
});
|
| 345 |
});
|
| 346 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 347 |
it('only classifies transient Hugging Face management transport errors as retryable', () => {
|
| 348 |
assert.equal(
|
| 349 |
isRetryableSpaceInfoReadError(
|
|
|
|
| 344 |
});
|
| 345 |
});
|
| 346 |
|
| 347 |
+
it('does not sleep after the final transient management status error before verifying the marker', async () => {
|
| 348 |
+
const marker = buildDeployMarker(
|
| 349 |
+
'7777777777777777777777777777777777777777',
|
| 350 |
+
new Date('2026-06-20T14:00:00.000Z'),
|
| 351 |
+
'deploy-777'
|
| 352 |
+
);
|
| 353 |
+
let sleepCount = 0;
|
| 354 |
+
|
| 355 |
+
const runtime = await waitForRunning('cccccccccccccccccccccccccccccccccccccccc', marker, {
|
| 356 |
+
attempts: 1,
|
| 357 |
+
intervalMs: 10_000,
|
| 358 |
+
readInfo: () => {
|
| 359 |
+
throw new Error('httpx.ReadTimeout: timed out while reading Space status');
|
| 360 |
+
},
|
| 361 |
+
verifyMarker: async (expected) => assertDeployMarkerMatches(marker, expected),
|
| 362 |
+
sleep: async () => {
|
| 363 |
+
sleepCount += 1;
|
| 364 |
+
},
|
| 365 |
+
log: () => {}
|
| 366 |
+
});
|
| 367 |
+
|
| 368 |
+
assert.equal(sleepCount, 0);
|
| 369 |
+
assert.deepEqual(runtime, {
|
| 370 |
+
stage: 'unknown',
|
| 371 |
+
sha: 'unknown',
|
| 372 |
+
management_status: 'target_commit_not_confirmed',
|
| 373 |
+
verification_source: 'service_marker_after_management_timeout',
|
| 374 |
+
service_marker_verified: true,
|
| 375 |
+
warning:
|
| 376 |
+
'Hugging Face management status did not confirm RUNNING for cccccccccccccccccccccccccccccccccccccccc, but the public service returned the exact one-time deploy marker; last stage=unknown sha=unknown marker_error=unknown management_error=httpx.ReadTimeout: timed out while reading Space status',
|
| 377 |
+
marker
|
| 378 |
+
});
|
| 379 |
+
});
|
| 380 |
+
|
| 381 |
+
it('accepts an exact one-time service marker when management status remains stale', async () => {
|
| 382 |
+
const marker = buildDeployMarker(
|
| 383 |
+
'8888888888888888888888888888888888888888',
|
| 384 |
+
new Date('2026-06-20T15:00:00.000Z'),
|
| 385 |
+
'deploy-888'
|
| 386 |
+
);
|
| 387 |
+
let sleepCount = 0;
|
| 388 |
+
|
| 389 |
+
const runtime = await waitForRunning('dddddddddddddddddddddddddddddddddddddddd', marker, {
|
| 390 |
+
attempts: 1,
|
| 391 |
+
intervalMs: 0,
|
| 392 |
+
readInfo: () => ({ runtime: { stage: 'RUNNING' }, sha: 'stale-space-sha' }),
|
| 393 |
+
verifyMarker: async (expected) => assertDeployMarkerMatches(marker, expected),
|
| 394 |
+
sleep: async () => {
|
| 395 |
+
sleepCount += 1;
|
| 396 |
+
},
|
| 397 |
+
log: () => {}
|
| 398 |
+
});
|
| 399 |
+
|
| 400 |
+
assert.equal(sleepCount, 0);
|
| 401 |
+
assert.deepEqual(runtime, {
|
| 402 |
+
stage: 'RUNNING',
|
| 403 |
+
sha: 'stale-space-sha',
|
| 404 |
+
management_status: 'target_commit_not_confirmed',
|
| 405 |
+
verification_source: 'service_marker_after_management_timeout',
|
| 406 |
+
service_marker_verified: true,
|
| 407 |
+
warning:
|
| 408 |
+
'Hugging Face management status did not confirm RUNNING for dddddddddddddddddddddddddddddddddddddddd, but the public service returned the exact one-time deploy marker; last stage=RUNNING sha=stale-space-sha marker_error=unknown management_error=none',
|
| 409 |
+
marker
|
| 410 |
+
});
|
| 411 |
+
});
|
| 412 |
+
|
| 413 |
+
it('rejects a stale service marker after management status times out', async () => {
|
| 414 |
+
const marker = buildDeployMarker(
|
| 415 |
+
'9999999999999999999999999999999999999999',
|
| 416 |
+
new Date('2026-06-20T16:00:00.000Z'),
|
| 417 |
+
'deploy-999'
|
| 418 |
+
);
|
| 419 |
+
const staleMarker = { ...marker, deploy_id: 'deploy-stale' };
|
| 420 |
+
|
| 421 |
+
await assert.rejects(
|
| 422 |
+
waitForRunning('eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', marker, {
|
| 423 |
+
attempts: 1,
|
| 424 |
+
intervalMs: 0,
|
| 425 |
+
readInfo: () => ({ runtime: { stage: 'RUNNING' }, sha: 'stale-space-sha' }),
|
| 426 |
+
verifyMarker: async (expected) => assertDeployMarkerMatches(staleMarker, expected),
|
| 427 |
+
sleep: async () => {},
|
| 428 |
+
log: () => {}
|
| 429 |
+
}),
|
| 430 |
+
/deploy_id mismatch/
|
| 431 |
+
);
|
| 432 |
+
});
|
| 433 |
+
|
| 434 |
it('only classifies transient Hugging Face management transport errors as retryable', () => {
|
| 435 |
assert.equal(
|
| 436 |
isRetryableSpaceInfoReadError(
|
scripts/deploy-local.mjs
CHANGED
|
@@ -4,57 +4,165 @@ import { setTimeout as delay } from 'node:timers/promises';
|
|
| 4 |
|
| 5 |
import { fetchJsonWithTimeout, isMainModule, pickFailureOutput, printJson, runCommand } from './command-center-utils.mjs';
|
| 6 |
|
| 7 |
-
const
|
|
|
|
|
|
|
|
|
|
| 8 |
const PROBE_PATHS = ['/api/auth-status', '/api/runtime-capabilities', '/api/agent/capabilities'];
|
| 9 |
const PROBE_ATTEMPTS = 30;
|
| 10 |
const PROBE_INTERVAL_MS = 2000;
|
| 11 |
const PROBE_TIMEOUT_MS = 5000;
|
| 12 |
const DOCKER_COMPOSE_TIMEOUT_MS = 10 * 60 * 1000;
|
|
|
|
|
|
|
| 13 |
|
| 14 |
export function buildDockerComposeArgs(options = {}) {
|
|
|
|
| 15 |
const files = ['-f', 'docker-compose.yml'];
|
| 16 |
if (options.memory) files.push('-f', 'docker-compose.memory.yml');
|
| 17 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
}
|
| 19 |
|
| 20 |
-
|
| 21 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
}
|
| 23 |
|
| 24 |
function parseArgs(argv) {
|
| 25 |
-
const unknown = argv.find((arg) => !['--help', '-h', '--memory', '--skip-probe'].includes(arg));
|
| 26 |
if (unknown) throw new Error(`Unknown option: ${unknown}`);
|
| 27 |
-
|
| 28 |
help: argv.includes('--help') || argv.includes('-h'),
|
| 29 |
memory: argv.includes('--memory'),
|
|
|
|
| 30 |
skipProbe: argv.includes('--skip-probe')
|
| 31 |
};
|
|
|
|
|
|
|
| 32 |
}
|
| 33 |
|
| 34 |
function printHelp() {
|
| 35 |
console.log(`Usage:
|
| 36 |
npm run deploy:local
|
| 37 |
npm run deploy:local -- --memory
|
|
|
|
| 38 |
|
| 39 |
Options:
|
| 40 |
--memory Use docker-compose.memory.yml overlay for HF Space-like memory mode.
|
|
|
|
| 41 |
--skip-probe Rebuild and start the container without HTTP endpoint probes.
|
| 42 |
--help Show this help.`);
|
| 43 |
}
|
| 44 |
|
| 45 |
-
|
| 46 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
}
|
| 48 |
|
| 49 |
-
async function
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
let lastError = '';
|
| 51 |
-
for (let attempt = 1; attempt <=
|
| 52 |
try {
|
| 53 |
const responses = {};
|
| 54 |
-
for (const path of PROBE_PATHS) responses[path] = await
|
| 55 |
return {
|
| 56 |
attempts: attempt,
|
| 57 |
-
baseUrl
|
| 58 |
authRequired: responses['/api/auth-status'].passwordRequired,
|
| 59 |
stateBackend: responses['/api/agent/capabilities'].defaults?.state_backend,
|
| 60 |
imageStorageMode: responses['/api/agent/capabilities'].storage?.image_storage_mode,
|
|
@@ -62,20 +170,57 @@ async function waitForLocalEndpoints() {
|
|
| 62 |
};
|
| 63 |
} catch (error) {
|
| 64 |
lastError = error instanceof Error ? error.message : String(error);
|
| 65 |
-
await
|
| 66 |
}
|
| 67 |
}
|
| 68 |
throw new Error(`Local container did not pass HTTP probes: ${lastError}`);
|
| 69 |
}
|
| 70 |
|
| 71 |
export function assertLocalProbeMatchesMode(probe, options = {}) {
|
| 72 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
const mismatches = [];
|
| 74 |
-
if (probe.stateBackend !==
|
| 75 |
-
|
| 76 |
-
|
|
|
|
|
|
|
| 77 |
}
|
| 78 |
-
if (mismatches.length) throw new Error(`
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
}
|
| 80 |
|
| 81 |
async function main() {
|
|
@@ -85,8 +230,9 @@ async function main() {
|
|
| 85 |
return;
|
| 86 |
}
|
| 87 |
|
|
|
|
| 88 |
const docker = runCommand('docker', buildDockerComposeArgs(options), {
|
| 89 |
-
env: buildDockerComposeEnv(),
|
| 90 |
timeoutMs: DOCKER_COMPOSE_TIMEOUT_MS
|
| 91 |
});
|
| 92 |
if (!docker.ok) {
|
|
@@ -94,14 +240,16 @@ async function main() {
|
|
| 94 |
process.exit(1);
|
| 95 |
}
|
| 96 |
|
|
|
|
|
|
|
| 97 |
if (options.skipProbe) {
|
| 98 |
-
printJson({ ok: true, phase: 'docker-compose', probe: 'skipped' });
|
| 99 |
return;
|
| 100 |
}
|
| 101 |
|
| 102 |
-
const probe = await waitForLocalEndpoints();
|
| 103 |
assertLocalProbeMatchesMode(probe, options);
|
| 104 |
-
printJson({ ok: true, phase: 'ready', probe });
|
| 105 |
}
|
| 106 |
|
| 107 |
if (isMainModule(import.meta.url, process.argv[1])) {
|
|
|
|
| 4 |
|
| 5 |
import { fetchJsonWithTimeout, isMainModule, pickFailureOutput, printJson, runCommand } from './command-center-utils.mjs';
|
| 6 |
|
| 7 |
+
const CONTAINER_NAME = 'gpt-image-playground-customer';
|
| 8 |
+
const IMAGE_REPOSITORY = 'gpt-image-playground-customer';
|
| 9 |
+
const DEFAULT_BIND_HOST = '127.0.0.1';
|
| 10 |
+
const DEFAULT_HOST_PORT = '4783';
|
| 11 |
const PROBE_PATHS = ['/api/auth-status', '/api/runtime-capabilities', '/api/agent/capabilities'];
|
| 12 |
const PROBE_ATTEMPTS = 30;
|
| 13 |
const PROBE_INTERVAL_MS = 2000;
|
| 14 |
const PROBE_TIMEOUT_MS = 5000;
|
| 15 |
const DOCKER_COMPOSE_TIMEOUT_MS = 10 * 60 * 1000;
|
| 16 |
+
const DOCKER_COMPOSE_WAIT_TIMEOUT_SECONDS = 120;
|
| 17 |
+
const GIT_REVISION_PATTERN = /^[0-9a-f]{40}$/i;
|
| 18 |
|
| 19 |
export function buildDockerComposeArgs(options = {}) {
|
| 20 |
+
assertSingleDeploymentMode(options);
|
| 21 |
const files = ['-f', 'docker-compose.yml'];
|
| 22 |
if (options.memory) files.push('-f', 'docker-compose.memory.yml');
|
| 23 |
+
if (options.postgres) files.push('-f', 'docker-compose.postgres.yml');
|
| 24 |
+
return [
|
| 25 |
+
'compose',
|
| 26 |
+
...files,
|
| 27 |
+
'up',
|
| 28 |
+
'-d',
|
| 29 |
+
'--build',
|
| 30 |
+
'--force-recreate',
|
| 31 |
+
'--remove-orphans',
|
| 32 |
+
'--wait',
|
| 33 |
+
'--wait-timeout',
|
| 34 |
+
String(DOCKER_COMPOSE_WAIT_TIMEOUT_SECONDS)
|
| 35 |
+
];
|
| 36 |
}
|
| 37 |
|
| 38 |
+
function assertSingleDeploymentMode(options = {}) {
|
| 39 |
+
if (options.memory && options.postgres) {
|
| 40 |
+
throw new Error('--memory 和 --postgres 不能同时使用。');
|
| 41 |
+
}
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
export function buildDockerComposeEnv(env = process.env, deployment) {
|
| 45 |
+
return {
|
| 46 |
+
...env,
|
| 47 |
+
COMPOSE_PROGRESS: 'plain',
|
| 48 |
+
...(deployment
|
| 49 |
+
? {
|
| 50 |
+
GIP_IMAGE_REVISION: deployment.revision,
|
| 51 |
+
GIP_IMAGE_TAG: deployment.imageTag
|
| 52 |
+
}
|
| 53 |
+
: {})
|
| 54 |
+
};
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
export function buildDeploymentImageTag(revision) {
|
| 58 |
+
const normalized = revision?.trim().toLowerCase();
|
| 59 |
+
if (!GIT_REVISION_PATTERN.test(normalized || '')) throw new Error('Git revision 必须是完整的 40 位 SHA。');
|
| 60 |
+
return `local-${normalized}`;
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
export function buildDeploymentImageReference(revision) {
|
| 64 |
+
return `${IMAGE_REPOSITORY}:${buildDeploymentImageTag(revision)}`;
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
export function buildLocalBaseUrl(bindHost = DEFAULT_BIND_HOST, hostPort = DEFAULT_HOST_PORT) {
|
| 68 |
+
const host = bindHost.trim();
|
| 69 |
+
const port = hostPort.trim();
|
| 70 |
+
if (!host) throw new Error('GIP_BIND_HOST 不能为空。');
|
| 71 |
+
if (!/^\d+$/.test(port) || Number(port) < 1 || Number(port) > 65535) {
|
| 72 |
+
throw new Error(`GIP_PORT 必须是 1 到 65535 的整数,收到:${hostPort}`);
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
const probeHost = host === '0.0.0.0' ? '127.0.0.1' : host === '::' ? '[::1]' : host.includes(':') && !host.startsWith('[') ? `[${host}]` : host;
|
| 76 |
+
return `http://${probeHost}:${port}`;
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
export function parsePublishedContainerPortBindings(output) {
|
| 80 |
+
let bindings;
|
| 81 |
+
try {
|
| 82 |
+
bindings = JSON.parse(output);
|
| 83 |
+
} catch {
|
| 84 |
+
throw new Error('无法解析 Docker 容器端口映射。');
|
| 85 |
+
}
|
| 86 |
+
if (!Array.isArray(bindings) || bindings.length === 0) {
|
| 87 |
+
throw new Error('Docker 容器未发布 4783/tcp 端口。');
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
const binding = bindings.find((entry) => typeof entry?.HostIp === 'string' && !entry.HostIp.includes(':')) || bindings[0];
|
| 91 |
+
const bindHost = typeof binding?.HostIp === 'string' && binding.HostIp.trim() ? binding.HostIp.trim() : '0.0.0.0';
|
| 92 |
+
const hostPort = typeof binding?.HostPort === 'string' ? binding.HostPort.trim() : '';
|
| 93 |
+
return { bindHost, hostPort, baseUrl: buildLocalBaseUrl(bindHost, hostPort) };
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
export function assertDeploymentImageIdentity(identity, deployment) {
|
| 97 |
+
const expectedImage = buildDeploymentImageReference(deployment.revision);
|
| 98 |
+
if (identity.image !== expectedImage) {
|
| 99 |
+
throw new Error(`运行容器镜像不匹配:expected ${expectedImage}, received ${identity.image || '<missing>'}。`);
|
| 100 |
+
}
|
| 101 |
+
if (identity.revision !== deployment.revision) {
|
| 102 |
+
throw new Error(
|
| 103 |
+
`运行镜像 revision 不匹配:expected ${deployment.revision}, received ${identity.revision || '<missing>'}。`
|
| 104 |
+
);
|
| 105 |
+
}
|
| 106 |
}
|
| 107 |
|
| 108 |
function parseArgs(argv) {
|
| 109 |
+
const unknown = argv.find((arg) => !['--help', '-h', '--memory', '--postgres', '--skip-probe'].includes(arg));
|
| 110 |
if (unknown) throw new Error(`Unknown option: ${unknown}`);
|
| 111 |
+
const options = {
|
| 112 |
help: argv.includes('--help') || argv.includes('-h'),
|
| 113 |
memory: argv.includes('--memory'),
|
| 114 |
+
postgres: argv.includes('--postgres'),
|
| 115 |
skipProbe: argv.includes('--skip-probe')
|
| 116 |
};
|
| 117 |
+
assertSingleDeploymentMode(options);
|
| 118 |
+
return options;
|
| 119 |
}
|
| 120 |
|
| 121 |
function printHelp() {
|
| 122 |
console.log(`Usage:
|
| 123 |
npm run deploy:local
|
| 124 |
npm run deploy:local -- --memory
|
| 125 |
+
npm run deploy:local -- --postgres
|
| 126 |
|
| 127 |
Options:
|
| 128 |
--memory Use docker-compose.memory.yml overlay for HF Space-like memory mode.
|
| 129 |
+
--postgres Use docker-compose.postgres.yml and require GPT_IMAGE_POSTGRES_PASSWORD.
|
| 130 |
--skip-probe Rebuild and start the container without HTTP endpoint probes.
|
| 131 |
--help Show this help.`);
|
| 132 |
}
|
| 133 |
|
| 134 |
+
function readCleanGitRevision() {
|
| 135 |
+
const revisionResult = runCommand('git', ['rev-parse', '--verify', 'HEAD']);
|
| 136 |
+
if (!revisionResult.ok) throw new Error(`无法读取当前 Git revision:${pickFailureOutput(revisionResult)}`);
|
| 137 |
+
|
| 138 |
+
const revision = revisionResult.stdout.trim().toLowerCase();
|
| 139 |
+
const imageTag = buildDeploymentImageTag(revision);
|
| 140 |
+
const statusResult = runCommand('git', ['status', '--porcelain=v1', '--untracked-files=all']);
|
| 141 |
+
if (!statusResult.ok) throw new Error(`无法检查 Git 工作区状态:${pickFailureOutput(statusResult)}`);
|
| 142 |
+
if (statusResult.stdout) {
|
| 143 |
+
throw new Error('拒绝部署脏工作区。请先提交或清理当前改动,再运行 npm run deploy:local。');
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
return { revision, imageTag };
|
| 147 |
}
|
| 148 |
|
| 149 |
+
async function fetchJson(path, baseUrl) {
|
| 150 |
+
return fetchJsonWithTimeout(new URL(path, baseUrl), { timeoutMs: PROBE_TIMEOUT_MS });
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
export async function waitForLocalEndpoints(baseUrl, options = {}) {
|
| 154 |
+
const attempts = options.attempts ?? PROBE_ATTEMPTS;
|
| 155 |
+
const intervalMs = options.intervalMs ?? PROBE_INTERVAL_MS;
|
| 156 |
+
const requestJson = options.fetchJson || fetchJson;
|
| 157 |
+
const sleep = options.sleep || delay;
|
| 158 |
let lastError = '';
|
| 159 |
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
| 160 |
try {
|
| 161 |
const responses = {};
|
| 162 |
+
for (const path of PROBE_PATHS) responses[path] = await requestJson(path, baseUrl);
|
| 163 |
return {
|
| 164 |
attempts: attempt,
|
| 165 |
+
baseUrl,
|
| 166 |
authRequired: responses['/api/auth-status'].passwordRequired,
|
| 167 |
stateBackend: responses['/api/agent/capabilities'].defaults?.state_backend,
|
| 168 |
imageStorageMode: responses['/api/agent/capabilities'].storage?.image_storage_mode,
|
|
|
|
| 170 |
};
|
| 171 |
} catch (error) {
|
| 172 |
lastError = error instanceof Error ? error.message : String(error);
|
| 173 |
+
if (attempt < attempts) await sleep(intervalMs);
|
| 174 |
}
|
| 175 |
}
|
| 176 |
throw new Error(`Local container did not pass HTTP probes: ${lastError}`);
|
| 177 |
}
|
| 178 |
|
| 179 |
export function assertLocalProbeMatchesMode(probe, options = {}) {
|
| 180 |
+
assertSingleDeploymentMode(options);
|
| 181 |
+
const expected = options.memory
|
| 182 |
+
? { label: 'Memory', stateBackend: 'memory', imageStorageMode: 'indexeddb' }
|
| 183 |
+
: options.postgres
|
| 184 |
+
? { label: 'PostgreSQL', stateBackend: 'postgres', imageStorageMode: 'fs' }
|
| 185 |
+
: { label: 'SQLite', stateBackend: 'sqlite', imageStorageMode: 'fs' };
|
| 186 |
const mismatches = [];
|
| 187 |
+
if (probe.stateBackend !== expected.stateBackend) {
|
| 188 |
+
mismatches.push(`stateBackend=${probe.stateBackend ?? '<missing>'} expected ${expected.stateBackend}`);
|
| 189 |
+
}
|
| 190 |
+
if (probe.imageStorageMode !== expected.imageStorageMode) {
|
| 191 |
+
mismatches.push(`imageStorageMode=${probe.imageStorageMode ?? '<missing>'} expected ${expected.imageStorageMode}`);
|
| 192 |
}
|
| 193 |
+
if (mismatches.length) throw new Error(`${expected.label} deployment mode did not take effect: ${mismatches.join(', ')}.`);
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
function inspectDeploymentImage(deployment) {
|
| 197 |
+
const expectedImage = buildDeploymentImageReference(deployment.revision);
|
| 198 |
+
const containerImage = runCommand('docker', ['inspect', '--format', '{{.Config.Image}}', CONTAINER_NAME]);
|
| 199 |
+
if (!containerImage.ok) throw new Error(`无法读取部署容器镜像:${pickFailureOutput(containerImage)}`);
|
| 200 |
+
|
| 201 |
+
const imageRevision = runCommand('docker', [
|
| 202 |
+
'image',
|
| 203 |
+
'inspect',
|
| 204 |
+
'--format',
|
| 205 |
+
'{{ index .Config.Labels "org.opencontainers.image.revision" }}',
|
| 206 |
+
expectedImage
|
| 207 |
+
]);
|
| 208 |
+
if (!imageRevision.ok) throw new Error(`无法读取部署镜像 revision:${pickFailureOutput(imageRevision)}`);
|
| 209 |
+
|
| 210 |
+
const identity = { image: containerImage.stdout.trim(), revision: imageRevision.stdout.trim().toLowerCase() };
|
| 211 |
+
assertDeploymentImageIdentity(identity, deployment);
|
| 212 |
+
return identity;
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
function inspectPublishedContainerPort() {
|
| 216 |
+
const result = runCommand('docker', [
|
| 217 |
+
'inspect',
|
| 218 |
+
'--format',
|
| 219 |
+
'{{json (index .NetworkSettings.Ports "4783/tcp")}}',
|
| 220 |
+
CONTAINER_NAME
|
| 221 |
+
]);
|
| 222 |
+
if (!result.ok) throw new Error(`无法读取部署容器端口映射:${pickFailureOutput(result)}`);
|
| 223 |
+
return parsePublishedContainerPortBindings(result.stdout.trim());
|
| 224 |
}
|
| 225 |
|
| 226 |
async function main() {
|
|
|
|
| 230 |
return;
|
| 231 |
}
|
| 232 |
|
| 233 |
+
const deployment = readCleanGitRevision();
|
| 234 |
const docker = runCommand('docker', buildDockerComposeArgs(options), {
|
| 235 |
+
env: buildDockerComposeEnv(process.env, deployment),
|
| 236 |
timeoutMs: DOCKER_COMPOSE_TIMEOUT_MS
|
| 237 |
});
|
| 238 |
if (!docker.ok) {
|
|
|
|
| 240 |
process.exit(1);
|
| 241 |
}
|
| 242 |
|
| 243 |
+
const image = inspectDeploymentImage(deployment);
|
| 244 |
+
const localConfig = inspectPublishedContainerPort();
|
| 245 |
if (options.skipProbe) {
|
| 246 |
+
printJson({ ok: true, phase: 'docker-compose', image, probe: 'skipped' });
|
| 247 |
return;
|
| 248 |
}
|
| 249 |
|
| 250 |
+
const probe = await waitForLocalEndpoints(localConfig.baseUrl);
|
| 251 |
assertLocalProbeMatchesMode(probe, options);
|
| 252 |
+
printJson({ ok: true, phase: 'ready', deployment: { ...deployment, ...localConfig }, image, probe });
|
| 253 |
}
|
| 254 |
|
| 255 |
if (isMainModule(import.meta.url, process.argv[1])) {
|
scripts/docker-build-context.test.mjs
CHANGED
|
@@ -4,12 +4,16 @@ import { describe, it } from 'node:test';
|
|
| 4 |
|
| 5 |
describe('Docker build context', () => {
|
| 6 |
it('keeps the tracked real-upstream smoke template available to containerized tests', async () => {
|
| 7 |
-
const [dockerignore, dockerfile, gitignore, realSmokeTemplate
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
assert.match(dockerignore, /^\.gitignore$/m);
|
| 15 |
assert.match(dockerignore, /^!\.gitignore$/m);
|
|
@@ -17,7 +21,24 @@ describe('Docker build context', () => {
|
|
| 17 |
assert.match(dockerignore, /^!\.env\.real-smoke\.example$/m);
|
| 18 |
assert.match(dockerfile, /^COPY \. \.$/m);
|
| 19 |
assert.match(dockerfile, /^COPY vendor\/brace-expansion-compat \.\/vendor\/brace-expansion-compat$/m);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
assert.match(gitignore, /^!\.env\.real-smoke\.example$/m);
|
| 21 |
assert.match(realSmokeTemplate, /^IMAGE_REAL_SMOKE_TIMEOUT_MS=240000$/m);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
});
|
| 23 |
});
|
|
|
|
| 4 |
|
| 5 |
describe('Docker build context', () => {
|
| 6 |
it('keeps the tracked real-upstream smoke template available to containerized tests', async () => {
|
| 7 |
+
const [dockerignore, dockerfile, gitignore, realSmokeTemplate, compose, postgresCompose, ciWorkflow] =
|
| 8 |
+
await Promise.all([
|
| 9 |
+
readFile(new URL('../.dockerignore', import.meta.url), 'utf8'),
|
| 10 |
+
readFile(new URL('../Dockerfile', import.meta.url), 'utf8'),
|
| 11 |
+
readFile(new URL('../.gitignore', import.meta.url), 'utf8'),
|
| 12 |
+
readFile(new URL('../.env.real-smoke.example', import.meta.url), 'utf8'),
|
| 13 |
+
readFile(new URL('../docker-compose.yml', import.meta.url), 'utf8'),
|
| 14 |
+
readFile(new URL('../docker-compose.postgres.yml', import.meta.url), 'utf8'),
|
| 15 |
+
readFile(new URL('../.github/workflows/ci.yml', import.meta.url), 'utf8')
|
| 16 |
+
]);
|
| 17 |
|
| 18 |
assert.match(dockerignore, /^\.gitignore$/m);
|
| 19 |
assert.match(dockerignore, /^!\.gitignore$/m);
|
|
|
|
| 21 |
assert.match(dockerignore, /^!\.env\.real-smoke\.example$/m);
|
| 22 |
assert.match(dockerfile, /^COPY \. \.$/m);
|
| 23 |
assert.match(dockerfile, /^COPY vendor\/brace-expansion-compat \.\/vendor\/brace-expansion-compat$/m);
|
| 24 |
+
assert.match(
|
| 25 |
+
dockerfile,
|
| 26 |
+
/^COPY --from=builder --chown=node:node \/app\/scripts\/docker-entrypoint\.mjs \.\/scripts\/docker-entrypoint\.mjs$/m
|
| 27 |
+
);
|
| 28 |
+
assert.match(dockerfile, /^HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 CMD /m);
|
| 29 |
+
assert.match(dockerfile, /^LABEL org\.opencontainers\.image\.revision=\$VCS_REF$/m);
|
| 30 |
assert.match(gitignore, /^!\.env\.real-smoke\.example$/m);
|
| 31 |
assert.match(realSmokeTemplate, /^IMAGE_REAL_SMOKE_TIMEOUT_MS=240000$/m);
|
| 32 |
+
assert.match(compose, /^ - "\$\{GIP_BIND_HOST:-127\.0\.0\.1\}:\$\{GIP_PORT:-4783\}:4783"$/m);
|
| 33 |
+
assert.doesNotMatch(compose, /^ - "4783:4783"$/m);
|
| 34 |
+
assert.match(postgresCompose, /^ AGENT_DATABASE_URL: ""$/m);
|
| 35 |
+
assert.match(postgresCompose, /^ AGENT_DB_PASSWORD: ""$/m);
|
| 36 |
+
assert.match(postgresCompose, /^ AGENT_DB_PASSWORD_FILE: \/run\/secrets\/postgres_password$/m);
|
| 37 |
+
assert.doesNotMatch(postgresCompose, /gpt-image-playground-customer:postgres/);
|
| 38 |
+
assert.doesNotMatch(postgresCompose, /^ ports:/m);
|
| 39 |
+
assert.match(ciWorkflow, /--env GIP_COMPOSE_DEPLOYMENT=true --env GIP_BIND_HOST=127\.0\.0\.1/);
|
| 40 |
+
assert.match(ciWorkflow, /for attempt in \{1\.\.120\}; do/);
|
| 41 |
+
assert.match(ciWorkflow, /attempt %s\/120/);
|
| 42 |
+
assert.match(ciWorkflow, /if \(\( attempt < 120 \)\); then\n\s+sleep 1/);
|
| 43 |
});
|
| 44 |
});
|
scripts/docker-entrypoint.mjs
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env node
|
| 2 |
+
|
| 3 |
+
import path from 'node:path';
|
| 4 |
+
import { fileURLToPath } from 'node:url';
|
| 5 |
+
|
| 6 |
+
const IPV4_LOOPBACK_PATTERN = /^127(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/;
|
| 7 |
+
const LOOPBACK_HOSTS = new Set(['localhost', '::1', '[::1]']);
|
| 8 |
+
|
| 9 |
+
export function isLoopbackBindHost(value) {
|
| 10 |
+
const host = value?.trim().toLowerCase();
|
| 11 |
+
return Boolean(host) && (LOOPBACK_HOSTS.has(host) || IPV4_LOOPBACK_PATTERN.test(host));
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
export function assertDockerComposeAccessPolicy(env = process.env) {
|
| 15 |
+
if (env.GIP_COMPOSE_DEPLOYMENT?.trim().toLowerCase() !== 'true') return;
|
| 16 |
+
if (isLoopbackBindHost(env.GIP_BIND_HOST)) return;
|
| 17 |
+
if (env.APP_PASSWORD?.trim()) return;
|
| 18 |
+
|
| 19 |
+
throw new Error(
|
| 20 |
+
'拒绝以非回环地址发布 Docker 服务:请先在 .env.local 设置 APP_PASSWORD,或将 GIP_BIND_HOST 保持为 127.0.0.1。'
|
| 21 |
+
);
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
async function main() {
|
| 25 |
+
assertDockerComposeAccessPolicy();
|
| 26 |
+
await import('../server.js');
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
if (path.resolve(process.argv[1] ?? '') === fileURLToPath(import.meta.url)) {
|
| 30 |
+
main().catch((error) => {
|
| 31 |
+
console.error(`[ERROR] ${error instanceof Error ? error.message : String(error)}`);
|
| 32 |
+
process.exit(1);
|
| 33 |
+
});
|
| 34 |
+
}
|
scripts/docker-entrypoint.test.mjs
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { assertDockerComposeAccessPolicy, isLoopbackBindHost } from './docker-entrypoint.mjs';
|
| 2 |
+
import assert from 'node:assert/strict';
|
| 3 |
+
import { describe, it } from 'node:test';
|
| 4 |
+
|
| 5 |
+
describe('Docker Compose access policy', () => {
|
| 6 |
+
it('recognizes only loopback bind hosts as local-only', () => {
|
| 7 |
+
for (const host of ['127.0.0.1', '127.0.12.34', 'localhost', '::1', '[::1]']) {
|
| 8 |
+
assert.equal(isLoopbackBindHost(host), true, host);
|
| 9 |
+
}
|
| 10 |
+
for (const host of ['0.0.0.0', '10.0.90.200', '::', 'localhost.example.test']) {
|
| 11 |
+
assert.equal(isLoopbackBindHost(host), false, host);
|
| 12 |
+
}
|
| 13 |
+
});
|
| 14 |
+
|
| 15 |
+
it('allows standalone images and loopback Compose deployments without a page password', () => {
|
| 16 |
+
assert.doesNotThrow(() => assertDockerComposeAccessPolicy({}));
|
| 17 |
+
assert.doesNotThrow(() =>
|
| 18 |
+
assertDockerComposeAccessPolicy({ GIP_COMPOSE_DEPLOYMENT: 'true', GIP_BIND_HOST: '127.0.0.1' })
|
| 19 |
+
);
|
| 20 |
+
});
|
| 21 |
+
|
| 22 |
+
it('rejects unauthenticated non-loopback Compose deployments', () => {
|
| 23 |
+
assert.throws(
|
| 24 |
+
() => assertDockerComposeAccessPolicy({ GIP_COMPOSE_DEPLOYMENT: 'true', GIP_BIND_HOST: '0.0.0.0' }),
|
| 25 |
+
/APP_PASSWORD/
|
| 26 |
+
);
|
| 27 |
+
assert.throws(
|
| 28 |
+
() => assertDockerComposeAccessPolicy({ GIP_COMPOSE_DEPLOYMENT: ' TRUE ', GIP_BIND_HOST: '0.0.0.0' }),
|
| 29 |
+
/APP_PASSWORD/
|
| 30 |
+
);
|
| 31 |
+
assert.doesNotThrow(() =>
|
| 32 |
+
assertDockerComposeAccessPolicy({
|
| 33 |
+
GIP_COMPOSE_DEPLOYMENT: 'true',
|
| 34 |
+
GIP_BIND_HOST: '10.0.90.200',
|
| 35 |
+
APP_PASSWORD: 'access-code'
|
| 36 |
+
})
|
| 37 |
+
);
|
| 38 |
+
});
|
| 39 |
+
});
|
scripts/smoke-hf-space-memory.mjs
CHANGED
|
@@ -5,7 +5,11 @@ import { readPositiveIntegerEnv } from './env-utils.mjs';
|
|
| 5 |
|
| 6 |
const imageName = process.env.HF_SPACE_SMOKE_IMAGE || 'gpt-image-playground:hf-space-memory-smoke';
|
| 7 |
const containerName = process.env.HF_SPACE_SMOKE_CONTAINER || 'gpt-image-playground-hf-space-smoke';
|
| 8 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
const token = process.env.HF_SPACE_SMOKE_AGENT_TOKEN || 'hf-space-smoke-token';
|
| 10 |
const baseUrl = `http://127.0.0.1:${hostPort}`;
|
| 11 |
const readyTimeoutMs = readPositiveIntegerEnv('HF_SPACE_SMOKE_READY_TIMEOUT_MS', 45_000);
|
|
@@ -16,6 +20,9 @@ function run(command, args, options = {}) {
|
|
| 16 |
encoding: 'utf8',
|
| 17 |
env: { ...process.env, ...(options.env || {}) }
|
| 18 |
});
|
|
|
|
|
|
|
|
|
|
| 19 |
if (result.status !== 0) {
|
| 20 |
const output = [result.stdout, result.stderr].filter(Boolean).join('\n').trim();
|
| 21 |
throw new Error(`${command} ${args.join(' ')} failed${output ? `\n${output}` : ''}`);
|
|
@@ -93,7 +100,7 @@ try {
|
|
| 93 |
'--name',
|
| 94 |
containerName,
|
| 95 |
'-p',
|
| 96 |
-
`${hostPort}:4783`,
|
| 97 |
'-e',
|
| 98 |
'AGENT_STATE_BACKEND=memory',
|
| 99 |
'-e',
|
|
|
|
| 5 |
|
| 6 |
const imageName = process.env.HF_SPACE_SMOKE_IMAGE || 'gpt-image-playground:hf-space-memory-smoke';
|
| 7 |
const containerName = process.env.HF_SPACE_SMOKE_CONTAINER || 'gpt-image-playground-hf-space-smoke';
|
| 8 |
+
const parsedHostPort = readPositiveIntegerEnv('HF_SPACE_SMOKE_PORT', 4785);
|
| 9 |
+
if (parsedHostPort > 65_535) {
|
| 10 |
+
throw new Error('HF_SPACE_SMOKE_PORT must be less than or equal to 65535');
|
| 11 |
+
}
|
| 12 |
+
const hostPort = String(parsedHostPort);
|
| 13 |
const token = process.env.HF_SPACE_SMOKE_AGENT_TOKEN || 'hf-space-smoke-token';
|
| 14 |
const baseUrl = `http://127.0.0.1:${hostPort}`;
|
| 15 |
const readyTimeoutMs = readPositiveIntegerEnv('HF_SPACE_SMOKE_READY_TIMEOUT_MS', 45_000);
|
|
|
|
| 20 |
encoding: 'utf8',
|
| 21 |
env: { ...process.env, ...(options.env || {}) }
|
| 22 |
});
|
| 23 |
+
if (result.error) {
|
| 24 |
+
throw new Error(`${command} ${args.join(' ')} failed to start: ${result.error.message}`);
|
| 25 |
+
}
|
| 26 |
if (result.status !== 0) {
|
| 27 |
const output = [result.stdout, result.stderr].filter(Boolean).join('\n').trim();
|
| 28 |
throw new Error(`${command} ${args.join(' ')} failed${output ? `\n${output}` : ''}`);
|
|
|
|
| 100 |
'--name',
|
| 101 |
containerName,
|
| 102 |
'-p',
|
| 103 |
+
`127.0.0.1:${hostPort}:4783`,
|
| 104 |
'-e',
|
| 105 |
'AGENT_STATE_BACKEND=memory',
|
| 106 |
'-e',
|
scripts/smoke-hf-space-memory.test.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
import assert from 'node:assert/strict';
|
| 2 |
import { spawnSync } from 'node:child_process';
|
|
|
|
| 3 |
import { join } from 'node:path';
|
| 4 |
import { fileURLToPath } from 'node:url';
|
| 5 |
import { describe, it } from 'node:test';
|
|
@@ -8,6 +9,13 @@ const repoRoot = fileURLToPath(new URL('..', import.meta.url));
|
|
| 8 |
const scriptPath = join(repoRoot, 'scripts/smoke-hf-space-memory.mjs');
|
| 9 |
|
| 10 |
describe('HF Space memory smoke script validation', () => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
it('rejects invalid ready timeout values before Docker access', () => {
|
| 12 |
const result = spawnSync(process.execPath, [scriptPath], {
|
| 13 |
cwd: repoRoot,
|
|
@@ -20,4 +28,30 @@ describe('HF Space memory smoke script validation', () => {
|
|
| 20 |
assert.match(result.stderr, /positive integer/);
|
| 21 |
assert.equal(result.stdout.trim(), '');
|
| 22 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
});
|
|
|
|
| 1 |
import assert from 'node:assert/strict';
|
| 2 |
import { spawnSync } from 'node:child_process';
|
| 3 |
+
import { readFileSync } from 'node:fs';
|
| 4 |
import { join } from 'node:path';
|
| 5 |
import { fileURLToPath } from 'node:url';
|
| 6 |
import { describe, it } from 'node:test';
|
|
|
|
| 9 |
const scriptPath = join(repoRoot, 'scripts/smoke-hf-space-memory.mjs');
|
| 10 |
|
| 11 |
describe('HF Space memory smoke script validation', () => {
|
| 12 |
+
it('publishes the temporary container only on the IPv4 loopback interface', () => {
|
| 13 |
+
const source = readFileSync(scriptPath, 'utf8');
|
| 14 |
+
|
| 15 |
+
assert.match(source, /`127\.0\.0\.1:\$\{hostPort\}:4783`/);
|
| 16 |
+
assert.doesNotMatch(source, /^\s*`\$\{hostPort\}:4783`,?$/m);
|
| 17 |
+
});
|
| 18 |
+
|
| 19 |
it('rejects invalid ready timeout values before Docker access', () => {
|
| 20 |
const result = spawnSync(process.execPath, [scriptPath], {
|
| 21 |
cwd: repoRoot,
|
|
|
|
| 28 |
assert.match(result.stderr, /positive integer/);
|
| 29 |
assert.equal(result.stdout.trim(), '');
|
| 30 |
});
|
| 31 |
+
|
| 32 |
+
it('rejects out-of-range host ports before Docker access', () => {
|
| 33 |
+
const result = spawnSync(process.execPath, [scriptPath], {
|
| 34 |
+
cwd: repoRoot,
|
| 35 |
+
encoding: 'utf8',
|
| 36 |
+
env: { ...process.env, HF_SPACE_SMOKE_PORT: '65536' }
|
| 37 |
+
});
|
| 38 |
+
|
| 39 |
+
assert.equal(result.status, 1);
|
| 40 |
+
assert.match(result.stderr, /HF_SPACE_SMOKE_PORT/);
|
| 41 |
+
assert.match(result.stderr, /65535/);
|
| 42 |
+
assert.equal(result.stdout.trim(), '');
|
| 43 |
+
});
|
| 44 |
+
|
| 45 |
+
it('reports the underlying spawn error when Docker is unavailable', () => {
|
| 46 |
+
const result = spawnSync(process.execPath, [scriptPath], {
|
| 47 |
+
cwd: repoRoot,
|
| 48 |
+
encoding: 'utf8',
|
| 49 |
+
env: { ...process.env, PATH: '' }
|
| 50 |
+
});
|
| 51 |
+
|
| 52 |
+
assert.equal(result.status, 1);
|
| 53 |
+
assert.match(result.stderr, /docker build/);
|
| 54 |
+
assert.match(result.stderr, /failed to start/);
|
| 55 |
+
assert.match(result.stderr, /ENOENT/);
|
| 56 |
+
});
|
| 57 |
});
|
skills/gpt-image-playground-agent/SKILL.md
CHANGED
|
@@ -141,7 +141,7 @@ Authorization: Bearer <token>
|
|
| 141 |
- `npm run status`:只读查看 git、Space 目标、Agent API、Skill 入口和独立真实图片上游 smoke 配置摘要;会自动读取 `.env.real-smoke.local`,不输出 URL 或 API Key。
|
| 142 |
- `npm run doctor`:统一诊断本机与 HF Space 配置,不写 Secret。
|
| 143 |
- `npm run verify`:运行提交前基线;需要真实 PostgreSQL gate 时加 `-- --postgres`。
|
| 144 |
-
- `npm run deploy:local`:重建本地 Docker 服务并探测真实 HTTP 端点;加 `-- --memory` 会断言 memory/indexeddb overlay 生效。
|
| 145 |
- `npm run deploy:space`:部署干净 git HEAD 到固定 Space,并做只读公网验证。
|
| 146 |
- `npm run agent:doctor`:执行非计费分层诊断,覆盖 capabilities、Agent contract、runtime backend、state backend 和 Responses/GPT2Image readiness;支持 `-- --base-url <url>`;真实 1K/2K smoke 必须显式加 `-- --allow-billable`。
|
| 147 |
|
|
|
|
| 141 |
- `npm run status`:只读查看 git、Space 目标、Agent API、Skill 入口和独立真实图片上游 smoke 配置摘要;会自动读取 `.env.real-smoke.local`,不输出 URL 或 API Key。
|
| 142 |
- `npm run doctor`:统一诊断本机与 HF Space 配置,不写 Secret。
|
| 143 |
- `npm run verify`:运行提交前基线;需要真实 PostgreSQL gate 时加 `-- --postgres`。
|
| 144 |
+
- `npm run deploy:local`:重建本地 Docker 服务并探测真实 HTTP 端点;加 `-- --memory` 会断言 memory/indexeddb overlay 生效,加 `-- --postgres` 会断言 postgres/fs overlay 生效。PostgreSQL 模式要求在运行环境或 Compose `.env` 中提供 `GPT_IMAGE_POSTGRES_PASSWORD`。
|
| 145 |
- `npm run deploy:space`:部署干净 git HEAD 到固定 Space,并做只读公网验证。
|
| 146 |
- `npm run agent:doctor`:执行非计费分层诊断,覆盖 capabilities、Agent contract、runtime backend、state backend 和 Responses/GPT2Image readiness;支持 `-- --base-url <url>`;真实 1K/2K smoke 必须显式加 `-- --allow-billable`。
|
| 147 |
|
src/app/api/deploy-marker/route.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
import { NextResponse } from 'next/server';
|
| 2 |
|
| 3 |
-
const deployMarker = {"schema_version":1,"local_sha":"
|
| 4 |
|
| 5 |
export const dynamic = 'force-dynamic';
|
| 6 |
|
|
|
|
| 1 |
import { NextResponse } from 'next/server';
|
| 2 |
|
| 3 |
+
const deployMarker = {"schema_version":1,"local_sha":"6590d91c9e6e0a3314fa16bd8e5667b2d9e855cd","created_at":"2026-07-27T03:45:51.712Z","deploy_id":"d1603a14-8385-441d-adac-e4c5b059dde4"} as const;
|
| 4 |
|
| 5 |
export const dynamic = 'force-dynamic';
|
| 6 |
|