157 changed files with 576 additions and 25875 deletions
-52
View File
@@ -1,52 +0,0 @@
# ============================================
# Agent 记忆服务(agent-memory)环境变量
# 使用前请复制为 .env.AMS 并根据实际情况修改
# ============================================
# ---------- PostgreSQL(复用 easyai-pgvector)----------
# ---------- 主服务连接(用于 Embedding 与对话压缩)----------
# 主服务 API 地址(容器内网地址)
MEMORY_AI_BASE_URL=http://easyai-server:3001
MEMORY_AI_API_KEY=
# 默认模型配置接口路径已内置为 /content/llm/default-models,无需额外环境变量
# ---------- Embeddings ----------
MEMORY_EMBEDDINGS_PATH=/v1/embeddings
MEMORY_EMBEDDING_MODELS_PATH=/v1/embeddings/models
MEMORY_EMBEDDING_DIMENSION=1024
MEMORY_EMBEDDING_MODEL=Qwen3-Embedding-v4
MEMORY_SCORE_CONFIDENCE_BASE=0.6
MEMORY_SCORE_CONFIDENCE_GAIN=0.4
MEMORY_SCORE_IMPORTANCE_BASE=0.7
MEMORY_SCORE_IMPORTANCE_GAIN=0.3
MEMORY_DEDUP_MIN_SCORE=0.92
MEMORY_TEXT_DEDUP_MIN_SCORE=0.9
# ---------- 对话压缩 ----------
MEMORY_CHAT_COMPLETIONS_PATH=/v1/chat/completions
MEMORY_CHAT_MODELS_PATH=/v1/models
MEMORY_CHAT_MODEL=qwen-plus
MEMORY_COMPRESSION_ENABLED=true
MEMORY_COMPRESSION_MIN_LENGTH=80
# ---------- 每日记忆整理 ----------
# 是否启用每天凌晨 2 点记忆整理(去重、冲突处理)
MEMORY_MAINTENANCE_ENABLED=true
# dry-run: true=仅分析不落库;建议升级首日先开 true 观察
MEMORY_MAINTENANCE_DRY_RUN=false
# 每个租户单轮扫描上限
MEMORY_MAINTENANCE_BATCH_SIZE=200
# 是否启用 LLM 语义冲突裁决(复用会话总结模型,无需单独模型配置)
MEMORY_MAINTENANCE_LLM_ENABLED=true
# 单轮最多调用 LLM 裁决的冲突对数量(用于控成本)
MEMORY_MAINTENANCE_LLM_MAX_PAIRS=20
# ---------- 其他 ----------
# 日志级别
LOG_LEVEL=log,error,warn,debug
# 是否禁用 Swagger 文档(true 禁用)
MEMORY_DOCS_DISABLE=false
# 记忆服务镜像版本
AMS_VERSION=latest
+5 -4
View File
@@ -5,8 +5,9 @@
# ---------- PostgreSQL ----------
# Prisma 数据库连接字符串(容器内网地址)
ASG_DATABASE_URL=postgresql://easyai:easyai2025@easyai-pgvector:5432/agent_governance?schema=public
ASG_DATABASE_URL=postgresql://easyai:easyai2025@easyai-asg-pg:5432/agent_governance?schema=public
# PostgreSQL 容器初始化配置(与 docker-compose 中 sg-postgres 保持一致)
ASG_POSTGRES_USER=easyai
ASG_POSTGRES_PASSWORD=easyai2025
ASG_POSTGRES_DB=agent_governance
@@ -21,13 +22,13 @@ ASG_REDIS_DB=8
# ---------- 服务端口 ----------
# HTTP API 端口
ASG_PORT=3003
# TCP 微服务端口(供 easyai-server 内部调用)
# TCP 微服务端口(供 comfy-server 内部调用)
ASG_TCP_PORT=4003
ASG_TCP_HOST=0.0.0.0
# ---------- 主服务连接 ----------
# Agent 调用时需要访问的 easyai-server 地址(容器内网地址)
ASG_MAIN_BACKEND_URL=http://easyai-server:3001
# Agent 调用时需要访问的 comfy-server 地址(容器内网地址)
ASG_MAIN_BACKEND_URL=http://comfy-server:3001
# 管理员账号(用于 Agent 登录获取 token)
ASG_ADMIN_USERNAME=admin
ASG_ADMIN_PASSWORD=123456
+71
View File
@@ -0,0 +1,71 @@
# =============================================================================
# ComfyAI Web 静态资源 CDN 初始化配置
# 用于 comfyAI-web-init 容器:构建后上传前端静态资源到 OSS/CDN
# 部署时请根据实际使用的 OSS 服务商填写对应配置
# =============================================================================
# -----------------------------------------------------------------------------
# 基础开关与路径
# -----------------------------------------------------------------------------
# 是否启用静态资源 CDN 加速
# true: 构建完成后将静态资源上传至 OSS,前端通过 CDN 地址加载
# false: 不启用,静态资源随应用一起部署(不推荐生产环境)
STATIC_CDN_ENABLED=true
# 前端静态资源在 OSS 中的根目录(桶内路径前缀)
# 例如: static / frontend-assets
# 与 STATIC_CDN_BASE_URL 末尾路径保持一致,用于上传时的目录划分
STATIC_CDN_ROOT_PATH=static
# 静态资源的完整访问基础 URL(不含末尾斜杠)
# 用户访问前端时加载 JS/CSS/图片的根地址
# 示例: https://cdn.example.com/static 或 http://minio.example.com:9000/bucket/frontend-assets
STATIC_CDN_BASE_URL=https://cdn.example.com/static
# -----------------------------------------------------------------------------
# OSS 服务商与连接配置
# -----------------------------------------------------------------------------
# OSS 服务商类型(小写)
# 可选: aliyun | tencent | minio | qiniu | s3Compatible
# aliyun=阿里云 OSS, tencent=腾讯云 COS, minio=MinIO, qiniu=七牛云, s3Compatible=兼容 S3 的存储
STATIC_CDN_OSS_PROVIDER=aliyun
# OSS 服务端点(Endpoint)
# 阿里云: oss-cn-shanghai.aliyuncs.com
# 腾讯云: cos.ap-guangzhou.myqcloud.com
# MinIO: minio.example.com 或 宿主机访问容器时用 host.docker.internal
# 七牛: 无需填写 endpoint,使用 region
STATIC_CDN_OSS_ENDPOINT=oss-cn-shanghai.aliyuncs.com
# OSS 存储桶名称(Bucket)
# 需提前在对应云控制台或 MinIO 中创建好
STATIC_CDN_OSS_BUCKET=your_bucket
# OSS Access Key ID(访问密钥 ID)
STATIC_CDN_OSS_ACCESS_KEY=your_access_key_id
# OSS Secret Access Key(访问密钥 Secret)
# 请勿提交到版本库,生产环境建议使用密钥管理服务
STATIC_CDN_OSS_SECRET_KEY=your_secret_access_key
# OSS 区域(Region),部分服务商必填
# 阿里云: oss-cn-shanghai
# 腾讯云: ap-guangzhou
# MinIO/自建: 可留空或填 us-east-1
# 七牛: 如 z0(华东)
STATIC_CDN_OSS_REGION=oss-cn-shanghai
# 自定义域名(可选)
# 若通过自有域名访问 OSS(如 cdn.example.com),在此填写;否则留空,将使用 endpoint + bucket 生成地址
STATIC_CDN_OSS_DOMAIN=
# OSS 服务端口(可选)
# 非标准端口时填写,如 MinIO 默认 9000;标准 80/443 可留空
STATIC_CDN_OSS_PORT=
# 连接 OSS 时是否使用 HTTPS
# true: 使用 https(生产推荐)
# false: 使用 http(本地 MinIO 调试时可设为 false)
STATIC_CDN_OSS_USE_SSL=true
+64 -277
View File
@@ -1,115 +1,70 @@
#############################################
# EasyAI 环境变量配置
# 复制为 .env 并根据实际情况修改
#1、如下配置需要根据实际情况进行配置
##############################################
# 默认服务器地址,本地不需要更改,云服务需要修改为云端IP,并放行对应端口 TODO
NUXT_PUBLIC_BASE_APIURL=http://127.0.0.1:3001
#域名访问使用如下配置,使用/api进行转发,并配置nginx代理/api至3001端口
#NUXT_PUBLIC_BASE_APIURL=/api
# 默认的服务器websocket地址,本地不需要更改,云服务需要修改为云端IP,并放行对应端口 TODO
NUXT_PUBLIC_BASE_SOCKETURL=ws://127.0.0.1:3002
#域名访问使用如下配置,配置为/,并代理/socket.io请求到3002端口
#NUXT_PUBLIC_BASE_SOCKETURL=wss://yourwebsite.com/socket.io
# Agent 服务治理 API 地址,前端管理页面需要。IP访问填http://<IP>:3003,域名访问填/asg-api
NUXT_PUBLIC_SG_APIURL=http://127.0.0.1:3003
#域名访问使用如下配置,通过nginx代理转发
#NUXT_PUBLIC_SG_APIURL=/asg-api
#############################################
# ========== 1. 访问地址(部署时必改) ==========
# 浏览器统一通过 Nginx 同源访问 API
NUXT_PUBLIC_BASE_APIURL=/api
# 后端生成文件、预签名地址和异步任务查询地址时使用的公开 API 根地址。
# 留空时启动/升级脚本会根据本节已有配置初始化;手工维护时必须填写完整 http(s) URL。
CONFIG_PUBLIC_API_BASE_URL=
# 启动/升级脚本的高级覆盖项,适用于已有外部 TLS、特殊端口或手工反向代理。
# 示例:https://api.example.com:8443/api
DEPLOY_PUBLIC_API_BASE_URL=
# 默认 WebSocket 地址;部署脚本会替换为 ws(s)://IP或域名/socket.io
NUXT_PUBLIC_BASE_SOCKETURL=ws://127.0.0.1/socket.io
# Agent 服务治理 API 地址,前端管理页面需要
NUXT_PUBLIC_SG_APIURL=/asg-api
# ========== 2. 服务端口 ==========
#2、以下部分可保持默认,如果没有端口冲突,生产环境可修改密码
##############################################
#comfyAI-web 前端应用暴露端口,访问地址ip:3010访问,初始化管理员账号admin,密码123456
WEB_PORT=3010
# easyai-web 上游端口,由 Nginx 在 80 端口统一代理
EASYAI_PROXY_BIND_IP=127.0.0.1
# Nginx 上游服务的宿主机绑定地址。Linux IP/域名部署保持回环。
EASYAI_INFRA_BIND_IP=127.0.0.1
# MongoDB、RabbitMQ、Dozzle、视频编辑、Agent Memory 等基础设施端口默认仅允许宿主机访问。
# 仅在受控内网且有额外防火墙时才可显式改为其他地址,禁止使用 0.0.0.0 暴露到公网。
SERVER_HTTP_PORT=3001
# easyai-server 后端 HTTP 端口
SERVER_HTTP_BIND_IP=127.0.0.1
# easyai-server 宿主机绑定地址。Linux IP/域名部署经 Nginx 访问,保持 127.0.0.1
NODE_MAX_OLD_SPACE_SIZE=8192
# easyai-server 的 Node.js V8 old-space 堆上限,单位 MiB
MALLOC_ARENA_MAX=2
# 限制 glibc native heap arena,降低 sharp/libvips 场景下 RSS 膨胀
MALLOC_TRIM_THRESHOLD_=131072
# glibc 更积极 trim 空闲 top chunk,降低 native RSS 高水位
MALLOC_MMAP_THRESHOLD_=131072
# 较大的 native 分配更倾向 mmap,释放后更容易归还 OS
MALLOC_TOP_PAD_=131072
# 降低 trim 后额外保留的 heap padding
SHARP_CACHE_ENABLED=false
# 默认关闭 sharp/libvips 全局 cache;需要开启时再配置 SHARP_CACHE_* 参数
SHARP_CONCURRENCY=1
# sharp/libvips 并发,8G 单机建议保持 1
# ws-gateway WebSocket 端口(统一作为对外访问映射端口使用)
CONFIG_WS_PORT=3002
# ws-gateway 容器内部 TCP 微服务监听地址(供 easyai-server 推送事件)
CONFIG_TCP_HOST=0.0.0.0
# ws-gateway 容器内部 TCP 微服务监听端口(默认 4002)
CONFIG_TCP_PORT=4002
#支持静态资源CDN,将静态资源从服务器分离
NUXT_APP_CDN_URL=
#视频编辑对外暴露端口
VIDEO_EDIT_PORT=8000
# 视频编辑服务对外端口
AMS_PORT=3004
# Agent 记忆服务 HTTP 端口(启用 memory profile 时)
# ========== 3. Agent 记忆服务(启用 memory profile 时) ==========
MEMORY_TCP_HOST=agent-memory
MEMORY_TCP_PORT=4004
# easyai-server 调用 agent-memory 的 TCP 连接
# 记忆整理相关参数在 .env.AMS 中配置(MEMORY_MAINTENANCE_*)
# ========== 4. 沙箱环境 ==========
#沙箱环境对外端口,不建议暴露,如果需要暴露,取消docker-compose.yml中的对应注释
SANDBOX_PORT=8081
#SANDBOX jupyterlab 端口
SANDBOX_JUPYTERLAB_PORT=8888
# 配置Jupter的token,安全考虑,建议设置
SANDBOX_JUPYTER_TOKEN=easyaiisbest
# Sandbox API 与 JupyterLab 默认不映射宿主机端口;token 仅用于容器内 Jupyter 认证
SANDBOX_SERVICE_BASE_URL=
# 留空时 easyai-server 通过 Docker 内网 http://sandbox:8000 访问
# ========== 5. Redis ==========
#REDIS暴露端口,默认不暴露
REDIS_PORT=
# 默认不对外暴露
# Redis 容器镜像(可改为国内镜像站或自建仓库)
REDIS_IMAGE=registry.cn-shanghai.aliyuncs.com/comfy-ai/redis-aliyun:latest
CONFIG_COMFYUI_QUENE_REDIS_USERNAME=
CONFIG_COMFYUI_QUENE_REDIS_PASSWORD=
#队列使用的DB
CONFIG_COMFYUI_QUENE_REDIS_DB=6
#普通缓存使用的DB
CONFIG_COMFYUI_CACHE_REDIS_DB=11
# ========== 6. MongoDB ==========
#MONGO 暴露端口,默认不暴露。用户名密码初次部署可以修改,更新请勿修改
MONGO_PORT=27017
# MongoDB 容器镜像(可改为国内镜像站或自建仓库)
MONGO_IMAGE=registry.cn-shanghai.aliyuncs.com/comfy-ai/mongo-aliyun:latest
# 固定 MongoDB 4.4 系列(与旧数据/旧客户端兼容、或需锁定 4.x 行为时使用;升级大版本前请备份并查阅迁移说明):
# MONGO_IMAGE=registry.cn-shanghai.aliyuncs.com/comfy-ai/mongo-aliyun:4.4
MONGO_INITDB_ROOT_USERNAME=username
MONGO_INITDB_ROOT_PASSWORD=password
# 初次部署可修改,更新请勿修改
#comfy-server后端web服务暴露端口。一般情况下无需修改
SERVER_HTTP_PORT=3001
SERVER_WS_PORT=3002
#watchtower 监听端口,自动更新容器和通过浏览器查看容器日志
WATCHTOWER_PORT=8089
PORTAINER_PORT=8090
PORTAINER_HTTPS_PORT=8091
# ========== 7. 消息队列 RabbitMQ ==========
# RabbitMQ 容器镜像(可改为国内镜像站或自建仓库),备选示例:
# RABBITMQ_IMAGE=docker.m.daocloud.io/library/rabbitmq:4-management
# RABBITMQ_IMAGE=docker.dockerproxy.com/library/rabbitmq:4-management
# RABBITMQ_IMAGE=rabbitmq:4-management
RABBITMQ_IMAGE=registry.cn-shanghai.aliyuncs.com/easyaigc/mq:latest
#网络代理,链接gpt等需要设置
CONFIG_PROXY_URL=
#实例ID、集群情况下区分不同客户端
CONFIG_INSTANCE_ID=2025
#消息队列
CONFIG_MQ_PROTOCOL=amqp
CONFIG_MQ_USER=admin
CONFIG_MQ_PASSWORD=easyai2025
@@ -118,193 +73,25 @@ CONFIG_MQ_PORT=5672
CONFIG_MQ_ADMIN_PORT=15672
CONFIG_MQ_VHOST=/
# ========== 8. 鉴权与安全 ==========
CONFIG_TOKEN_EXPIRE=1800
# JWT 签名密钥。启动/升级脚本发现空值、历史默认值或不足 32 字节时会生成并持久化到 .env。
# 也可手动生成:openssl rand -base64 48
CONFIG_JWT_SECRET=
# 后台 JWT 动态配置的 AES-256-GCM 独立主密钥,建议与 JWT 签名密钥分别生成。
# 启动/升级脚本会为新部署生成;数据库中已有的 JWT 轮转配置优先,不会被覆盖。
CONFIG_SECURITY_CONFIG_ENCRYPTION_KEY=
# 独立凭据加密密钥。新安装由脚本生成,重复部署保留;不得复用 JWT 密钥。
# 存量部署首次接入:DEPLOY_ENABLE_CREDENTIAL_ENCRYPTION=true ./update.sh
# 未接入的旧部署保持兼容;已有密文时必须恢复原密钥,禁止重新生成。
CONFIG_CREDENTIAL_ENCRYPTION_ACTIVE_KEY_ID=
CONFIG_CREDENTIAL_ENCRYPTION_KEYRING=
# 新安装 false;存量接入 true,迁移 verify --finalize=true 后关闭。
CONFIG_CREDENTIAL_ALLOW_LEGACY_PLAINTEXT=
# Canvas CLI / Agent WebSocket 短时票据专用签名密钥。
# 启动和升级脚本会在缺失、过短时自动生成并持久化,无需手动填写。
WS_AUTH_WS_TICKET_SECRET=
# 验证码 HMAC、设备/指纹哈希和审计完整性密钥,由启动/升级脚本独立生成并持久化。
CONFIG_OTP_HASH_SECRET=
CONFIG_AUDIT_HASH_PEPPER=
CONFIG_AUDIT_INTEGRITY_KEY=
# 可选:限制允许访问后端的浏览器 Origin,多个值用英文逗号分隔,生产环境禁止使用 *。
# 留空时后端默认允许所有来源;启动/升级脚本不会自动补齐,显式配置时才会校验。
# 域名部署示例:https://yourwebsite.com,https://www.yourwebsite.com
CONFIG_SECURITY_ORIGIN=
# 可信反向代理 IP/CIDR,多个值用英文逗号分隔;未使用反向代理时保持为空。
# 仅填写实际 Nginx/负载均衡地址,不要使用 0.0.0.0/0。
CONFIG_TRUSTED_PROXY=
# 完成身份冲突扫描和人工处置后才能启用手机号、邮箱和微信身份唯一索引。
CONFIG_AUTH_UNIQUE_INDEXES_ENABLED=false
# 仅在数据库首次创建 admin 账号时使用;启动脚本会生成 20 位字母数字随机值并持久化到 .env。
# 首次部署完成页会明确显示登录账号和密码;初始密码没有独立过期时间,首次登录后请立即修改。
# admin 创建后不再使用该值;修改 .env 或执行升级不会重置现有管理员密码。
CONFIG_INITIAL_ADMIN_PASSWORD=
# 海淀教育平台旧 SSO 专用密钥(必须为随机的 16 或 32 字节;不用该集成则保持为空)
CONFIG_TOKEN_SIGN_SK=
# ========== 9. 运维与调试 ==========
CONFIG_INSTANCE_ID=2025
# 集群情况下区分不同客户端
CONFIG_PROXY_URL=
# 连接 GPT 等外部服务时设置
LOG_LEVEL=log,error,warn,debug
# WS 网关专用日志级别(默认生产不输出 debug)
WS_GATEWAY_LOG_LEVEL=log,error,warn
DOZZLE_PORT=8080
WATCHTOWER_PORT=8089
PORTAINER_PORT=8090
PORTAINER_HTTPS_PORT=8091
# ========== 10. WS Gateway 集群背板(Redis) ==========
# 是否启用 ws-gateway Redis 集群背板:true=支持多节点路由,false=仅单机本地投递
GATEWAY_CLUSTER_REDIS_ENABLED=true
# 当前 ws-gateway 节点 ID;单节点可保持默认,集群部署时每个实例必须唯一
GATEWAY_CLUSTER_NODE_ID=easyai-wsgateway-node-1
# 背板 Pub/Sub 频道名;用于节点间转发消息
GATEWAY_CLUSTER_REDIS_CHANNEL=easyai:wsgateway:cluster
# 路由表 key 前缀(记录 channel+clientId 对应的 nodeId)
GATEWAY_CLUSTER_REDIS_ROUTE_PREFIX=easyai:wsgateway:route
# 路由 TTL(秒);连接存活期间会续租,断开后自然过期
GATEWAY_CLUSTER_REDIS_ROUTE_TTL_SEC=120
# Redis 失联后自动重连间隔(毫秒)
GATEWAY_CLUSTER_REDIS_RECONNECT_INTERVAL_MS=5000
# 可选:Redis URL(配置后优先于 host/port/user/password/db)
GATEWAY_CLUSTER_REDIS_URL=
# 以下为 URL 未配置时使用的拆分配置
GATEWAY_CLUSTER_REDIS_HOST=redis
GATEWAY_CLUSTER_REDIS_PORT=6379
GATEWAY_CLUSTER_REDIS_USERNAME=
GATEWAY_CLUSTER_REDIS_PASSWORD=
GATEWAY_CLUSTER_REDIS_DB=0
# ========== 11. easyai-server 发布事件到 ws-gateway(TCP) ==========
# easyai-server 访问 ws-gateway 的 TCP 地址(容器网络内建议写服务名 ws-gateway)
WS_GATEWAY_TCP_HOST=ws-gateway
# easyai-server 访问 ws-gateway 的 TCP 端口(需与 CONFIG_TCP_PORT 一致)
WS_GATEWAY_TCP_PORT=4002
# 事件发布 Pattern,通常保持默认
WS_GATEWAY_TCP_EVENT_PATTERN=gateway.event.publish
# 发布超时时间(毫秒);超时只记录 ERROR,不中断主进程
WS_GATEWAY_TCP_TIMEOUT_MS=1500
# ========== 12. ws-gateway 转发客户端命令到 easyai-server(TCP) ==========
# 拖动画布、协作 presence 等客户端命令由 ws-gateway 转发到 easyai-server 处理
GATEWAY_INBOUND_ENABLED=true
# easyai-server 只在主业务网络固定地址监听,避免 Sandbox 网络访问内部命令入口
GATEWAY_INBOUND_TCP_LISTEN_HOST=172.21.0.6
# ws-gateway 访问 easyai-server 的 TCP 地址;Docker 部署必须使用服务名,不能用 127.0.0.1
GATEWAY_INBOUND_TCP_HOST=easyai-server
# ws-gateway 到 easyai-server 的命令入口端口,对应主服务 HTTP 端口 3001
GATEWAY_INBOUND_TCP_PORT=4001
GATEWAY_INBOUND_TCP_PATTERN=gateway.inbound.command
GATEWAY_INBOUND_TIMEOUT_MS=5000
# ========== 13. WS 会话鉴权(MCP 风格,可选) ==========
# true=客户端必须鉴权后才能建立可用会话;false=允许匿名会话
WS_AUTH_REQUIRED=false
# 鉴权阶段超时时间(毫秒)
WS_AUTH_TIMEOUT_MS=6000
# 可用鉴权方法(逗号分隔):none/bearer/ws_ticket
WS_AUTH_METHODS=none,bearer,ws_ticket
# bearer 令牌列表(逗号分隔,生产环境请使用安全配置中心)
WS_AUTH_BEARER_TOKENS=
# ws_ticket 票据列表(逗号分隔,适合短时授权)
WS_AUTH_WS_TICKETS=
# ========== 14. 静态资源 CDN(可选,默认自动 Brotli) ==========
# auto:优先使用验证通过的 CDN Brotli,其次源站 Brotli/Gzip;off:紧急关闭自动协商
STATIC_ASSET_COMPRESSION=auto
# CDN 公网根地址。保留 NUXT_APP_CDN_URL 兼容旧部署;两者都填写时优先使用 STATIC_CDN_BASE_URL。
STATIC_CDN_BASE_URL=
NUXT_APP_CDN_URL=
# 对象存储中的目录前缀;CDN 根地址应直接指向该目录。根目录部署时留空。
STATIC_CDN_ROOT_PATH=
# 可选:填写后由 EasyAI 自动发布 canonical、Brotli、Gzip 三套资源。
# 不填写时只验证客户已有 CDN;验证失败会自动使用源站,不影响 Web 启动。
# 支持 aliyun、tencent、minio、qiniu、s3Compatible。
STATIC_CDN_OSS_PROVIDER=
STATIC_CDN_OSS_ENDPOINT=
STATIC_CDN_OSS_BUCKET=
STATIC_CDN_OSS_ACCESS_KEY=
STATIC_CDN_OSS_SECRET_KEY=
STATIC_CDN_OSS_REGION=
STATIC_CDN_OSS_PORT=
STATIC_CDN_OSS_USE_SSL=true
STATIC_CDN_FORCE_UPLOAD=false
# ========== 15. 版本 ==========
#版本
VERSION=latest
#日志与调试
LOG_LEVEL=log,error,warn,debug
#Token过期时间,单位秒
CONFIG_TOKEN_EXPIRE=1800
# token加密密钥,可以修改为任意字符串
CONFIG_JWT_SECRET='this is a very secret secret'
CONFIG_TOKEN_SIGN_SK=easyai2025easyai
#minio
#MINIO_ROOT_USER=minioadmin
#MINIO_ROOT_PASSWORD=minioadmin
# ========== 15.1 在线更新 ==========
# 新安装默认使用可查询任务模式;存量环境执行 update.sh 后会自动补齐缺失配置。
# 若已显式配置 SYSTEM_UPDATE_MODE=legacy_watchtower,迁移脚本会保留该选择。
SYSTEM_UPDATE_MODE=managed
COMPOSE_PROFILES=managed-update
WATCHTOWER_CORE_UPDATE_ENABLED=false
# 更新器由独立于任务执行进程的 Watchtower 每 24 小时检查一次,避免自替换中断任务。
WATCHTOWER_UPDATE_TARGET=easyai-updater
UPDATE_MANIFEST_SOURCE=remote
# 留空时使用部署仓库中的官方稳定发布清单;离线部署可显式选择 local。
UPDATE_MANIFEST_URL=
SYSTEM_UPDATER_UPDATE_INTERVAL_SECONDS=86400
WATCHTOWER_CLEANUP=true
# 核心服务替换后最多等待 5 分钟恢复可用。
UPDATE_HEALTH_TIMEOUT_MS=300000
# 私有镜像仓库登录信息;执行 docker login 后通常位于此处。
DOCKER_CONFIG_FILE=/root/.docker/config.json
SYSTEM_UPDATE_INTERNAL_TOKEN=
# 宿主机每六小时分别维护 Watchtower 和 updater;保留 Watchtower 的 updater 更新计划。
INFRASTRUCTURE_AUTO_UPDATE_ENABLED=true
WATCHTOWER_SCOPE=easyai
WATCHTOWER_IMAGE=registry.cn-shanghai.aliyuncs.com/comfy-ai/watchtower-aliyun:latest
# 更新接口及事件接口仅供容器内网访问,由初始化脚本生成独立令牌。
WATCHTOWER_API_KEY=
WATCHTOWER_HTTP_API_EVENTS_TOKEN=
WATCHTOWER_HTTP_API_PERIODIC_POLLS=true
# 留空时使用正式版本服务;测试环境可指向候选版本服务。
APP_VERSION_CHECK_BASE_URL=
EASYAI_DEPLOYMENT_SCHEMA_VERSION=2
# ========== 15.2 3D 模型轻量转换 ==========
# update 脚本自动补齐缺失配置并拉取独立容器,无需手动修改线上配置。
# 可填写已发布版本号固定镜像;latest 跟随正式转换镜像。
MODEL_CONVERSION_WORKER_VERSION=latest
# 安装/升级时自动生成,API 和 Worker 共用。已有强令牌不会重新生成。
MODEL_CONVERSION_WORKER_TOKEN=
# 付费通道默认关闭;自建失败不会自动扣费转入其他通道。
MODEL_CONVERSION_HUNYUAN_ENABLED=false
# ========== 16. OSS 配置(可选,PDF 解析图片上传) ==========
# 可填写 .env.tools 或在此覆盖
# OSS_ENDPOINT=
# OSS_ACCESS_KEY_ID=
# OSS_ACCESS_KEY_SECRET=
# OSS_BUCKET=
# OSS_REGION=us-east-1
# OSS_DOMAIN=
@@ -1,78 +0,0 @@
name: Test deployment public URL (Linux)
on:
push:
paths:
- 'start.sh'
- 'update.sh'
- 'docker-compose.yml'
- 'easyai-proxy.conf.sample'
- 'scripts/*infrastructure*.sh'
- 'scripts/*Infrastructure*.ps1'
- 'scripts/test-infrastructure-auto-update.py'
- 'scripts/update-env.d/*.env'
- '.env.sample'
- 'scripts/init-public-api-base-url.sh'
- 'scripts/test-public-api-base-url.sh'
- 'scripts/init-server-http-bind-ip.sh'
- 'scripts/test-server-http-bind-ip.sh'
- 'scripts/init-security-env.sh'
- 'scripts/init-security-origin.sh'
- 'scripts/check-redis-persistence.sh'
- 'scripts/test-security-origin.sh'
- 'scripts/test-redis-persistence-guard.sh'
- 'scripts/test-deployment-public-url.sh'
- '.github/workflows/test-deployment-public-url.yml'
pull_request:
paths:
- 'start.sh'
- 'update.sh'
- 'docker-compose.yml'
- 'easyai-proxy.conf.sample'
- 'scripts/*infrastructure*.sh'
- 'scripts/*Infrastructure*.ps1'
- 'scripts/test-infrastructure-auto-update.py'
- 'scripts/update-env.d/*.env'
- '.env.sample'
- 'scripts/init-public-api-base-url.sh'
- 'scripts/test-public-api-base-url.sh'
- 'scripts/init-server-http-bind-ip.sh'
- 'scripts/test-server-http-bind-ip.sh'
- 'scripts/init-security-env.sh'
- 'scripts/init-security-origin.sh'
- 'scripts/check-redis-persistence.sh'
- 'scripts/test-security-origin.sh'
- 'scripts/test-redis-persistence-guard.sh'
- 'scripts/test-deployment-public-url.sh'
- '.github/workflows/test-deployment-public-url.yml'
workflow_dispatch:
jobs:
test-linux:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Check shell syntax
run: |
bash -n start.sh update.sh scripts/init-security-env.sh scripts/init-security-origin.sh scripts/check-redis-persistence.sh scripts/init-public-api-base-url.sh scripts/init-server-http-bind-ip.sh scripts/test-security-origin.sh scripts/test-redis-persistence-guard.sh scripts/test-public-api-base-url.sh scripts/test-server-http-bind-ip.sh scripts/test-deployment-public-url.sh
- name: Test migration helper
run: bash scripts/test-public-api-base-url.sh
- name: Test server HTTP bind migration
run: bash scripts/test-server-http-bind-ip.sh
- name: Test startup recovery guards
run: |
bash scripts/test-security-origin.sh
bash scripts/test-redis-persistence-guard.sh
- name: Test deployment dry-run matrix
run: bash scripts/test-deployment-public-url.sh
- name: Test automatic Watchtower maintenance
run: |
python3 scripts/test-infrastructure-auto-update.py
pwsh -NoProfile -File scripts/Test-InfrastructureUpdateTask.ps1
bash scripts/test-update-env-migrations.sh
-97
View File
@@ -1,97 +0,0 @@
# 在 Windows 上测试 start.ps1(DRY_RUN 模式,不启动 Docker)
name: Test start.ps1 (Windows)
on:
push:
paths:
- 'start.ps1'
- 'update.ps1'
- 'scripts/Initialize-PublicApiBaseUrl.ps1'
- 'scripts/Test-PublicApiBaseUrl.ps1'
- 'scripts/Initialize-ServerHttpBindIp.ps1'
- 'scripts/Test-ServerHttpBindIp.ps1'
- 'scripts/Initialize-SecurityOrigin.ps1'
- 'scripts/Test-SecurityOrigin.ps1'
- 'scripts/Assert-RedisPersistence.ps1'
- 'scripts/Test-RedisPersistence.ps1'
- 'scripts/*infrastructure*.sh'
- 'scripts/*Infrastructure*.ps1'
- 'scripts/test-infrastructure-auto-update.py'
- 'scripts/update-env.d/*.env'
- '.env.sample'
- '.github/workflows/test-start-ps1.yml'
pull_request:
paths:
- 'start.ps1'
- 'update.ps1'
- 'scripts/Initialize-PublicApiBaseUrl.ps1'
- 'scripts/Test-PublicApiBaseUrl.ps1'
- 'scripts/Initialize-ServerHttpBindIp.ps1'
- 'scripts/Test-ServerHttpBindIp.ps1'
- 'scripts/Initialize-SecurityOrigin.ps1'
- 'scripts/Test-SecurityOrigin.ps1'
- 'scripts/Assert-RedisPersistence.ps1'
- 'scripts/Test-RedisPersistence.ps1'
- 'scripts/*infrastructure*.sh'
- 'scripts/*Infrastructure*.ps1'
- 'scripts/test-infrastructure-auto-update.py'
- 'scripts/update-env.d/*.env'
- '.env.sample'
- '.github/workflows/test-start-ps1.yml'
workflow_dispatch:
jobs:
test-windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Run start.ps1 (DRY_RUN)
env:
DEPLOY_DRY_RUN: "1"
DEPLOY_IP: "192.168.1.100"
run: |
(Get-Content .env.sample) -replace '^SERVER_HTTP_PORT=.*$', 'SERVER_HTTP_PORT=4100' | Set-Content .env.sample -Encoding UTF8
$output = powershell -ExecutionPolicy Bypass -File .\start.ps1 2>&1 | Out-String
if ($LASTEXITCODE -ne 0) { Write-Host $output; exit $LASTEXITCODE }
$adminPassword = (Select-String -Path .env -Pattern '^CONFIG_INITIAL_ADMIN_PASSWORD=' | ForEach-Object { $_.Line.Substring('CONFIG_INITIAL_ADMIN_PASSWORD='.Length) })
if ([string]::IsNullOrWhiteSpace($adminPassword)) { throw 'Initial admin password was not generated' }
if (-not $output.Contains('登录账号: admin')) { throw 'Deployment output does not show the admin username' }
if (-not $output.Contains("登录密码: $adminPassword")) { throw 'Deployment output does not show the generated admin password' }
if (-not $output.Contains('初始密码没有独立过期时间')) { throw 'Deployment output does not explain the initial password lifetime' }
$existingOutput = powershell -ExecutionPolicy Bypass -File .\start.ps1 2>&1 | Out-String
if ($LASTEXITCODE -ne 0) { Write-Host $existingOutput; exit $LASTEXITCODE }
if (-not $existingOutput.Contains('本次部署不会生成或重置已有密码')) { throw 'Existing-env output does not explain password preservation' }
if ($existingOutput.Contains("登录密码: $adminPassword")) { throw 'Existing-env output must not report the env value as the current password' }
- name: Verify .env
run: |
$api = Select-String -Path .env -Pattern '^NUXT_PUBLIC_BASE_APIURL=' | ForEach-Object { $_.Line }
$socket = Select-String -Path .env -Pattern '^NUXT_PUBLIC_BASE_SOCKETURL=' | ForEach-Object { $_.Line }
$sg = Select-String -Path .env -Pattern '^NUXT_PUBLIC_SG_APIURL=' | ForEach-Object { $_.Line }
$publicApi = Select-String -Path .env -Pattern '^CONFIG_PUBLIC_API_BASE_URL=' | ForEach-Object { $_.Line }
$securityOrigin = Select-String -Path .env -Pattern '^CONFIG_SECURITY_ORIGIN=' | ForEach-Object { $_.Line }
$serverBindIp = Select-String -Path .env -Pattern '^SERVER_HTTP_BIND_IP=' | ForEach-Object { $_.Line }
if ($api -ne 'NUXT_PUBLIC_BASE_APIURL=http://192.168.1.100:4100') { exit 1 }
if ($socket -ne 'NUXT_PUBLIC_BASE_SOCKETURL=ws://192.168.1.100:3002') { exit 1 }
if ($sg -ne 'NUXT_PUBLIC_SG_APIURL=http://192.168.1.100:3003') { exit 1 }
if ($publicApi -ne 'CONFIG_PUBLIC_API_BASE_URL=http://192.168.1.100:4100') { exit 1 }
if ($securityOrigin -ne 'CONFIG_SECURITY_ORIGIN=') { exit 1 }
if ($serverBindIp -ne 'SERVER_HTTP_BIND_IP=0.0.0.0') { exit 1 }
Write-Host "OK: .env 配置正确"
- name: Test public API URL migration helper
run: |
powershell -ExecutionPolicy Bypass -File .\scripts\Test-PublicApiBaseUrl.ps1
- name: Test server HTTP bind migration helper
run: |
powershell -ExecutionPolicy Bypass -File .\scripts\Test-ServerHttpBindIp.ps1
- name: Test startup recovery guards
run: |
powershell -ExecutionPolicy Bypass -File .\scripts\Test-SecurityOrigin.ps1
powershell -ExecutionPolicy Bypass -File .\scripts\Test-RedisPersistence.ps1
- name: Test Watchtower schedule registration contract
run: powershell -ExecutionPolicy Bypass -File .\scripts\Test-InfrastructureUpdateTask.ps1
-12
View File
@@ -1,12 +0,0 @@
.env
# 私有部署密钥、恢复副本与数据库/队列归档
.env.*
*.env
!.env*.sample
.env.pre-credential-restore.*
*.credential-keys.*
data/credential-migration/
data/credential-recovery/
data/backups/
data/restores/
+81 -484
View File
@@ -1,460 +1,13 @@
## 一键部署
### Linux/Mac
```bash
git clone --depth 1 https://git.51easyai.com/wangbo/easyai.git && cd easyai && chmod +x start.sh && ./start.sh
```
也可以通过环境变量直接传入部署方式,脚本会跳过对应问答:
```bash
# IP 访问
DEPLOY_ACCESS=ip DEPLOY_IP=服务器IP ./start.sh
# 域名访问,不申请 HTTPS 证书
DEPLOY_ACCESS=domain DEPLOY_DOMAIN=demo.example.com DEPLOY_HTTPS=false ./start.sh
# 域名访问,并自动申请 HTTPS 证书
DEPLOY_ACCESS=domain DEPLOY_DOMAIN=demo.example.com DEPLOY_HTTPS=true CERTBOT_EMAIL=ops@example.com ./start.sh
```
Linux IP 模式会自动安装并配置 Nginx,浏览器统一访问 `http://IP`。公网只需放行 TCP 80;前端、API、WebSocket 和 ASG 分别通过 `/`、`/api`、`/socket.io`、`/asg-api` 反向代理,容器上游端口默认仅绑定 `127.0.0.1`。
常用变量:
| 变量 | 说明 |
|------|------|
| `DEPLOY_ACCESS` | `ip` 或 `domain`;未传时进入问答选择 |
| `DEPLOY_IP` | IP 模式访问地址,如 `10.0.0.8` |
| `DEPLOY_DOMAIN` | 域名模式访问地址,可带 `http://` 或 `https://`,脚本会自动清理 |
| `DEPLOY_HTTPS` | `true/false`,域名模式下是否申请 HTTPS 证书 |
| `DEPLOY_PUBLIC_API_BASE_URL` | 高级覆盖后端公开 API 地址,例如 `https://api.example.com:8443/api` |
| `CERTBOT_EMAIL` | 申请 HTTPS 证书使用的邮箱,默认 `wangbo@51easyai.com` |
| `DEPLOY_FORCE_RECONFIG` | 非空时即使已有 `.env` 也重新生成部署配置 |
| `DEPLOY_DRY_RUN` | `1` 时只生成配置文件,不安装 Docker、不启动服务 |
| `DEPLOY_NON_INTERACTIVE` | `1` 时缺少必要变量会直接报错,不再进入问答 |
| `DEPLOY_MAC_DOCKER_INSTALL` | macOS 未安装 Docker 时使用,`auto` 自动安装或 `manual` 提示手动安装 |
#### 安全密钥与 Canvas CLI 实时协作
`start.sh` / `start.ps1` 和 `update.sh` / `update.ps1` 会自动初始化并持久化鉴权安全密钥。首次安装或存量升级发现 `WS_AUTH_WS_TICKET_SECRET` 缺失、过短时,会生成新的高强度随机值,并确保 `WS_AUTH_METHODS` 包含 `ws_ticket`;Compose 会把同一密钥同时注入 `easyai-server` 与 `ws-gateway`。已有合格密钥会原样保留,重复启动或升级不会轮换,用户无需手动填写或复制密钥。
生成结果仅保存在本机 `.env`。除首次安装完成页会明确显示登录用的初始管理员密码外,脚本日志对其他安全密钥只显示变量名,不输出密钥内容。不要把 `.env` 提交到 Git;集群部署时,各 `easyai-server` / `ws-gateway` 实例必须使用同一份持久化配置。
#### API Key 加密与旧部署升级
新安装脚本自动生成独立凭据加密密钥;存量部署默认保留兼容模式,未升级脚本的用户只更新兼容镜像也可继续使用原 API Key 和平台凭据。首次接入使用 `DEPLOY_ENABLE_CREDENTIAL_ENCRYPTION=true` 执行新版更新脚本,完成历史迁移后关闭明文读取;已有密文时丢失密钥会拒绝启动。数据库归档与完整原 keyring、配置主密钥/JWT 分开备份;新版提供密钥导出/还原和覆盖前预检。操作步骤、Windows 入口、备份还原顺序和回滚条件见 [API Key 加密部署与旧用户兼容](docs/API_KEY_ENCRYPTION_DEPLOYMENT.md)。
#### 初始管理员密码生命周期
首次安装时,部署脚本会生成 20 位随机密码,写入 `.env` 的 `CONFIG_INITIAL_ADMIN_PASSWORD`,并在部署完成页直接显示:
```text
登录账号: admin
登录密码: <本次部署生成的随机密码>
```
后端只在数据库中不存在 `admin` 时读取该值并创建管理员。创建成功后,`.env` 中的值不再用于登录校验,修改该变量或执行升级都不会重置现有管理员密码。初始密码没有独立的自动过期时间,在管理员主动修改前持续有效,因此首次登录后应立即修改密码。
如果部署脚本检测到已有 `.env`,完成页不会把其中的值误报成当前密码:数据库已有 `admin` 时继续使用现有密码;数据库尚未创建 `admin` 时,可查看 `.env -> CONFIG_INITIAL_ADMIN_PASSWORD`。
#### 后端公开 API 地址
后端返回文件、预签名上传和异步任务查询地址时,按以下优先级确定公开入口:
1. `.env` 中的 `CONFIG_PUBLIC_API_BASE_URL`。
2. 来自可信代理的当前请求地址(仅当请求来源命中 `CONFIG_TRUSTED_PROXY` 时读取转发 Header)。
3. 当前请求的直接 `Host`。
启动和升级脚本会在拉取镜像、重建服务之前校验并持久化 `CONFIG_PUBLIC_API_BASE_URL`:新 IP 部署使用 `http://IP/api`;域名部署中 `DEPLOY_HTTPS=false` 使用 `http/ws`,`DEPLOY_HTTPS=true` 使用 `https/wss`。IP 与域名模式的浏览器接口路径统一为 `/api`、`/socket.io`、`/asg-api`,只由 Nginx 监听地址和公开协议决定 origin。旧环境缺少该配置时,会依次从绝对 `NUXT_PUBLIC_BASE_APIURL`、WebSocket origin、首个 `CONFIG_SECURITY_ORIGIN` 推导。已有合法值保持不变,非法或无法推导时脚本会终止并提示修复。
`CONFIG_SECURITY_ORIGIN` 是可选的浏览器来源白名单:留空或不配置时后端默认允许所有来源,启动和升级脚本不会自动生成;显式配置一个或多个 http(s) Origin 时,脚本会校验格式并由后端按白名单限制访问。
已有外部 TLS、手工代理、自定义公开端口或特殊路径时,可在执行脚本前设置高级覆盖:
```bash
DEPLOY_PUBLIC_API_BASE_URL=https://api.example.com:8443/custom-api ./update.sh
```
也可以直接在 `.env` 中维护:
```dotenv
CONFIG_PUBLIC_API_BASE_URL=https://api.example.com:8443/api
CONFIG_TRUSTED_PROXY=127.0.0.1,10.0.0.0/8
```
新的 `easyai-proxy.conf.sample` 会为 `/api/` 和 `/mcp` 转发保留客户端 authority 中的显式端口,并补齐 `X-Forwarded-Host`、`X-Forwarded-Port`;`/api/` 继续发送 `X-Original-Prefix: /api`。脚本不会改写已经生成的本地 Nginx 配置,旧部署可先通过显式 `CONFIG_PUBLIC_API_BASE_URL` 生效,再按模板手工同步代理 Header。
模板同时为 JSON、CSS、`application/javascript` 和 Nuxt 常用的 `text/javascript` 开启 Gzip;`text/event-stream` 未加入压缩类型,SSE 和 WebSocket 保持流式传输。已经生成的域名配置不会随模板自动更新,旧部署需要手工把 `text/javascript` 加入现有 `gzip_types`,执行 `nginx -t` 成功后再无中断 reload。
前端静态资源默认使用 `STATIC_ASSET_COMPRESSION=auto`。浏览器支持 Brotli 时优先使用经过哈希和响应头验证的 CDN Brotli 资源;客户 CDN 尚未配置编码资源或验证失败时,自动切回 Nuxt 源站的 Brotli/Gzip,不会阻塞 Web 启动。旧部署更新 `docker-compose.yml` 后无需修改 `.env` 即可使用默认值。
私有化客户可继续只配置 `NUXT_APP_CDN_URL`。如果 CDN 已支持 canonical URL 的 Brotli,EasyAI 会自动识别;如果希望 EasyAI 代为发布三套资源,再填写 `.env.sample` 中的 `STATIC_CDN_OSS_*` 最小读写凭据。上传对象使用 `__easyai_encoding/br` 和 `__easyai_encoding/gzip` 固定前缀,并携带正确的 `Content-Encoding`。也可由客户自己的流水线按该目录协议上传,EasyAI只负责验证。紧急回退可设置 `STATIC_ASSET_COMPRESSION=off`。
CDN 验证结果保存在 `./data/cdn-init`,并与当前前端构建标识绑定;升级后旧结果会被忽略。任何密钥都只应放在本机 `.env` 或安全配置中心,不要提交到仓库。
`https.sh` 单独执行时也可以跳过域名选择:
```bash
DEPLOY_DOMAIN=demo.example.com EASYAI_PROXY_CONF=demo.example.com.conf CERTBOT_EMAIL=ops@example.com ./https.sh
```
如需对已有 Nginx 配置追加或替换域名,可设置 `EASYAI_PROXY_DOMAIN_ACTION=add|replace`;未设置时默认追加。
### Windows
在 **PowerShell** 中执行:
```powershell
git clone --depth 1 "https://git.51easyai.com/wangbo/easyai.git" "easyai"; Set-Location "easyai"; powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".\start.ps1"
```
Windows 也支持变量方式跳过选择:
```powershell
# 本机访问
$env:DEPLOY_ACCESS = "local"
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".\start.ps1"
# 局域网访问
$env:DEPLOY_IP = "192.168.1.10"
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".\start.ps1"
# 仅生成配置,不启动 Docker
$env:DEPLOY_IP = "127.0.0.1"
$env:DEPLOY_DRY_RUN = "1"
$env:DEPLOY_NO_WAIT = "1"
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".\start.ps1"
```
> **Windows 脚本权限说明**:PowerShell 默认禁止运行脚本,直接双击 `start.ps1` 会闪退。
>
> - **推荐**:使用 `powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".\start.ps1"` 执行,无需修改系统策略
> - **或**:以管理员身份打开 PowerShell,执行 `Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser`,之后可直接运行 `.\start.ps1`
---
🚀部署问题扫码咨询
![img.png](img.png)
## 更新升级
### 老用户(旧版本部署包)升级教程
如果你当前使用的是较早版本的部署包,**无法在旧包目录里直接执行 `git pull`** 来获取配置文件,建议按下面流程升级:
1. 先停止并下线旧项目容器:
```bash
cd ~/easyai
docker compose down
```
如果你的环境使用的是旧命令,请改用:
```bash
docker-compose down
```
2. 将旧目录重命名备份(示例改名为 `easyai2`):
```bash
cd ~
mv easyai easyai2
```
3. 重新执行一键部署命令,按脚本提示完成初始化输入。
如果希望跳过问答,可直接传入变量:
```bash
DEPLOY_ACCESS=ip DEPLOY_IP=服务器IP ./start.sh
# 或
DEPLOY_ACCESS=domain DEPLOY_DOMAIN=demo.example.com DEPLOY_HTTPS=false ./start.sh
```
4. 当脚本询问是否启用 HTTPS 时:
- 如果你之前已经有可用证书,填写 `N`(不生成新证书)。
- 只有在需要新申请证书时,才选择启用并生成证书。
5. 完成上述迁移后,后续若有新的配置文件或版本更新,可直接在新的 `easyai` 目录中执行 `./update.sh` 进行更新。
update.sh 脚本用于自动更新 EasyAI 应用,包含以下功能:
- **拉取整个仓库**:执行 `git pull` 获取最新代码(docker-compose.yml、start.sh、.env.*.sample 等全部文件)
- 自动补齐缺失的环境配置文件(.env、.env.tools、.env.ASG、.env.AMS,从 .sample 生成且不覆盖已有文件)
- 每次默认执行 `scripts/update-env.d/` 中的版本化环境配置迁移,只补缺失/空值和必要列表项,不覆盖已有非空配置
- 自动初始化 3D 模型转换内部令牌,拉取并启动独立 `model-conversion-worker`;通过数据库、队列连接和 Worker 健康检查后才显示更新成功。
- 仓库版本发生变化时自动切换到拉取后的新版更新脚本继续执行,确保本次新增的迁移立即生效
- 在重建服务前迁移并校验 `CONFIG_PUBLIC_API_BASE_URL`
- 兼容 `docker compose` 和 `docker-compose` 两种命令格式
- 自动拉取最新镜像并重启服务
- 等待主服务、Web 和 WebSocket 网关恢复可用后才报告成功;拉取失败、启动失败或恢复超时会明确报告失败
新安装默认启用可查询进度的在线更新模式。管理员在页面发起更新后,即使主服务重启导致原请求断开,页面也会根据任务编号继续查询最终结果。存量部署如果没有 `easyai-updater` 容器,更新脚本会保留 Watchtower 模式;已经显式设置 `SYSTEM_UPDATE_MODE=legacy_watchtower` 的环境也保持该模式。只有新安装默认启用 updater。新模式只管理 `easyai-server`、`easyai-web` 和 `ws-gateway`,数据库、Redis、RabbitMQ 等基础服务仍按原部署流程维护。
更新执行器 `easyai-updater` 由 Watchtower 默认每 24 小时检查更新,同时由宿主机定时任务每六小时直接检查更新,提供两条独立维护路径。它的任务状态保存在宿主机 `data/update-state` 中,自身更新或意外重启后会继续恢复未完成任务;Watchtower 不再接管三个核心服务,因此页面任务结果不会与后台静默替换相互冲突。
受控更新默认直接获取部署仓库 `main` 分支的 `system-update/release-manifest.json`,无需先在服务器执行 `git pull`。页面检测、手动更新和自动更新使用同一发布清单;任务开始后将清单保存在任务记录中,服务重启恢复时继续使用原目标。发布新正式镜像时必须同步发布清单中的不可变摘要、镜像大小和版本标识,不能只修改官方站运行版本或推送 `latest` 镜像。
发布源不可用时会提示暂时无法检查更新,并保留当前服务;不会用历史清单宣称已是最新。`UPDATE_MANIFEST_URL` 可配置私有发布源,留空时使用官方部署仓库。离线部署可显式设置 `UPDATE_MANIFEST_SOURCE=local`,此时读取 Compose 只读挂载的本地清单;需要自行维护该清单。旧部署中的 `UPDATE_MANIFEST_FILE` 路径覆盖不再影响默认远程模式。
新安装或已有 updater 的部署无需手工编辑 `.env`。更新脚本会从 `scripts/update-env.d/010-managed-system-update.env` 应用以下缺省值:
```dotenv
SYSTEM_UPDATE_MODE=managed
COMPOSE_PROFILES=managed-update
WATCHTOWER_CORE_UPDATE_ENABLED=false
WATCHTOWER_UPDATE_TARGET=easyai-updater
SYSTEM_UPDATER_UPDATE_INTERVAL_SECONDS=86400
UPDATE_HEALTH_TIMEOUT_MS=300000
DOCKER_CONFIG_FILE=/root/.docker/config.json
EASYAI_DEPLOYMENT_SCHEMA_VERSION=2
```
`SYSTEM_UPDATE_INTERNAL_TOKEN` 由 `start.sh` / `update.sh` 自动生成并持久化,不需要手工填写。`UPDATE_HEALTH_TIMEOUT_MS` 控制每个核心服务替换后的恢复等待时间,默认 300000 毫秒。私有镜像仓库需要先执行 `docker login`;更新器与仅负责其自更新的 Watchtower 都会只读挂载 `DOCKER_CONFIG_FILE` 指向的 Docker 登录配置。切换后执行一次 `./update.sh`,确认 `easyai-updater` 以及三个核心服务均处于可用状态。
Watchtower 默认使用 `latest`。`start.sh/update.sh` 自动安装宿主机 cron,`start.ps1/update.ps1` 自动注册 Windows 计划任务;每六小时分别对 Watchtower 和已启用的 updater 执行 `docker compose pull <服务>`、`docker compose up -d --no-deps <服务>` 并确认健康状态。只在镜像或配置有变化时重建,拉取失败不会停止该服务的现有容器。一项维护失败仍继续处理另一项,日志保留各自结果并报告失败;因此 updater 的宿主机维护不依赖 Watchtower 可用。未启用 updater 的 legacy 部署跳过 updater,不自动切换更新模式。Watchtower 的 updater 24 小时更新计划保持不变,无需额外容器。Windows 任务使用当前登录用户,以便访问其 Docker Desktop;Docker 不可用时保留失败日志,下次计划重试。
内网启用更新、事件、历史、状态和健康接口,不映射主机端口。`WATCHTOWER_API_KEY` 与 `WATCHTOWER_HTTP_API_EVENTS_TOKEN` 是两个独立随机令牌,由 Bash 和 PowerShell 安全初始化流程补齐;浏览器只访问后端。新安装及默认整组升级使用独立 `WATCHTOWER_SCOPE`(`easyai`),防止启动时清理其他部署的 Watchtower;定向升级和自定义监控目标保留原 scope,避免未重建的容器标签与新范围不匹配。managed 模式只扫描 updater,legacy 模式仍只在明确提交业务更新时执行原更新链路。
定时任务按部署目录去重,重复运行部署脚本不会重复注册。最近一次结果写入 `data/infrastructure-update/last-run.log`。设置 `INFRASTRUCTURE_AUTO_UPDATE_ENABLED=false` 后再次执行部署脚本可移除本部署的定时任务;不改变 updater 的原有维护计划。启用时,官方镜像的旧版本标签自动迁移到 `latest`,自定义仓库或镜像摘要保持原值。旧客户需先由运维执行一次新版部署脚本,之后不用客户手工升级 Watchtower;只发布仓库不会自动把宿主机定时任务安装到未接入的机器。发布到官方 Watchtower `latest` 的镜像应先通过部署及旧接口兼容验收。
两个页面入口共用计时和步骤窗口。updater 保存阶段完成时间到 `data/update-state/jobs`;无 updater 时后端保存任务和单任务锁到 MongoDB。刷新或重新登录后按站点与账号恢复任务查询。请求超时或服务断开不会自动创建另一项更新,旧接口无法读取的阶段会明确提示。后端重启时页面保留进度,提醒在 3–5 分钟后刷新检查版本;只有确认目标版本和服务恢复才显示完成。
正式版本检测默认仍使用原来的 `https://openapi.51easyai.com`。测试验收可在后端设置 `APP_VERSION_CHECK_BASE_URL`,该地址应提供与正式地址相同格式的 `/app/version` 响应;正式发布前必须撤销测试覆盖。候选镜像用版本标签发布,验收后才将相同摘要提升为 `latest`。源码、镜像、环境迁移、三种实际更新验收和回退证据记录在 `system-update/releases/`,其中不得包含令牌或登录凭据。
以后新增必需环境变量时,在 `scripts/update-env.d/` 增加新的编号 `.env` 文件即可,不需要继续把写入逻辑堆进 `update.sh`。迁移文件支持 `KEY=value`(仅在缺失或空值时写入)、`KEY+=item`(确保逗号列表包含该项),以及 `@skip-if KEY=value`(明确保留某种旧模式);所有迁移每次都会执行并保持幂等。迁移失败会在拉取镜像前终止,避免使用半完成配置启动服务。
### 使用步骤
1. [首次执行,后续无需重复执行]添加执行权限,命令:
```bash
chmod +x update.sh
```
2. 执行更新(默认会 `git pull` 拉取整个仓库)
```bash
./update.sh
```
> **注意**:update.sh 需要在 Git 克隆的目录下运行。若通过 zip 下载而非 git clone,请先使用 `git clone` 获取项目。
### 使用方式
- 执行 `./update.sh` 后会**命令行内选择**更新方式:
- `[1]` 更新并拉取仓库(git pull)+ 更新镜像并重启(**默认**,回车即选)
- `[2]` 仅更新镜像并重启(跳过 git pull,适用于有本地修改不想被覆盖的场景)
- API Key 加密首次接入需要新版脚本及历史迁移,请按 [加密升级说明](docs/API_KEY_ENCRYPTION_DEPLOYMENT.md) 执行;只有尚未接入加密的旧部署可以继续仅更新兼容镜像。
- 如果本次**不涉及配置文件更新**(如 `.env*`、`docker-compose.yml`、`easyai-proxy.conf*` 无变更),可直接选择 `[2]`,仅更新后台服务镜像并重启即可。
- 自动化更新可通过变量跳过选择:
```bash
# 更新仓库 + 拉取镜像 + 重启(等同默认选 1)
UPDATE_MODE=full ./update.sh
# 仅拉取镜像 + 重启(等同选 2)
UPDATE_MODE=image ./update.sh
# 或使用布尔变量
UPDATE_SKIP_REPO_UPDATE=true ./update.sh
```
3D 转换服务首次上线或仅更新指定服务时,可直接使用同一个入口:
```bash
UPDATE_MODE=full UPDATE_NON_INTERACTIVE=1 \
UPDATE_SERVICES='easyai-server easyai-web model-conversion-worker' ./update.sh
```
脚本拉取部署仓库后会切换到新版脚本,补齐缺失的转换配置;已有令牌、端口、数据卷和显式付费选择保持原值。指定 `UPDATE_SERVICES` 时只重建这些服务,不重建数据库等依赖;服务名称有误会在拉取镜像前终止。普通更新不指定该变量时保留原来的整组更新行为。转换容器只加入业务网络,不开放主机端口,不连接沙箱网络,不挂载 Docker socket,也不读取整个生产 `.env`。默认限额为 2 CPU、4 GiB、并发 1、单任务 300 秒、临时空间 2 GiB;原模型始终保留,轻量转换失败可使用原模型。
`MODEL_CONVERSION_WORKER_VERSION` 可以固定已发布的版本;默认 `latest`。混元付费通道默认关闭,自建转换失败不自动切换到付费服务。已有 updater 的页面更新仍按稳定清单管理三个核心服务;转换 Worker 的首次安装和配置升级使用上述 `update` 脚本完成。
测试候选版本时使用 `UPDATE_RELEASE_VERSION=3.23.0.20261003`,由更新脚本固定核心服务和转换 Worker 的版本,无需改动远端 `.env` 或移动 `latest` 标签。版本号不合法时在修改配置前终止。
Linux 旧部署如果因本地 Compose 修改无法拉取仓库,可在检查差异后设置 `UPDATE_ADOPT_LEGACY_CONFIG=1`。脚本需要 Python 3 和 PyYAML;先校验合并配置,再将原始配置和冲突文件备份到 `data/deployment-backups/`,本地差异保存在 `data/deployment-config/legacy-compose.json`,仓库继续管理新增服务和配置。其他业务代码修改、未支持的文件冲突或无法表示的删除会阻止迁移,不启动容器。活动 Nginx 配置不会被改写。
迁移默认保留本地固定镜像。如果已确认某个核心服务的旧 digest 应恢复为部署仓库的镜像定义,可显式指定该服务;只允许本次更新范围中的 `easyai-server`、`easyai-web` 或 `ws-gateway`,其他镜像和本地配置继续保留:
```bash
UPDATE_MODE=full UPDATE_NON_INTERACTIVE=1 \
UPDATE_SERVICES='easyai-server easyai-web' \
UPDATE_ADOPT_LEGACY_CONFIG=1 \
UPDATE_RESTORE_REPOSITORY_IMAGES='easyai-web' ./update.sh
```
该操作由脚本备份并迁移本地 Compose 差异,随后快进部署仓库;不需要直接编辑生产 Compose。执行前须检查所有有效 Compose 文件;如果已存在覆盖文件固定同一服务的镜像,不能仅恢复主文件就宣称已解除固定。更新后须检查有效配置中的镜像及容器实际 digest。
没有自更新能力的历史脚本可先通过 Git 获取新版 `update.sh` 和 `scripts/` 到临时目录,再以 `UPDATE_PROJECT_DIR=/实际/easyai/目录` 执行新版脚本;配置迁移、仓库更新和容器操作仍由 `update.sh` 完成。已有 managed updater 应使用仓库分配的独立内网地址,避免在主后端暂时停止时占用其固定地址。
- **查看帮助**:`./update.sh -h` 或 `./update.sh --help`
### 更新说明
- 脚本会执行 `git pull` 拉取整个仓库最新代码
- 拉取后会检查并补齐缺失的 .env、.env.tools、.env.ASG、.env.AMS(不会覆盖已有文件)
- 最后执行 `docker compose pull` 和 `docker compose up -d` 拉取镜像并重启服务
- 默认最多等待 300 秒确认核心服务恢复可用;可通过 `UPDATE_HEALTH_TIMEOUT_SECONDS` 调整
### Windows 用户(update.ps1)
Windows 下使用 `update.ps1`,功能与 Linux 版一致:
```powershell
.\update.ps1
```
- 执行后会**命令行内选择**:`[1]` 更新并拉取仓库 + 更新镜像(默认);`[2]` 仅更新镜像
- 需在 Git 克隆的 easyai 目录下运行
- 也可通过变量跳过选择:
```powershell
$env:UPDATE_MODE = "image"
$env:UPDATE_NO_WAIT = "1"
.\update.ps1
```
---
## 重要更新记录:
### 2026.07.16
1. **公开 URL 稳定化**:新增 `CONFIG_PUBLIC_API_BASE_URL`,后端文件、预签名和任务查询地址不再依赖 Redis 中缓存的首次请求 Host。
2. **新装与升级迁移**:Linux/macOS 和 Windows 启动、升级脚本会在重建服务前初始化并校验公开 API 地址,支持 IP 自定义 `SERVER_HTTP_PORT`、HTTP/HTTPS 域名和 `DEPLOY_PUBLIC_API_BASE_URL` 高级覆盖。
3. **代理端口透传**:新 Nginx 模板为 `/api/`、`/mcp` 补齐公开 Host/Port Header;已有生成配置不会自动改写。
### 2026.07.15
1. **主服务 Node.js 堆内存上限**:`easyai-server` 默认通过 `NODE_OPTIONS` 将 V8 old-space 堆上限设置为 8192 MiB,可在 `.env` 中使用 `NODE_MAX_OLD_SPACE_SIZE` 覆盖。该值只限制 V8 堆,不包含 Buffer、sharp/libvips 等 native 内存。
2. **生效要求**:该参数仅在创建 Node.js 进程时读取,更新部署文件后需要在合适的维护窗口重建 `easyai-server` 容器。
### 2026.06.24
1. **主服务 sharp/native 内存护栏**:`easyai-server` 默认关闭 sharp/libvips cache,并将 sharp 并发限制为 1;同时设置 `MALLOC_ARENA_MAX=2`、`MALLOC_TRIM_THRESHOLD_=131072`、`MALLOC_MMAP_THRESHOLD_=131072`、`MALLOC_TOP_PAD_=131072`,降低图片处理和大 Buffer 场景下 native RSS 持续膨胀风险。
2. **重 native 操作隔离**:遮罩合并、alpha 反转等 sharp/raw 操作改为一次性子进程执行,处理完成后由 OS 回收子进程 native heap,避免主服务 `[heap]` 被撑大。
3. **生图 base64 输出收敛**:模型返回的 base64 图片会先上传到 OSS,返回对象和 raw/debug 响应中不再长期保留大 base64 字符串。
4. **重启要求**:该内存护栏必须在新进程启动时生效,更新后需执行 `docker compose up -d easyai-server` 或重建并重启主服务容器。
### 2026.06.22
1. **Redis 资源护栏**:Redis 增加 `./data/redis:/data` 持久化挂载、`maxmemory 2gb`、`appendfsync everysec`,并禁用 RDB 快照,避免队列历史膨胀后占满内存和容器写层。
2. **Mongo/主服务内存限制**:Mongo 增加 `--wiredTigerCacheSizeGB 1.5` 和容器内存限制,`easyai-server` 增加内存限制,避免 8G 机器上 Redis/Mongo/Node 相互挤压触发 OOM。
3. **升级前置要求**:如果线上 Redis 之前没有 `/data` 持久化挂载,必须先迁移数据,再重建容器。Linux 和 Windows 的启动、升级脚本都会在拉取镜像前检查现有 Redis 的挂载位置,不一致时停止部署。
#### Redis 数据迁移与重启步骤
执行前先做云盘快照或外部备份,并确认可用磁盘空间。不要删除或重建旧 Redis 容器,直到数据已经迁出并校验。
```bash
cd ~/easyai
# 1. 确认旧容器的 /data 未挂载到当前项目的 data/redis,并备份现有配置。
docker inspect --format '{{range .Mounts}}{{if eq .Destination "/data"}}{{.Source}}{{end}}{{end}}' redis
# 2. 停止 Redis 后复制一致的数据目录;保留旧容器,直到新容器验收完成。
docker stop --timeout 60 redis
mkdir -p ./data/redis
docker cp redis:/data/. ./data/redis/
# 3. 将 ./data/redis 再备份到其他磁盘或安全位置,并校验 AOF 文件与 manifest。
# 若目标目录原本非空,先核对来源,不要覆盖。
# 4. 确认 docker-compose.yml 已包含 ./data/redis:/data,检查配置并只重建 Redis。
docker compose config --quiet
docker compose up -d --no-deps redis
docker exec redis redis-cli ping
docker inspect --format '{{range .Mounts}}{{if eq .Destination "/data"}}{{.Source}}{{end}}{{end}}' redis
```
迁移后核对 Redis 数据库键数、`aof_last_write_status:ok`、后端 `/content` 和网站首页;后端若未自动恢复,再单独重启后端容器。队列历史清理应单独评估,不作为迁移的默认步骤。
### 2026.3.20
1. **新增 Agent 记忆服务(AMS)模块**:新增 `agent-memory`(Agent 长期记忆)容器,复用 `easyai-pgvector` 数据库(需 pgvector 扩展),用于支持 Agent 对话记忆、向量召回与反馈能力。
2. **新增环境变量文件**:新增 `.env.AMS.sample` 文件,包含记忆服务所需的全部环境变量。
3. 主服务 `easyai-server` 新增 `MEMORY_TCP_HOST` 和 `MEMORY_TCP_PORT` 环境变量,用于内部 TCP 微服务通信。
4. **Nginx 代理**:`easyai-proxy.conf.sample` 中新增 `/ams-api/` 路径代理(可选)。
#### 升级步骤
**步骤一:更新文件**
将以下文件更新到最新版本:
- `docker-compose.yml`
- `easyai-proxy.conf.sample`
- `docker/postgres/init-pgvector.sql`(PostgreSQL 首次启动时创建 vector 扩展)
新增文件复制到部署目录:
- `.env.AMS.sample` → 复制为 `.env.AMS` 并根据实际环境修改
**步骤二:配置 `.env.AMS`**
```bash
cp .env.AMS.sample .env.AMS
```
根据实际环境修改 `.env.AMS` 中的关键配置:
```dotenv
# PostgreSQL 连接(独立库 easyai_memory,与 .env.ASG 中账号密码一致)
MEMORY_DATABASE_URL=postgresql://easyai:easyai2025@easyai-pgvector:5432/easyai_memory?schema=public
# 主服务 API 地址(用于 Embedding 与对话压缩)
MEMORY_AI_BASE_URL=http://easyai-server:3001
# Embedding 模型与维度(需与主服务一致)
MEMORY_EMBEDDING_DIMENSION=1024
MEMORY_EMBEDDING_MODEL=text-embedding-v4
# 每日记忆整理(默认每天凌晨 2 点执行)
MEMORY_MAINTENANCE_ENABLED=true
# 升级首日建议 true,确认无误后再改为 false
MEMORY_MAINTENANCE_DRY_RUN=false
# 单轮每租户处理上限
MEMORY_MAINTENANCE_BATCH_SIZE=200
# 启用语义冲突裁决(复用会话总结模型,无需单独配置模型)
MEMORY_MAINTENANCE_LLM_ENABLED=true
MEMORY_MAINTENANCE_LLM_MAX_PAIRS=20
```
说明:
- `MEMORY_MAINTENANCE_ENABLED`:总开关,`false` 时不执行每日整理。
- `MEMORY_MAINTENANCE_DRY_RUN`:`true` 仅记录分析和审计,不执行软删除。
- `MEMORY_MAINTENANCE_LLM_ENABLED`:开启后冲突处理优先使用会话总结模型做语义裁决,失败会回退规则处理。
- `MEMORY_MAINTENANCE_LLM_MAX_PAIRS`:单轮最多调用模型裁决的冲突对数,用于控制成本。
**步骤三:启动服务**
记忆服务使用 Docker Compose Profile,需显式启用:
```bash
cd ~/easyai
# 启动全部服务(含记忆服务)
docker compose --profile memory up -d
# 或仅启动基础服务(不含记忆)
docker compose up -d
```
新增容器:`agent-memory`(Agent 记忆服务),复用 `easyai-pgvector`,使用独立库 `easyai_memory`。初始化流程与 ASG 一致:
- **PostgreSQL 首次启动**:`docker/postgres/init-pgvector.sql` 创建 `vector`、`pgcrypto` 扩展
- **easyai-asg 启动**:entrypoint 执行 `prisma migrate deploy`,创建治理相关表
- **agent-memory 启动**:entrypoint 执行 `prisma migrate deploy`,创建 `memory_records` 等表
**注意**:`easyai-pgvector` 使用 `registry.cn-shanghai.aliyuncs.com/easyaigc/pgvector:0.8.2-pg18-trixie`(含 pgvector 扩展)。若此前使用其他镜像且已有数据,升级前请备份 `asg_postgres_data` 卷。
**步骤四:验证**
```bash
# 检查容器状态
docker compose ps agent-memory
# 检查记忆服务健康状态
curl http://127.0.0.1:3004/health
# 通过 Nginx 代理访问(配置 Nginx 后)
curl https://<你的域名>/ams-api/health
```
> **注意**:记忆服务默认不启动,使用 `docker compose --profile memory up -d` 显式启用。若不需要 Agent 记忆功能,保持 `docker compose up -d` 即可,主服务会正常运行(记忆相关能力不可用)。
---
### 2026.3.2
1. **新增 Agent 服务治理(ASG)模块**:新增 `easyai-asg`(Agent 服务治理)容器和独立的 `easyai-pgvector`(PostgreSQL 18)数据库容器,用于支持 Agent 自动化治理能力。
1. **新增 Agent 服务治理(ASG)模块**:新增 `easyai-asg`(Agent 服务治理)容器和独立的 `easyai-asg-pg`(PostgreSQL 18)数据库容器,用于支持 Agent 自动化治理能力。
2. **新增 Nginx 反向代理**:在 `easyai-proxy.conf.sample` 中新增 `/asg-api/` 路径代理,用于暴露 ASG 服务的 REST API。
3. **新增环境变量文件**:新增 `.env.ASG.sample` 文件,包含 ASG 服务所需的全部环境变量。
4. 主服务 `easyai-server` 新增 `ASG_TCP_HOST` 和 `ASG_TCP_PORT` 环境变量,用于内部 TCP 微服务通信。
4. 主服务 `comfy-server` 新增 `ASG_TCP_HOST` 和 `ASG_TCP_PORT` 环境变量,用于内部 TCP 微服务通信。
5. **前端新增环境变量**:`.env` 中新增 `NUXT_PUBLIC_SG_APIURL`,用于前端治理管理页面调用 ASG API。
#### 升级步骤
@@ -488,7 +41,7 @@ cp .env.ASG.sample .env.ASG
根据实际环境修改 `.env.ASG` 中的关键配置:
```dotenv
# PostgreSQL 连接(默认使用容器内网地址,一般无需修改)
ASG_DATABASE_URL=postgresql://easyai:easyai2025@easyai-pgvector:5432/agent_governance?schema=public
ASG_DATABASE_URL=postgresql://easyai:easyai2025@easyai-asg-pg:5432/agent_governance?schema=public
ASG_POSTGRES_USER=easyai
ASG_POSTGRES_PASSWORD=easyai2025
@@ -497,7 +50,7 @@ ASG_REDIS_HOST=redis
ASG_REDIS_PASSWORD= # 与主服务 Redis 密码保持一致
# 主服务连接地址(容器内网地址)
ASG_MAIN_BACKEND_URL=http://easyai-server:3001
ASG_MAIN_BACKEND_URL=http://comfy-server:3001
# 管理员账号(用于 Agent 调用时登录获取 token)
ASG_ADMIN_USERNAME=admin
ASG_ADMIN_PASSWORD=123456
@@ -532,13 +85,13 @@ cd ~/easyai
docker compose up -d
```
新增容器:`easyai-pgvector`(PostgreSQL)和 `easyai-asg`(Agent 服务治理),数据库会在首次启动时自动完成初始化和迁移。
新增容器:`easyai-asg-pg`(PostgreSQL)和 `easyai-asg`(Agent 服务治理),数据库会在首次启动时自动完成初始化和迁移。
**步骤五:验证**
```bash
# 检查容器状态
docker compose ps easyai-pgvector easyai-asg
docker compose ps easyai-asg-pg easyai-asg
# 检查 ASG 服务健康状态
curl http://127.0.0.1:3003/health
@@ -555,18 +108,34 @@ curl https://<你的域名>/asg-api/health
1. 增加脚本沙箱环境容器,需要更新`docker-compose.yml`,用以支持SKILL中的脚本运行
2. 增加环境变量配置
```dotenv
SANDBOX_SERVICE_BASE_URL= #脚本运行环境使用的服务地址,默认通过 Docker 内网访问 http://sandbox:8000,不需要配置
SANDBOX_PORT=8081 #对外暴露沙箱环境的端口,不建议暴露,权限较高
SANDBOX_SERVICE_BASE_URL= #脚本运行环境使用的服务地址,默认为通过内网直接访问http://sandbox:8000,不需要配置。当将sandbox部署在其他外部网络才需要配置
```
> 安全默认值:Sandbox API 与 JupyterLab 不映射宿主机端口,Nginx 模板对 `/api/sandbox`、`/sandbox`、`/jupyterlab` 返回 404。Sandbox 使用独立 Docker 网络,不能访问 MongoDB、Redis、RabbitMQ 与治理服务。不要在公网重新开放这些高权限入口,也不要把 Sandbox 接回主业务网络。
### 2025.1.29
1. 优化日志管理功能,使用单独的容器和模块来进行管理,不占用主进程文件写入性能
2. Dozzle 端口默认绑定 `127.0.0.1`,Nginx 对 `/logs-web` 返回 404,禁止把容器日志查看器直接代理到公网。
3. 需要查看日志时,在受信任终端建立 SSH 隧道后访问本机端口,或直接使用 `docker logs`。不要依赖 Dozzle 的无认证模式或默认口令。
4. 日志下载功能默认关闭,避免容器日志被批量导出。
2. 更新步骤:
- 本次`docker-compose.yml`新增dozzle容器,将`docker-compose.yml`文件更新到最新
- 在nginx配置文件中(/etc/nginx/conf.d/easyai-proxy.conf)添加如下配置,具体添加位置参考`easyai-proxy.conf.sample`
```nginx configuration
location /logs-web/ {
proxy_pass http://127.0.0.1:8080/logs-web/;
proxy_redirect off;
proxy_set_header X-Original-Prefix '/logs-web';
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $host;
}
```
3. 默认不启用密码(有风险⚠️,别人可以任意访问你的应用日志)。如果启用密码,取消`docker-compose.yml`中的`DOZZLE_AUTH_PROVIDER: simple`的注释,默认密码为`123456`,用户名为`admin`。
4. 修改默认密码,使用下面的命令生成密码:(替换admin和密码为实际的用户名和密码)
```bash
docker run -it --rm registry.cn-shanghai.aliyuncs.com/easyaigc/dozzle:latest generate admin --password 密码
```
5. 用生成的密码填入/data/users.yml中的password,并保存后,密码即刻生效
### 2025.12.27
@@ -657,40 +226,26 @@ start.sh 脚本用于服务器一键安装启动EasyAI应用
### 首次安装部署步骤,并使用服务器公网IP进行访问
1. 克隆脚本和相关文件到服务器
```bash
git clone --depth 1 https://git.51easyai.com/wangbo/easyai.git
git clone https://git.51easyai.com/wangbo/easyai.git
# 进入easyai目录
cd easyai
```
2. 在安全组或防火墙中仅开放 TCP 80 端口。脚本会自动生成 `.env` 和完整 Nginx 配置,接口统一走 `/api`、`/socket.io`、`/asg-api`。
3. [首次执行,后续无需重复执行]添加脚本的执行权限,命令:
2. 去掉所有文件后缀的.sample后缀,修改env中环境变量,将下面两个环境变量修改为你的服务器的公网ip,本地局域网方式使用局域网IP
```bash
NUXT_PUBLIC_BASE_APIURL=http://<你的服务器公网IP或者局域网地址>:3001
NUXT_PUBLIC_BASE_SOCKETURL=ws://<你的服务器公网IP或者局域网地址>:3002
```
3. 开放服务器的3001、3002和3010端口
4. [首次执行,后续无需重复执行]添加脚本的执行权限,命令:
```bash
chmod +x start.sh
```
4. 执行start.sh脚本,选择 IP 模式并输入服务器公网 IP,即可自动安装依赖、启动 EasyAI 并配置 Nginx
5. 执行start.sh脚本,即可自动安装依赖和启动EasyAI应用
```bash
./start.sh
```
5. 脚本运行完成无错误后,打开浏览器访问 `http://服务器IP`。
## Windows 一键启动
**不要双击** `start.ps1`(易闪退)。在 **PowerShell** 中执行:
```powershell
git clone --depth 1 "https://git.51easyai.com/wangbo/easyai.git" "easyai"; Set-Location "easyai"; powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".\start.ps1"
```
已在 `easyai` 目录内时:
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".\start.ps1"
```
> **Windows 脚本权限说明**:PowerShell 默认禁止运行脚本,直接双击 `start.ps1` 会闪退。
>
> - **推荐**:使用 `powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".\start.ps1"` 执行,无需修改系统策略
> - **或**:以管理员身份打开 PowerShell,执行 `Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser`,之后可直接运行 `.\start.ps1`
6. 脚本运行完成无错误,并且提示`EasyAI应用启动成功`表示应用启动成功,打开浏览器输入服务器的公网ip:3010或者局域网IP:3010,即可访问EasyAI应用
### 启用HTTPS
1. [更改为你的域名]修改`easyai-proxy.conf`中域名`51easyai.com`为你的域名[可以使用Ctrl+F批量替换51easyai.com为你的域名]
@@ -698,7 +253,6 @@ powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".\start.ps1"
```bash
NUXT_PUBLIC_BASE_APIURL=/api
NUXT_PUBLIC_BASE_SOCKETURL=wss://<你的域名>/socket.io
SERVER_HTTP_BIND_IP=127.0.0.1
```
3. [首次执行,后续无需重复执行]添加执行权限,命令:
```bash
@@ -713,6 +267,49 @@ chmod +x https.sh
如果需要启用PDF图文解析Markdown功能,需要`env.tools`中的OSS环境变量,具体参考文件中的配置说明:自动解析PDF图文,将图片文件上传到OSS,并解析成Markdown格式,并返回
### 更新升级
update.sh 脚本用于自动更新 EasyAI 应用,包含以下功能:
- 自动检查和更新 `docker-compose.yml` 文件(从远程仓库获取最新版本)
- 兼容 `docker compose` 和 `docker-compose` 两种命令格式
- 自动拉取最新镜像并重启服务
#### 使用步骤
1. [首次执行,后续无需重复执行]添加执行权限,命令:
```bash
chmod +x update.sh
```
2. 执行更新(默认会检查并更新 docker-compose.yml)
```bash
./update.sh
```
#### 参数选项
- **跳过 docker-compose.yml 更新**:如果你已经手动修改了 `docker-compose.yml` 文件,可以使用 `-s` 或 `--skip-compose-update` 参数跳过更新
```bash
# 跳过 docker-compose.yml 更新,仅更新容器镜像
./update.sh -s
# 或
./update.sh --skip-compose-update
```
- **查看帮助信息**:
```bash
./update.sh -h
# 或
./update.sh --help
```
#### 更新说明
- 脚本会自动从远程仓库下载最新的 `docker-compose.yml` 文件
- 如果本地文件与远程文件不同,会将原文件备份为 `docker-compose.yml.bak`
- 如果本地文件已是最新版本,则跳过更新
- 更新完成后会自动执行 `docker-compose pull` 和 `docker-compose up -d` 来重启服务
## 常见问题
1. 某个服务无法运行
-200
View File
@@ -1,200 +0,0 @@
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
# www 域名重定向,使用二级域名注释掉此部分的域名重定向
server {
listen 80;
listen [::]:80;
server_name www.demo.51easyai.com;
# 添加 HSTS 策略
# add_header Strict-Transport-Security "max-age=31536000" always;
# ssl 版本控制
ssl_protocols TLSv1.2 TLSv1.3; # 只启用 TLS 1.2 和 TLS 1.3
# 用于 certbot 验证的配置
location /.well-known/acme-challenge/ {
root /var/www/certbot;
try_files $uri =404;
}
# 更严格的重定向规则
location / {
return 301 https://demo.51easyai.com$request_uri;
}
}
server {
listen 80;
listen [::]:80;
server_name demo.51easyai.com;
# EasyAI response gzip: compress JSON/text API responses; SSE/WebSocket stay uncompressed by content type.
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_comp_level 4;
gzip_proxied any;
gzip_types
application/json
application/javascript
application/xml
text/css
text/plain
text/xml
image/svg+xml;
# ssl 版本控制
ssl_protocols TLSv1.2 TLSv1.3; # 只启用 TLS 1.2 和 TLS 1.3
# 通用安全头部
add_header X-Content-Type-Options nosniff;
add_header X-Frame-Options "SAMEORIGIN";
add_header X-XSS-Protection "1; mode=block";
# 用于 certbot 验证的配置
location /.well-known/acme-challenge/ {
root /var/www/certbot;
try_files $uri =404;
}
# 专门处理企业微信验证文件的请求
# location = /WW_verify_9eWb5ozYfqE0Bb2i.txt {
# default_type text/plain; # 设置正确的Content-Type
# return 200 '9eWb5ozYfqE0Bb2i'; # 直接返回验证内容
# }
location / {
proxy_pass http://127.0.0.1:3010/;
proxy_redirect off; # 修改重定向处理
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $host;
}
location = /api/sandbox {
return 404;
}
location ^~ /api/sandbox/ {
return 404;
}
location = /api/integration/platform-api/execute {
return 404;
}
# 旧教育平台 SSO 仅在双方完成独立密钥配置后,才允许显式开启。
location = /api/auth/sign/token {
return 404;
}
location = /api/auth/verify/token {
return 404;
}
location /api/ {
proxy_pass http://127.0.0.1:3001/;
proxy_read_timeout 1200s; # 您已设置,适合长连接
client_max_body_size 100M;
proxy_redirect off;
# --- SSE 关键配置开始 ---
proxy_buffering off; # 必须:关闭代理缓冲,数据会立即从后端发送到客户端
proxy_cache off; # 必须:禁用缓存,SSE是动态内容
proxy_set_header Connection ''; # 清除 Connection header,有助于保持长连接
proxy_http_version 1.1; # 推荐使用 HTTP/1.1
proxy_set_header Accept-Encoding ''; # 可选:防止上游压缩导致缓冲,如果后端有gzip等压缩,可能会影响流式输出
# --- SSE 关键配置结束 ---
proxy_set_header X-Original-Prefix '/api';
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $host;
}
location /asg-api/ {
proxy_pass http://127.0.0.1:3003/;
proxy_read_timeout 300s;
client_max_body_size 20M;
proxy_redirect off;
proxy_set_header X-Original-Prefix '/asg-api';
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $host;
}
# Agent 记忆服务 API(可选,用于健康检查或外部调用)
location /ams-api/ {
proxy_pass http://127.0.0.1:3004/;
proxy_read_timeout 300s;
client_max_body_size 20M;
proxy_redirect off;
proxy_set_header X-Original-Prefix '/ams-api';
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $host;
}
location /plugins/ {
proxy_pass http://127.0.0.1:3020/plugins/;
proxy_redirect off;
proxy_set_header X-Original-Prefix '/plugins';
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $host;
}
location = /logs-web {
return 404;
}
location ^~ /logs-web/ {
return 404;
}
location = /jupyterlab {
return 404;
}
location ^~ /jupyterlab/ {
return 404;
}
location /socket.io {
proxy_pass http://127.0.0.1:3002;
proxy_read_timeout 300s;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_buffering off; # 对于 WebSocket 连接禁用缓冲
}
# 转发/mcp/messages 消息到3001
location /mcp {
proxy_pass http://127.0.0.1:3001/mcp;
proxy_read_timeout 300s;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_buffering off; # 对于 WebSocket 连接禁用缓冲
}
location = /sandbox {
return 404;
}
location ^~ /sandbox/ {
return 404;
}
}
-43
View File
@@ -1,43 +0,0 @@
## 部署配置,通过问答让用户选择
1. 通过IP地址还是通过域名访问?
2. (如果通过IP地址访问),输入服务器 IP,并保证 80 端口已经开放
3. 如果通过域名访问输入域名,不含https://的前缀,例如:51easyai.com
3.1 是否启用https访问
4. IP 与域名访问共用相同的 Nginx 接口路径,只根据公开地址选择 WebSocket 协议和 Host
```bash
NUXT_PUBLIC_BASE_APIURL=/api
NUXT_PUBLIC_BASE_SOCKETURL=ws://<用户输入的IP地址>/socket.io
NUXT_PUBLIC_SG_APIURL=/asg-api
```
5. 对于使用域名的情况下情况,复制`.env.sampla`为`.env`,将上述3个变量设置为:
```bash
NUXT_PUBLIC_BASE_APIURL=/api
NUXT_PUBLIC_BASE_SOCKETURL=wss://<用户输入的域名>/socket.io
NUXT_PUBLIC_SG_APIURL=/asg-api
```
6. 复制 `.env.tools.sample`为`.env.tools`,复制`.env.ASG.sample`为`.env.ASG.sample`
7. IP 与域名模式都从 `easyai-proxy.conf.sample` 生成完整 Nginx 配置;IP 模式监听对应 IP 的 HTTP 80,域名模式监听域名并按需启用 HTTPS
8. 执行原来的start脚本内容,包括安装docker安装和部署
9. 如果启用https访问,还要同步执行原来的https脚本
## 平台要求,需要兼容主流的linux云平台
## 部署要求
直接通过一个命令访问,包含自动从https://git.51easyai.com/wangbo/easyai克隆项目
```
bash -c https://git.51easyai.com/wangbo/easyai/src/branch/main/start.sh
```
Binary file not shown.
+98 -316
View File
@@ -2,22 +2,53 @@
# (各项参数均有详细的说明,理论情况下保持默认即可运行)
#version: '3'
services:
easyai-web:
container_name: easyai-web
comfyAI-web-init:
container_name: comfyAI-web-init
image: registry.cn-shanghai.aliyuncs.com/comfy-ai/one-ai:${VERSION}
labels:
- "com.centurylinklabs.watchtower.enable=true"
entrypoint: [ "sh", "-c", "node .output/scripts/static-upload-init.mjs && touch /tmp/init-done && exec sleep infinity" ]
restart: unless-stopped
healthcheck:
test: [ "CMD", "test", "-f", "/tmp/init-done" ]
interval: 5s
timeout: 3s
retries: 60
start_period: 120s
networks:
comfyai:
ipv4_address: 172.21.0.15
volumes:
- ./data/cdn-init:/tmp/cdn-init
env_file:
- .env.comfyAI-web-init
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "5"
comfyAI-web:
container_name: comfyAI-web
#更新修改冒号后面的版本号
image: registry.cn-shanghai.aliyuncs.com/comfy-ai/one-ai:${VERSION}
#端口冲突时,只需要修改前面的端口比如修改为,3011:3010
labels:
- "com.centurylinklabs.watchtower.enable=${WATCHTOWER_CORE_UPDATE_ENABLED:-true}"
- "com.centurylinklabs.watchtower.scope=${WATCHTOWER_SCOPE:-}"
- "com.centurylinklabs.watchtower.enable=true"
ports:
- "${EASYAI_PROXY_BIND_IP:-127.0.0.1}:${WEB_PORT}:3010"
# read_only: true
- "${WEB_PORT}:3010"
read_only: true
volumes:
# 这一行是关键。它将一个名为 './data/forend/.pm2' 的持久化卷挂载到容器内的 /app/.pm2 目录
- ./data/forend/.pm2:/app/.pm2
- ./data/cdn-init:/tmp/cdn-init:ro
networks:
easyai:
comfyai:
ipv4_address: 172.21.0.8
depends_on:
- easyai-server
comfy-server:
condition: service_started
comfyAI-web-init:
condition: service_healthy
restart: unless-stopped
environment:
# 默认服务器地址,本地不需要更改,云服务需要修改为云端IP,并放行对应端口
@@ -27,13 +58,8 @@ services:
# 默认的服务器websocket地址,本地不需要更改,云服务需要修改为云端IP,并放行对应端口
- NUXT_PUBLIC_BASE_SOCKETURL=${NUXT_PUBLIC_BASE_SOCKETURL}
- NUXT_APP_CDN_URL=${NUXT_APP_CDN_URL}
# 默认自动选择 CDN Brotli;CDN 不兼容时安全回退到源站预压缩资源
- STATIC_ASSET_COMPRESSION=${STATIC_ASSET_COMPRESSION:-auto}
# Agent 服务治理 API 地址(前端直接调用,走 Nginx 代理时配置为 /asg-api)
- NUXT_PUBLIC_SG_APIURL=${NUXT_PUBLIC_SG_APIURL}
volumes:
# 保存与当前前端构建绑定的 CDN 验证结果,旧结果不会跨构建误用
- ./data/cdn-init:/tmp/cdn-init
# 日志大小设置,避免日志文件过大
env_file:
- .env
@@ -42,12 +68,6 @@ services:
options:
max-size: "100m"
max-file: "10"
healthcheck:
test: ["CMD-SHELL", "node -e \"require('http').get('http://127.0.0.1:3010/health', (res) => { res.resume(); process.exit(res.statusCode === 200 ? 0 : 1); }).on('error', () => process.exit(1))\""]
interval: 10s
timeout: 5s
retries: 12
start_period: 30s
# 资源限制,防止内存溢出
deploy:
resources:
@@ -55,49 +75,36 @@ services:
memory: 1500MB
reservations:
memory: 600MB
easyai-server:
container_name: easyai-server
comfy-server:
container_name: comfy-server
# 阿里云镜像地址
image: registry.cn-shanghai.aliyuncs.com/comfy-ai/comfy-server:${VERSION} # 阿里云
labels:
- "com.centurylinklabs.watchtower.enable=${WATCHTOWER_CORE_UPDATE_ENABLED:-true}"
- "com.centurylinklabs.watchtower.scope=${WATCHTOWER_SCOPE:-}"
- "com.centurylinklabs.watchtower.enable=true"
ports:
- "${SERVER_HTTP_BIND_IP:-127.0.0.1}:${SERVER_HTTP_PORT}:3001" # Linux Nginx 部署默认仅回环;Windows/LAN 直连由启动脚本显式覆盖
# read_only: true
- "${SERVER_HTTP_PORT}:3001" #http端口
read_only: true
networks:
easyai:
comfyai:
ipv4_address: 172.21.0.6
sandbox-network:
depends_on:
mongo:
condition: service_started
redis:
condition: service_healthy
rabbitmq:
condition: service_started
- mongo
- redis
- rabbitmq
volumes:
- ./system-update/release-manifest.json:/etc/easyai-updater/release-manifest.json:ro
- ./data/upload:/app/upload
- ./data/backups:/app/backups
- ./data/restores:/app/restores
- ./data/backend/app/tmp:/app/tmp
- ./data/tmp:/tmp
- ./data/backend/.pm2:/app/.pm2
# - ./data/backend/pm2.config.js:/app/pm2.config.js
restart: unless-stopped
environment:
# Node.js V8 old-space 堆上限(MiB),可在 .env 中覆盖
- NODE_OPTIONS=--max-old-space-size=${NODE_MAX_OLD_SPACE_SIZE:-8192}
# MongoDB配置,用户管理,不会配置的话保持默认即可
- CONFIG_DB_MONGO_URI=mongodb://172.21.0.3:27017
- CONFIG_DB_MONGO_USERNAME=${MONGO_INITDB_ROOT_USERNAME}
- CONFIG_DB_MONGO_PASSWORD=${MONGO_INITDB_ROOT_PASSWORD}
- MALLOC_ARENA_MAX=${MALLOC_ARENA_MAX:-2}
- MALLOC_TRIM_THRESHOLD_=${MALLOC_TRIM_THRESHOLD_:-131072}
- MALLOC_MMAP_THRESHOLD_=${MALLOC_MMAP_THRESHOLD_:-131072}
- MALLOC_TOP_PAD_=${MALLOC_TOP_PAD_:-131072}
- SHARP_CACHE_ENABLED=${SHARP_CACHE_ENABLED:-false}
- SHARP_CONCURRENCY=${SHARP_CONCURRENCY:-1}
# Redis配置,队列缓存,不会配置的话保持默认即可
- CONFIG_COMFYUI_QUENE_REDIS_HOST=172.21.0.4
- CONFIG_COMFYUI_QUENE_REDIS_PORT=6379
@@ -112,7 +119,6 @@ services:
#Token过期时间,单位秒
- CONFIG_TOKEN_EXPIRE=${CONFIG_TOKEN_EXPIRE}
- CONFIG_JWT_SECRET=${CONFIG_JWT_SECRET}
- WS_AUTH_WS_TICKET_SECRET=${WS_AUTH_WS_TICKET_SECRET}
# 代理服务器
- CONFIG_PROXY_URL=${CONFIG_PROXY_URL}
#禁用文档 true/false/留空
@@ -125,145 +131,49 @@ services:
- CONFIG_MQ_PORT=${CONFIG_MQ_PORT}
- CONFIG_MQ_VHOST=${CONFIG_MQ_VHOST}
# 视频编辑接口
- CONFIG_VIDEO_EDIT_API_URL=${CONFIG_VIDEO_EDIT_API_URL:-http://video-edit:8000}
- CONFIG_VIDEO_EDIT_API_URL=http://video-edit:8000
# 沙箱环境baseURL
- SANDBOX_SERVICE_BASE_URL=${SANDBOX_SERVICE_BASE_URL:-http://sandbox:8000}
# 服务治理 TCP 连接
- ASG_TCP_HOST=easyai-asg
- ASG_TCP_PORT=4003
# ws-gateway 转发客户端命令到 easyai-server(画布协作等),对应主服务 3001
- GATEWAY_INBOUND_ENABLED=${GATEWAY_INBOUND_ENABLED:-true}
# 仅监听主业务网络固定地址,避免沙箱网络访问内部 TCP 命令入口
- GATEWAY_INBOUND_TCP_LISTEN_HOST=${GATEWAY_INBOUND_TCP_LISTEN_HOST:-172.21.0.6}
- GATEWAY_INBOUND_TCP_PORT=${GATEWAY_INBOUND_TCP_PORT:-4001}
# Agent 记忆服务 TCP 连接(来自 .env)
- MEMORY_TCP_HOST=${MEMORY_TCP_HOST:-agent-memory}
- MEMORY_TCP_PORT=${MEMORY_TCP_PORT:-4004}
# easyai-server 发布事件到 ws-gateway(默认走容器内网服务名)
- WS_GATEWAY_TCP_HOST=${WS_GATEWAY_TCP_HOST:-ws-gateway}
- WS_GATEWAY_TCP_PORT=${WS_GATEWAY_TCP_PORT:-4002}
# 在线更新:未配置时保持原 Watchtower 兼容模式。
- SYSTEM_UPDATE_MODE=${SYSTEM_UPDATE_MODE:-legacy_watchtower}
- SYSTEM_UPDATE_SERVICE_URL=${SYSTEM_UPDATE_SERVICE_URL:-http://easyai-updater:3015}
- SYSTEM_UPDATE_INTERNAL_TOKEN=${SYSTEM_UPDATE_INTERNAL_TOKEN}
# Same published release source for legacy Watchtower and managed updater.
- UPDATE_MANIFEST_SOURCE=${UPDATE_MANIFEST_SOURCE:-remote}
- UPDATE_MANIFEST_URL=${UPDATE_MANIFEST_URL:-}
- UPDATE_MANIFEST_FILE=/etc/easyai-updater/release-manifest.json
- EASYAI_DEPLOYMENT_SCHEMA_VERSION=${EASYAI_DEPLOYMENT_SCHEMA_VERSION:-1}
# 独立转换 Worker 与 API 共用内部令牌,由安装/升级脚本自动生成。
- MODEL_CONVERSION_WORKER_TOKEN=${MODEL_CONVERSION_WORKER_TOKEN}
- MODEL_CONVERSION_HUNYUAN_ENABLED=${MODEL_CONVERSION_HUNYUAN_ENABLED:-false}
# 日志大小设置,避免日志文件过大
env_file:
- .env
healthcheck:
test: ["CMD-SHELL", "node -e \"require('http').get('http://127.0.0.1:3001/health/update-ready', (res) => { res.resume(); process.exit(res.statusCode === 200 ? 0 : 1); }).on('error', () => process.exit(1))\""]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
logging:
driver: "json-file"
options:
max-size: "100m"
max-file: "10"
model-conversion-worker:
container_name: easyai-model-conversion-worker
image: registry.cn-shanghai.aliyuncs.com/easyaigc/model-conversion-worker:${MODEL_CONVERSION_WORKER_VERSION:-latest}
labels:
- "com.centurylinklabs.watchtower.enable=${WATCHTOWER_CORE_UPDATE_ENABLED:-true}"
- "com.centurylinklabs.watchtower.scope=${WATCHTOWER_SCOPE:-}"
restart: unless-stopped
cpus: 2
mem_limit: 4g
memswap_limit: 4g
pids_limit: 64
read_only: true
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
tmpfs:
- /tmp:size=2g,uid=1000,gid=1000,mode=0700
stop_grace_period: 10s
networks:
- easyai
depends_on:
easyai-server:
condition: service_healthy
redis:
condition: service_healthy
mongo:
condition: service_started
# 只提供转换所需连接,不向解析器或 Worker 注入整个 .env。
environment:
- MODEL_CONVERSION_MONGO_URI=mongodb://mongo:27017
- MODEL_CONVERSION_MONGO_DB=${CONFIG_DB_MONGO_DBNAME:-aidraw}
- MODEL_CONVERSION_MONGO_USERNAME=${MONGO_INITDB_ROOT_USERNAME}
- MODEL_CONVERSION_MONGO_PASSWORD=${MONGO_INITDB_ROOT_PASSWORD}
- MODEL_CONVERSION_MONGO_AUTH_SOURCE=admin
- MODEL_CONVERSION_REDIS_HOST=redis
- MODEL_CONVERSION_REDIS_PORT=6379
- MODEL_CONVERSION_REDIS_USERNAME=${CONFIG_COMFYUI_QUENE_REDIS_USERNAME:-}
- MODEL_CONVERSION_REDIS_PASSWORD=${CONFIG_COMFYUI_QUENE_REDIS_PASSWORD:-}
- MODEL_CONVERSION_REDIS_DB=${CONFIG_COMFYUI_QUENE_REDIS_DB:-6}
- MODEL_CONVERSION_API_URL=http://easyai-server:3001
- MODEL_CONVERSION_WORKER_TOKEN=${MODEL_CONVERSION_WORKER_TOKEN}
healthcheck:
test: ["CMD", "node", "src/healthcheck.mjs"]
interval: 10s
timeout: 5s
retries: 3
start_period: 20s
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
ws-gateway:
container_name: easyai-wsgateway # websocket网关
# 阿里云镜像地址
image: registry.cn-shanghai.aliyuncs.com/easyaigc/wsgateway:${WS_GATEWAY_VERSION:-latest} # 阿里云
image: registry.cn-shanghai.aliyuncs.com/easyaigc/wsgateway:latest # 阿里云
labels:
- "com.centurylinklabs.watchtower.enable=${WATCHTOWER_CORE_UPDATE_ENABLED:-true}"
- "com.centurylinklabs.watchtower.scope=${WATCHTOWER_SCOPE:-}"
- "com.centurylinklabs.watchtower.enable=true"
ports:
- "${EASYAI_PROXY_BIND_IP:-127.0.0.1}:${CONFIG_WS_PORT:-3002}:3002" # Linux IP/域名模式默认仅供同机 Nginx 访问
- "${SERVER_WS_PORT}:3002" #http端口
read_only: true
networks:
- easyai
- comfyai
depends_on:
- easyai-server
- redis
- rabbitmq
volumes:
- ./data/wsgateway/.pm2:/app/.pm2
restart: unless-stopped
environment:
# Canvas CLI / Agent 使用短时 ws_ticket;密钥由启动/升级脚本自动生成并与主服务共享。
- WS_AUTH_WS_TICKET_SECRET=${WS_AUTH_WS_TICKET_SECRET}
- WS_AUTH_METHODS=${WS_AUTH_METHODS:-none,bearer,ws_ticket}
# Redis配置,队列缓存,不会配置的话保持默认即可
- CONFIG_COMFYUI_QUENE_REDIS_HOST=172.21.0.4
- CONFIG_COMFYUI_QUENE_REDIS_PORT=6379
- CONFIG_COMFYUI_QUENE_REDIS_PASSWORD=
#日志与调试
- LOG_LEVEL=${LOG_LEVEL}
- WS_GATEWAY_LOG_LEVEL=${WS_GATEWAY_LOG_LEVEL}
#MQ
- CONFIG_MQ_USER=${CONFIG_MQ_USER}
- CONFIG_MQ_PASSWORD=${CONFIG_MQ_PASSWORD}
- CONFIG_MQ_HOST=rabbitmq
- CONFIG_MQ_PORT=${CONFIG_MQ_PORT}
# 客户端命令转发到 easyai-server 入站 TCP;容器内不能使用 127.0.0.1
- GATEWAY_INBOUND_ENABLED=${GATEWAY_INBOUND_ENABLED:-true}
- GATEWAY_INBOUND_TCP_HOST=${GATEWAY_INBOUND_TCP_HOST:-easyai-server}
- GATEWAY_INBOUND_TCP_PORT=${GATEWAY_INBOUND_TCP_PORT:-4001}
- GATEWAY_INBOUND_TCP_PATTERN=${GATEWAY_INBOUND_TCP_PATTERN:-gateway.inbound.command}
- GATEWAY_INBOUND_TIMEOUT_MS=${GATEWAY_INBOUND_TIMEOUT_MS:-5000}
- WS_GATEWAY_HEALTH_PORT=3012
- EASYAI_BUILD_ID=${EASYAI_BUILD_ID:-unknown}
# 日志大小设置,避免日志文件过大
env_file:
- .env
@@ -272,29 +182,22 @@ services:
options:
max-size: "100m"
max-file: "10"
healthcheck:
test: ["CMD-SHELL", "node -e \"require('http').get('http://127.0.0.1:3012/health', (res) => { res.resume(); process.exit(res.statusCode === 200 ? 0 : 1); }).on('error', () => process.exit(1))\""]
interval: 10s
timeout: 5s
retries: 12
start_period: 20s
mongo:
# 镜像见 .env 中 MONGO_IMAGE;4.4 备选地址与用途见 .env.sample 注释
image: ${MONGO_IMAGE:-registry.cn-shanghai.aliyuncs.com/comfy-ai/mongo-aliyun:latest}
image: registry.cn-shanghai.aliyuncs.com/comfy-ai/mongo-aliyun:latest
# image: registry.cn-shanghai.aliyuncs.com/comfy-ai/mongo-aliyun:4.4
container_name: mongo
restart: unless-stopped
privileged: true
# 生产环境建议不要暴露
ports:
- "${EASYAI_INFRA_BIND_IP:-127.0.0.1}:${MONGO_PORT:-27017}:27017"
- ${MONGO_PORT+${MONGO_PORT}:27017}
networks:
easyai:
comfyai:
ipv4_address: 172.21.0.3
environment:
# 这里的配置只有首次运行生效。修改后,重启镜像是不会生效的。需要把持久化数据删除再重启,才有效果
- MONGO_INITDB_ROOT_USERNAME=${MONGO_INITDB_ROOT_USERNAME}
- MONGO_INITDB_ROOT_PASSWORD=${MONGO_INITDB_ROOT_PASSWORD}
command: [ "mongod", "--wiredTigerCacheSizeGB", "1.5" ]
mem_limit: 2500m
volumes:
# - /usr/local/mongo/data:/data/db
- mongo_data:/data/db # 使用docker volume存储数据,提高性能,避免兼容性问题
@@ -311,27 +214,17 @@ services:
max-size: "100m"
max-file: "10"
redis:
image: ${REDIS_IMAGE:-registry.cn-shanghai.aliyuncs.com/comfy-ai/redis-aliyun:latest}
image: registry.cn-shanghai.aliyuncs.com/comfy-ai/redis-aliyun:latest
container_name: redis
restart: always
volumes:
- ./redis.conf:/etc/redis/redis.conf # 挂载本地 redis.conf
- ./data/redis:/data # 持久化 Redis AOF/RDB,避免落到容器写层
command: [ "redis-server", "/etc/redis/redis.conf" ] # 让 Redis 读取配置文件
stop_grace_period: 1m # 给 Redis 足够时间在 SIGTERM 后完成 AOF fsync
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 3
start_period: 5s
mem_limit: 2g
memswap_limit: 2g
# ports:
# - ${REDIS_PORT+${REDIS_PORT}:6379}
# read_only: true
read_only: true
networks:
easyai:
comfyai:
ipv4_address: 172.21.0.4
# 日志大小设置,避免日志文件过大
logging:
@@ -340,49 +233,36 @@ services:
max-size: "100m"
max-file: "10"
rabbitmq:
# 镜像地址见 .env 中 RABBITMQ_IMAGE(备选示例见 .env.sample)
image: ${RABBITMQ_IMAGE:-registry.cn-shanghai.aliyuncs.com/easyaigc/mq:latest}
labels:
- "com.centurylinklabs.watchtower.enable=true"
- "com.centurylinklabs.watchtower.scope=${WATCHTOWER_SCOPE:-}"
image: registry.cn-shanghai.aliyuncs.com/easyaigc/mq:latest #阿里云镜像加速
# image: rabbitmq:4-management #官方原版镜像
container_name: rabbitmq
restart: unless-stopped
environment:
RABBITMQ_DEFAULT_USER: ${CONFIG_MQ_USER} # 登录用户名
RABBITMQ_DEFAULT_PASS: ${CONFIG_MQ_PASSWORD} # 登录密码
ports:
- "${EASYAI_INFRA_BIND_IP:-127.0.0.1}:${CONFIG_MQ_PORT}:${CONFIG_MQ_PORT}" # 应用连接端口 (AMQP)
- "${EASYAI_INFRA_BIND_IP:-127.0.0.1}:${CONFIG_MQ_ADMIN_PORT}:15672" # Web 管理界面
- "${CONFIG_MQ_PORT}:${CONFIG_MQ_PORT}" # 应用连接端口 (AMQP)
- "${CONFIG_MQ_ADMIN_PORT}:15672" # Web 管理界面
volumes:
- rabbitmq_data:/var/lib/rabbitmq # 持久化数据
networks:
easyai:
comfyai:
ipv4_address: 172.21.0.12
watchtower:
image: ${WATCHTOWER_IMAGE:-registry.cn-shanghai.aliyuncs.com/comfy-ai/watchtower-aliyun:latest}
image: registry.cn-shanghai.aliyuncs.com/comfy-ai/watchtower-aliyun:latest
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ${DOCKER_CONFIG_FILE:-/root/.docker/config.json}:/config.json:ro
# 新安装只负责 easyai-updater 自身;存量环境未配置目标时保持原来的全容器扫描方式。
command: --debug ${WATCHTOWER_UPDATE_TARGET:-}
environment:
- WATCHTOWER_POLL_INTERVAL=${SYSTEM_UPDATER_UPDATE_INTERVAL_SECONDS:-86400}
- WATCHTOWER_CLEANUP=${WATCHTOWER_CLEANUP:-false}
- WATCHTOWER_SCOPE=${WATCHTOWER_SCOPE:-}
- WATCHTOWER_HTTP_API_HOST=0.0.0.0
- WATCHTOWER_HTTP_API_ENDPOINTS=health,update,metrics,events,history
- WATCHTOWER_HTTP_API_TOKEN=${WATCHTOWER_API_KEY}
- WATCHTOWER_HTTP_API_EVENTS_TOKEN=${WATCHTOWER_HTTP_API_EVENTS_TOKEN}
- WATCHTOWER_HTTP_API_PERIODIC_POLLS=${WATCHTOWER_HTTP_API_PERIODIC_POLLS:-false}
command: --debug --http-api-update
restart: always
environment:
- WATCHTOWER_HTTP_API_TOKEN=sk-ofJoA4atj9dki6AvDtI1qwxdcejjDQ
labels:
- "com.centurylinklabs.watchtower.enable=false"
- "com.centurylinklabs.watchtower.scope=${WATCHTOWER_SCOPE:-}"
# ports:
# - "${WATCHTOWER_PORT}:8080"
read_only: true
networks:
easyai:
comfyai:
ipv4_address: 172.21.0.9
# 日志大小设置,避免日志文件过大
logging:
@@ -390,84 +270,28 @@ services:
options:
max-size: "100m"
max-file: "10"
easyai-updater:
image: registry.cn-shanghai.aliyuncs.com/easyaigc/easyai-updater:${UPDATER_VERSION:-latest}
container_name: easyai-updater
profiles:
- managed-update
labels:
- "com.centurylinklabs.watchtower.enable=true"
- "com.centurylinklabs.watchtower.scope=${WATCHTOWER_SCOPE:-}"
restart: unless-stopped
read_only: true
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
environment:
- PORT=3015
- SYSTEM_UPDATE_INTERNAL_TOKEN=${SYSTEM_UPDATE_INTERNAL_TOKEN}
- EASYAI_DEPLOYMENT_SCHEMA_VERSION=${EASYAI_DEPLOYMENT_SCHEMA_VERSION:-1}
- UPDATE_HEALTH_TIMEOUT_MS=${UPDATE_HEALTH_TIMEOUT_MS:-300000}
- DOCKER_CONFIG_FILE=/run/secrets/docker-config.json
- UPDATE_STATE_DIR=/var/lib/easyai-updater
- UPDATE_MANIFEST_FILE=/etc/easyai-updater/release-manifest.json
- UPDATE_MANIFEST_SOURCE=${UPDATE_MANIFEST_SOURCE:-remote}
- UPDATE_MANIFEST_URL=${UPDATE_MANIFEST_URL:-}
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ${DOCKER_CONFIG_FILE:-/root/.docker/config.json}:/run/secrets/docker-config.json:ro
- ./system-update/release-manifest.json:/etc/easyai-updater/release-manifest.json:ro
- ./data/update-state:/var/lib/easyai-updater
tmpfs:
- /tmp:mode=1777
networks:
easyai:
ipv4_address: 172.21.0.31
healthcheck:
test: ["CMD-SHELL", "node -e \"require('http').get('http://127.0.0.1:3015/health', (res) => { res.resume(); process.exit(res.statusCode === 200 ? 0 : 1); }).on('error', () => process.exit(1))\""]
interval: 10s
timeout: 5s
retries: 6
start_period: 10s
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
video-edit:
image: registry.cn-shanghai.aliyuncs.com/easyaigc/videoedit:latest
container_name: video-edit
platform: linux/amd64
labels:
- 'com.centurylinklabs.watchtower.enable=true'
- "com.centurylinklabs.watchtower.enable=true"
volumes:
- ./data/videoedit/temp:/app/temp
ports:
- '${EASYAI_INFRA_BIND_IP:-127.0.0.1}:${VIDEO_EDIT_PORT}:8000'
env_file:
- .env.tools
- "${VIDEO_EDIT_PORT}:8000"
environment:
- PYTHONUNBUFFERED=1
- OMP_NUM_THREADS=4
- OPENBLAS_NUM_THREADS=4
ipc: host
shm_size: 3g
cap_add:
- SYS_ADMIN
init: true
env_file:
- .env.tools
shm_size: 2g
restart: unless-stopped
networks:
easyai:
comfyai:
ipv4_address: 172.21.0.10
healthcheck:
test:
[
'CMD',
'python',
'-c',
"import urllib.request; urllib.request.urlopen('http://localhost:8000/docs')",
]
test: [ "CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/docs')" ]
interval: 30s
timeout: 10s
retries: 3
@@ -477,27 +301,24 @@ services:
image: registry.cn-shanghai.aliyuncs.com/easyaigc/dozzle:latest
environment:
DOZZLE_BASE: /logs-web
DOZZLE_ENABLE_DOWNLOAD: "false"
# DOZZLE_AUTH_PROVIDER: simple
DOZZLE_AUTH_TTL: 72h
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./data:/data
ports:
- "${EASYAI_INFRA_BIND_IP:-127.0.0.1}:${DOZZLE_PORT:-8080}:8080"
restart: unless-stopped
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
- 8080:8080
sandbox:
image: registry.cn-shanghai.aliyuncs.com/easyaigc/sandbox:latest
container_name: sandbox
networks:
- sandbox-network
# 不映射宿主机端口,仅允许 Docker 内网中的 easyai-server 访问
- comfyai
#沙箱环境默认不对外暴露
ports:
# - "${SANDBOX_PORT}:8000"
- "${SANDBOX_JUPYTERLAB_PORT:-8888}:8888"
labels:
- "com.centurylinklabs.watchtower.enable=true"
- "com.centurylinklabs.watchtower.scope=${WATCHTOWER_SCOPE:-}"
volumes:
- ./sandbox-data:/app/data
- ./local_packages:/app/local_packages
@@ -538,21 +359,18 @@ services:
timeout: 10s
retries: 3
start_period: 10s
easyai-pgvector:
# 使用带 pgvector 的镜像,供 ASG 与 Agent 记忆服务共用
image: registry.cn-shanghai.aliyuncs.com/easyaigc/pgvector:0.8.2-pg18-trixie
container_name: easyai-pgvector
easyai-asg-pg:
image: registry.cn-shanghai.aliyuncs.com/easyaigc/postgres:18-alpine
container_name: easyai-asg-pg
restart: unless-stopped
environment:
POSTGRES_USER: ${ASG_POSTGRES_USER:-easyai}
POSTGRES_PASSWORD: ${ASG_POSTGRES_PASSWORD:-easyai2025}
POSTGRES_DB: ${ASG_POSTGRES_DB:-agent_governance}
volumes:
- asg_postgres_data:/var/lib/postgresql
# 首次启动时执行,创建 pgvector 扩展供记忆服务使用
- ./docker/postgres/init-pgvector.sql:/docker-entrypoint-initdb.d/02-init-pgvector.sql
- asg_postgres_data:/var/lib/postgresql/data
networks:
easyai:
comfyai:
ipv4_address: 172.21.0.13
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${ASG_POSTGRES_USER:-easyai} -d ${ASG_POSTGRES_DB:-agent_governance}"]
@@ -570,14 +388,13 @@ services:
image: registry.cn-shanghai.aliyuncs.com/easyaigc/agent-governance:${ASG_VERSION:-latest}
labels:
- "com.centurylinklabs.watchtower.enable=true"
- "com.centurylinklabs.watchtower.scope=${WATCHTOWER_SCOPE:-}"
ports:
- "${EASYAI_PROXY_BIND_IP:-127.0.0.1}:${ASG_PORT:-3003}:3003"
- "${ASG_PORT:-3003}:3003"
networks:
easyai:
comfyai:
ipv4_address: 172.21.0.14
depends_on:
easyai-pgvector:
easyai-asg-pg:
condition: service_healthy
redis:
condition: service_started
@@ -597,39 +414,6 @@ services:
memory: 512MB
reservations:
memory: 128MB
agent-memory:
container_name: agent-memory
image: registry.cn-shanghai.aliyuncs.com/easyaigc/agent-memory:${AMS_VERSION:-latest}
labels:
- "com.centurylinklabs.watchtower.enable=true"
- "com.centurylinklabs.watchtower.scope=${WATCHTOWER_SCOPE:-}"
ports:
- "${EASYAI_INFRA_BIND_IP:-127.0.0.1}:${AMS_PORT:-3004}:3004"
environment:
- MEMORY_DATABASE_URL=${MEMORY_DATABASE_URL:-postgresql://easyai:easyai2025@easyai-pgvector:5432/easyai_memory}
networks:
easyai:
ipv4_address: 172.21.0.16
depends_on:
easyai-pgvector:
condition: service_healthy
easyai-server:
condition: service_started
restart: unless-stopped
env_file:
- .env.AMS
- .env.ASG
logging:
driver: "json-file"
options:
max-size: "100m"
max-file: "10"
deploy:
resources:
limits:
memory: 512MB
reservations:
memory: 128MB
# portainer:
# image: registry.cn-shanghai.aliyuncs.com/comfy-ai/portainer-ce:2.21.5
# container_name: portainer
@@ -675,13 +459,11 @@ volumes:
rabbitmq_data:
asg_postgres_data:
networks:
easyai:
comfyai:
driver: bridge
ipam:
config:
- subnet: 172.21.0.0/16
gateway: 172.21.0.1
# 仅 easyai-server 与 sandbox 共享,阻断沙箱横向访问数据库、缓存、MQ 与治理服务。
# 保持普通 bridge 以允许用户脚本按业务需要访问外网。
sandbox-network:
driver: bridge
-14
View File
@@ -1,14 +0,0 @@
-- 首次启动时执行,与 ASG 共用同一 PostgreSQL 实例
-- 1. 为 agent_governance 启用 pgvector(兼容共库场景)
\c agent_governance
CREATE EXTENSION IF NOT EXISTS vector;
CREATE EXTENSION IF NOT EXISTS pgcrypto;
-- 2. 创建记忆服务独立数据库 easyai_memory
CREATE DATABASE easyai_memory;
GRANT ALL PRIVILEGES ON DATABASE easyai_memory TO easyai;
-- 3. 为 easyai_memory 启用 pgvector
\c easyai_memory
CREATE EXTENSION IF NOT EXISTS vector;
CREATE EXTENSION IF NOT EXISTS pgcrypto;
-33
View File
@@ -1,33 +0,0 @@
# EasyAI 部署脚本验证环境
# 在 Docker 容器内运行 start.sh,通过挂载 Docker Socket 使用宿主机 Docker 启动服务
FROM ubuntu:22.04
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
ca-certificates \
gnupg \
lsb-release \
git \
sudo \
&& rm -rf /var/lib/apt/lists/*
# 安装 Docker CLI(使用宿主机 Docker 守护进程)
RUN install -m 0755 -d /etc/apt/keyrings \
&& curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg \
&& chmod a+r /etc/apt/keyrings/docker.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" > /etc/apt/sources.list.d/docker.list \
&& apt-get update \
&& apt-get install -y --no-install-recommends docker-ce-cli docker-compose-plugin \
&& rm -rf /var/lib/apt/lists/*
# 创建工作目录
WORKDIR /workspace/easyai
# 复制项目文件(运行时通过 volume 挂载覆盖)
COPY . /workspace/easyai/
# 允许以 root 运行(容器内通常为 root)
ENV DEPLOY_ACCESS=ip
ENV DEPLOY_IP=127.0.0.1
-24
View File
@@ -1,24 +0,0 @@
#!/bin/bash
# 快速验证:仅测试配置生成,不拉取镜像、不启动服务
# 适合 CI 或快速检查脚本逻辑
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
cd "$PROJECT_ROOT"
echo "================================"
echo " EasyAI 部署脚本快速验证"
echo " (仅配置,不启动 Docker)"
echo "================================"
# 使用管道模拟用户输入:1=IP模式, 127.0.0.1=IP地址
printf '1\n127.0.0.1\n' | DEPLOY_DRY_RUN=1 DEPLOY_FORCE_RECONFIG=1 ./start.sh
echo ""
echo "✅ 配置验证通过,检查生成的文件:"
ls -la .env .env.tools .env.ASG .env.AMS 2>/dev/null || true
echo ""
grep -E "NUXT_PUBLIC_(BASE_APIURL|BASE_SOCKETURL|SG_APIURL)" .env 2>/dev/null | head -3
-46
View File
@@ -1,46 +0,0 @@
#!/bin/bash
# 在 Docker 容器中验证 EasyAI 部署脚本
# 用法: 在 easyai 项目根目录执行 ./docker/verify/run-verify.sh
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
cd "$PROJECT_ROOT"
echo "================================"
echo " EasyAI 部署脚本 Docker 验证"
echo "================================"
echo "项目目录: $PROJECT_ROOT"
echo ""
# 构建验证镜像
echo "📦 构建验证镜像..."
docker build -f docker/verify/Dockerfile -t easyai-deploy-verify:latest .
echo ""
echo "🚀 运行部署脚本(非交互模式,挂载 Docker Socket)..."
echo " 使用 DEPLOY_ACCESS=ip DEPLOY_IP=127.0.0.1"
echo ""
# 挂载 Docker Socket,使容器内 docker 命令使用宿主机 Docker
# 挂载项目目录,使用本地文件(避免容器内复制过时)
docker run --rm -it \
-v /var/run/docker.sock:/var/run/docker.sock \
-v "$PROJECT_ROOT:/workspace/easyai" \
-w /workspace/easyai \
-e DEPLOY_ACCESS=ip \
-e DEPLOY_IP=127.0.0.1 \
-e DEPLOY_FORCE_RECONFIG=1 \
easyai-deploy-verify:latest \
bash -c './start.sh'
EXIT_CODE=$?
echo ""
if [ $EXIT_CODE -eq 0 ]; then
echo "✅ 部署脚本验证通过"
else
echo "❌ 部署脚本验证失败 (exit code: $EXIT_CODE)"
fi
exit $EXIT_CODE
-135
View File
@@ -1,135 +0,0 @@
# API Key 加密部署与旧用户兼容
用户 API Key、平台认证、云凭据和存储通道支持分阶段加密升级。未接入加密配置且数据库从未启用加密的旧部署,可以保持旧脚本和 Compose 不变,只更新兼容镜像;现有用户 Key 和平台 Key 的值均保留,不需要重新生成或重新填写。旧模式暂时沿用原存储方式,不能把它描述为已完成加密。
## 部署行为
| 场景 | 行为 |
| --- | --- |
| 使用新版启动脚本新安装(原来无 `.env`) | Bash/PowerShell 脚本生成独立随机 32 字节 keyring,持久保存;默认关闭旧明文读取 |
| 旧部署仅更新镜像 | 无新增密钥配置时保持旧读写与鉴权;数据库中有密文或加密启用标记时拒绝缺少密钥的启动 |
| 旧部署执行新版脚本 | 默认保留原模式;显式首次接入后新写入加密、历史明文临时可读,重复执行保留 keyring |
| 完成迁移并重启 | 原用户 Key 按哈希鉴权,平台按原凭据执行;永久关闭旧明文读取 |
数据库保存加密启用标记和密钥验证值。一旦启用,加密密钥丢失、部分配置或错误配置都会报错,不会静默降级或用新的随机密钥替换已有密钥。`verify --finalize=true` 将数据库状态置为严格模式,后续重启即使旧环境仍写着兼容开关 `true`,也不会恢复明文读取。
## 私有配置
| 变量 | 要求 |
| --- | --- |
| `CONFIG_CREDENTIAL_ENCRYPTION_ACTIVE_KEY_ID` | 当前写入密钥 ID,字母、数字、下划线或连字符,长度 1 至 64,大小写精确匹配 |
| `CONFIG_CREDENTIAL_ENCRYPTION_KEYRING` | 单行 JSON 对象;值为独立随机 32 字节密钥的标准 Base64;脚本保留已有条目 |
| `CONFIG_CREDENTIAL_ALLOW_LEGACY_PLAINTEXT` | 新安装 `false`;存量接入阶段 `true`;迁移完成后 `false` |
这组密钥与 `CONFIG_SECURITY_CONFIG_ENCRYPTION_KEY`、JWT 和数据库密码独立,不得复用或派生。密钥只存私有 `.env` 或安全配置中心;Linux/macOS 文件为 0600,Windows 使用当前用户私有 DACL。数据库备份和密钥备份分开保管。更换目录、重新生成配置或恢复备份时,应带回对应的原 keyring。
Compose 会把 `.env` 注入新建容器,修改文件不会改变已经运行的进程环境,因此接入、关闭兼容和轮换后都要重建全部相关实例。网关独立使用 `AI_GATEWAY_CREDENTIAL_*` 配置;其 API、Worker 和迁移进程共享同一组网关密钥,不能复用 server-main 的密钥。
## 存量部署接入与迁移
先将所有读取凭据的应用和 Worker 升级到兼容版本,再允许加密写入。只理解明文的旧进程无法读取密文,混部时不能直接启用加密。以下命令要求镜像包含本次配套实现和 `/app/dist/credential-migrate.js`,不适用于此前的旧镜像。
1. 备份 MongoDB、相关 Redis 队列及私有配置,记录当前应用版本;暂停凭据修改并排空或暂停消费者。
2. 显式接入独立密钥,并重建相关服务。Linux/macOS:
```bash
DEPLOY_ENABLE_CREDENTIAL_ENCRYPTION=true ./update.sh
```
Windows PowerShell:
```powershell
$env:DEPLOY_ENABLE_CREDENTIAL_ENCRYPTION = 'true'
.\update.ps1
Remove-Item Env:DEPLOY_ENABLE_CREDENTIAL_ENCRYPTION
```
3. 核对容器内 MongoDB 主机及数据库,按次序执行正式迁移入口。默认 Compose 示例为 `172.21.0.3/aidraw`;自定义连接必须使用实际值。
```bash
bash scripts/migrate-credential-encryption.sh inventory 172.21.0.3/aidraw
bash scripts/migrate-credential-encryption.sh dry-run 172.21.0.3/aidraw
bash scripts/migrate-credential-encryption.sh apply 172.21.0.3/aidraw
bash scripts/migrate-credential-encryption.sh apply 172.21.0.3/aidraw
bash scripts/migrate-credential-encryption.sh verify 172.21.0.3/aidraw
bash scripts/migrate-credential-encryption.sh finalize 172.21.0.3/aidraw
```
PowerShell 使用 `scripts/Migrate-CredentialEncryption.ps1 -Mode inventory -ExpectedTarget '172.21.0.3/aidraw'`,依次替换 Mode 为 `dry-run`、`apply`、`apply`、`verify`、`finalize`。
4. 确认 verify 零待更改、零明文、零冲突;把 `.env` 的 `CONFIG_CREDENTIAL_ALLOW_LEGACY_PLAINTEXT` 改为 `false`,重建全部相关进程,恢复消费,并用升级前的原用户 Key 验证业务调用。
入口只运行迁移进程,不启动新的应用消费者。正式目标须使用 `--production=true --expected-target=主机/数据库` 精确匹配;工具从服务环境继承认证、数据库名和 Redis 队列配置,不在命令或日志中输出密钥。队列使用配置中的 Redis 主机和 DB,显式覆盖 Redis URL 时还需匹配 `--expected-redis=主机/DB`。
checkpoint 保存在私有 `data/credential-migration` 目录,绑定目标、密钥和轮换设置。旧验收工具的无版本 checkpoint 不能直接复用;换目标或轮换时使用新的 checkpoint。遇到并发冲突会停止,保留 checkpoint、处理冲突后重跑;verify 从头扫描,不会因 checkpoint 跳过残留明文。工具保持 MongoDB 4.4 兼容。
迁移保留用户 Key 的原值、所有权、权限、撤销状态和有效期,增加哈希、密文及摘要。只有哈希的历史 Key 继续鉴权,页面显示无法查看。普通列表不返回完整值;复制或查看由登录会话按需请求。平台凭据按原记录身份加密,编辑摘要会保留原值,复制平台会按新记录身份重新加密。
## 备份、还原与换机
数据库归档只包含数据库中的密文、API Key 哈希、记录身份和 `credential_encryption_state` 验证值,**不会包含部署环境中的解密密钥**。后台的手动/每日自动备份仍可使用,但单独下载数据库文件不足以完成换机恢复。禁止把私有密钥放入上传 OSS 的数据库归档。
需要配套保管:
- 对应时间点的完整 MongoDB 备份,包括主库中的加密状态集合;多库归档包含配置中列出的业务库。
- 完整原 keyring 和 active ID,包括所有仍被历史备份使用的旧密钥。密钥轮换完成不代表旧备份已变为新密钥;保留旧备份期间仍需保留对应旧密钥。
- 原 `CONFIG_SECURITY_CONFIG_ENCRYPTION_KEY` 和 `CONFIG_JWT_SECRET`:已有 MCP、插件、OIDC/JWT 配置使用另一套加密或兼容派生,不能只备份新 API Key 的 keyring。另需私有保存完整 `.env`、相关 `.env.*` 与安全配置中心配置,保留审计、OTP 等原有安全配置和连接参数。
- 需要恢复未完成任务时,同一维护窗口的 Redis 队列备份及相应消费者版本;数据库备份不包含 Redis、PostgreSQL、对象存储或模型文件。
### 独立密钥备份入口
Linux/macOS(输出目录应位于部署目录之外的私有位置):
```bash
mkdir -p "$HOME/.easyai-recovery"
chmod 700 "$HOME/.easyai-recovery"
bash scripts/credential-keys.sh export "$HOME/.easyai-recovery/credential-keys-20261012.env"
```
Windows PowerShell:
```powershell
.\scripts\Manage-CredentialKeys.ps1 -Mode export -Path 'D:\PrivateRecovery\credential-keys-20261012.env'
```
先创建私有输出目录。导出文件保留完整 keyring、兼容开关、配置主密钥和 JWT 原值;文件为 Linux/macOS 0600 或 Windows 当前用户独占 DACL。已有备份不会被覆盖,非法/部分配置和无 keyring 的旧模式不会生成替代密钥。这是**含完整密钥的私有配置文件,不是密码加密的归档**;离机副本放入加密保险库,与数据库归档分开保存。文件及其路径不应提交 Git、发聊天或上传公开存储。
首次接入后立即导出;每次新增或切换密钥后使用新文件再导出。数据库与密钥备份按日期、应用版本和归档指纹配对,并保留旧版本,不能仅留最近一次 active 密钥。旧模式还未生成加密 keyring 时,先保存旧 `.env` 和数据库原备份,接入后补充密钥备份。
### 还原顺序
1. 进入维护窗口,暂停所有写入及消费者,备份当前目标库、队列和私有配置。全库还原使用 `--drop`,会覆盖相应集合,无法与业务写入保证原子性。
2. 在目标目录准备 `.env`,修正地址等连接参数,恢复对应原密钥:
```bash
bash scripts/credential-keys.sh restore "$HOME/.easyai-recovery/credential-keys-20261012.env"
```
```powershell
.\scripts\Manage-CredentialKeys.ps1 -Mode restore -Path 'D:\PrivateRecovery\credential-keys-20261012.env'
```
还原前校验配置,其他环境变量保留;原 `.env` 会保存私有回退副本。恢复入口明确用备份的完整 keyring 替换目标加密配置,适用于目标已误生成另一组随机密钥的情形;其他运行中实例须同步恢复对应配置。旧密钥备份若缺少配置主密钥/JWT 两项,额外恢复原私有 `.env`,不能以新生成值替代。
3. 先重建承接还原的兼容版本服务,让它读取已恢复的环境配置;其他实例和消费者保持暂停,再通过原后台备份记录或上传备份还原。正在运行的进程不会自动读取 `.env` 更改;目标原数据采用另一套密钥而导致服务无法启动时,使用下述离线隔离流程。
4. 后台会先还原至随机命名的临时数据库,检查归档数据库名是否属于已配置业务库,验证全部 API/平台密文、旧 key ID,以及 MCP、插件、OIDC/JWT 加密配置。缺钥、错钥、数据损坏、不安全压缩包或预检失败会拒绝,业务库尚未被覆盖。临时库会保留原索引;加密环境恢复旧明文归档时,先在临时库迁移历史凭据并保持原用户 Key,再导出校验后的归档覆盖业务集合,恢复严格状态。
5. 还原后重建全部相关进程,清理业务缓存;验证原用户 Key 的权限/过期/撤销状态、原平台实际调用、配置查看与复制,再恢复消费。若同时恢复旧 Redis 任务,先运行正式迁移 `apply → verify` 清除历史任务载荷凭据;`verify/finalize` 通过后再重建和消费。
预检需要 Mongo 账户在临时数据库中创建、读写和删除数据的权限,以及临时磁盘空间;权限或空间不足会失败,不会绕过预检。临时库位于同一 Mongo 实例,需约一份数据库工作空间和一份归档空间。macOS 归档附带的 `._*` / `__MACOSX` 资源元数据在解压时忽略。正常完成/失败会清理临时库与校验归档;进程被强制终止时,可能留下 `easyai_credential_restore_*` 临时库,运维确认归属后清理。线上还原仅支持归档内原库名与当前配置匹配;换库名、目标无法启动或跨环境恢复优先走离线隔离流程。
### 无法启动或离线换库恢复
保持业务和消费者停止。在隔离 Mongo 实例/临时目标中用原密钥还原归档,完整保留 `_id`、API Key 哈希和加密状态集合;换库使用 MongoDB Tools 的 `--nsInclude`、`--nsFrom` / `--nsTo` 映射,先核对目标再执行,语法参照 [MongoDB Tools 官方说明](https://www.mongodb.com/docs/database-tools/mongorestore/mongorestore-examples/)。新 API/平台凭据的 AAD 绑定集合、记录身份和字段,未绑定数据库名称;同库结构的换库恢复可保留原值,不能重生成记录 ID 后直接搬密文。
如果归档早于加密接入,在隔离库使用相同原 keyring 和临时兼容开关 `true` 运行正式迁移,再 `verify/finalize`,制作新的已加密备份后恢复目标。`mongorestore --drop` 只删除归档包含的集合,旧归档没有加密状态集合时,直接覆盖已严格化的目标会留下旧标记而混入明文;不要跳过隔离迁移直接手工覆盖。
配置、密钥、数据库和队列确认对应后启动兼容版本,校验正常再切换业务。遗失任何仍在使用的密钥时,不能通过重置 active ID、设兼容开关或生成随机密钥恢复密文,应找回对应私有备份。
## 轮换与回滚
轮换先加入新密钥并保留旧密钥,再切换 active ID;使用新的 checkpoint 执行 `--rotate=true`,验证业务数据与数据库验证值均已迁移后,运行环境才可移除旧密钥;历史备份仍需保留原密钥。工具会随 `apply --rotate=true` 迁移验证值,并保持严格状态。只切换 active ID 不代表可以删除旧密钥。
首次加密写入后,只能回滚到支持当前密文格式的应用。镜像回滚不会撤销数据库变更,也不能恢复遗失的密钥;更新清单不得声称可无条件回滚到只理解明文的旧应用。恢复数据库和队列备份时,要协调应用版本并处理备份后产生的数据。
## 本地验收
2026-10-12 使用真实 MongoDB 4.4、Redis、PostgreSQL 18、旧发布镜像及本地构建的新镜像验证升级和迁移。用户 Key 与平台凭据在仅更新镜像、接入、迁移、关闭兼容和重启阶段保持原值;文本调用连接真实供应商。Windows PowerShell 5.1 和 pwsh 7.6.5 各 33 项原生扩展检查通过,覆盖密钥独立生成、幂等、非法配置拒绝、私有 DACL 和日志泄密。生产环境未执行迁移或部署。
-223
View File
@@ -1,223 +0,0 @@
# EasyAI Windows 一键部署方案
API Key 加密和未升级脚本用户的兼容路径统一见 [API Key 加密部署与旧用户兼容](API_KEY_ENCRYPTION_DEPLOYMENT.md)。新安装自动生成独立密钥;存量部署默认保持兼容,首次接入后迁移验证并重启,原用户 Key 和平台 Key 保持不变。密钥备份/还原入口为 `scripts/Manage-CredentialKeys.ps1`,数据库归档与原密钥分开保管,按上述说明先还原密钥、重建服务再还原数据。
## 1. 背景与目标
`start.sh` 是 Linux 下的一键部署脚本,本方案为 Windows 平台提供等效的 `start.ps1` PowerShell 脚本。针对 Windows 典型场景做如下**精简**:
- **仅 IP 访问**:不含域名模式与 HTTPS
- **本地访问无需放行端口**:选择本地 (127.0.0.1) 时,不涉及防火墙配置
- **Docker 未安装时**:用户可选择手动或自动安装 **Docker Desktop for Windows**(winget/Chocolatey)
- **支持变量非交互部署**:通过 `DEPLOY_IP` 或 `DEPLOY_ACCESS=local` 可跳过访问方式选择
## 2. Linux start.sh 流程梳理
### 2.1 整体流程
| 步骤 | 功能 | 说明 |
|------|------|------|
| 1 | 项目初始化 | 校验当前目录下存在 `docker-compose.yml` |
| 2 | 部署配置问答 | IP 或域名二选一,并采集对应参数 |
| 3 | 配置文件生成 | 生成/更新 `.env`、`.env.tools`、`.env.ASG` |
| 4 | Docker 安装与检查 | 检测并安装 Docker(Ubuntu/CentOS) |
| 5 | 启动服务 | `docker compose pull && docker compose up -d` |
| 6 | HTTPS 配置(可选) | 域名模式下执行 `https.sh` |
### 2.2 配置问答逻辑
- **IP 模式**:输入服务器 IP 地址,需要放行 3001、3002、3003 端口
- **域名模式**:输入域名,可选是否启用 HTTPS,需放行 80、443 端口
### 2.3 环境变量写入 .env
- `NUXT_PUBLIC_BASE_APIURL`:API 地址
- `NUXT_PUBLIC_BASE_SOCKETURL`:WebSocket 地址
- `NUXT_PUBLIC_SG_APIURL`:Agent 服务治理 API 地址
**IP 模式**:
```
NUXT_PUBLIC_BASE_APIURL=http://<IP>:3001
NUXT_PUBLIC_BASE_SOCKETURL=ws://<IP>:3002
NUXT_PUBLIC_SG_APIURL=http://<IP>:3003
```
**域名模式**:
```
NUXT_PUBLIC_BASE_APIURL=/api
NUXT_PUBLIC_BASE_SOCKETURL=wss://<domain>/socket.io
NUXT_PUBLIC_SG_APIURL=/asg-api
```
---
## 3. Windows 与 Linux 差异
| 项目 | Linux | Windows |
|------|-------|---------|
| 脚本语言 | Bash | PowerShell |
| 文本替换 | sed | `(Get-Content) -replace` 或 `Set-Content` |
| 用户输入 | `read -p` | `Read-Host` |
| 访问方式 | IP + 域名 + HTTPS | **仅 IP**(本地 / 局域网) |
| 端口放行 | 本地无特殊说明 | **本地访问无需放行**,局域网需放行 3001/3002/3003 |
| Docker | apt/yum 安装 Linux Docker | **Docker Desktop for Windows**,未安装时可选择手动或自动安装 |
---
## 4. Windows IP 访问方式设计(核心差异)
Windows 版**仅支持 IP 访问**,不包含域名模式与 HTTPS 配置。用户访问方式分为两类:
| 选项 | 访问方式 | IP 值 | 端口说明 |
|------|----------|-------|----------|
| 1 | 本地访问 | `127.0.0.1` | 本机访问,**无需放行端口** |
| 2 | 局域网访问 | 用户输入本机局域网 IP | 同网段设备访问,需放行 3001、3002、3003 |
**交互流程:**
1. `[1] 本地访问` → 自动使用 `127.0.0.1`,无需配置防火墙
2. `[2] 局域网访问` → 提示用户输入局域网 IP(可提示 `ipconfig` 查看),并提醒放行上述端口
---
## 5. 实现方案
### 5.1 脚本文件
- 文件路径:`easyai/start.ps1`
- 一行命令示例:
```powershell
git clone https://git.51easyai.com/wangbo/easyai; cd easyai; .\start.ps1
```
### 5.2 模块划分
| 模块 | 函数/区块 | 功能 |
|------|-----------|------|
| 项目初始化 | `Init-ProjectDir` | 检查 `docker-compose.yml`,切换到项目根目录 |
| 配置问答 | `Run-DeployQuestions` | 本地访问 / 局域网访问选择(含 IP 输入) |
| 环境变量 | `PromptOrEnv` | 支持环境变量覆盖,用于 CI/自动化 |
| 配置文件 | `Setup-EnvFiles` | 复制 sample 并修改 `.env`、`.env.tools`、`.env.ASG` |
| Docker 检查 | `Test-Docker` | 检测 `docker`,未安装则让用户选择**手动安装**或**自动安装** |
| 启动服务 | `Start-Services` | `docker compose pull` 与 `docker compose up -d` |
| 主流程 | `Main` | 串联上述步骤 |
### 5.3 环境变量支持(非交互模式)
| 变量 | 说明 |
|------|------|
| `DEPLOY_ACCESS` | `local` 时直接使用 `127.0.0.1`;`lan`/`ip` 需要同时传 `DEPLOY_IP` |
| `DEPLOY_IP` | 访问 IP(本地填 `127.0.0.1`,局域网填实际 IP) |
| `DEPLOY_DRY_RUN` | 1 时只生成配置,不安装/启动 Docker |
| `DEPLOY_FORCE_RECONFIG` | 非空时强制重新配置 |
| `DEPLOY_NON_INTERACTIVE` | 1 时缺少必要变量会直接报错,不进入问答 |
| `DEPLOY_DOCKER_INSTALL` | Docker Desktop 未安装时使用,`manual` 打开下载地址,`auto` 使用 winget/choco 自动安装 |
| `DEPLOY_NO_WAIT` | 1 时脚本结束或失败不等待按 Enter |
### 5.4 配置文件修改实现(仅 IP 模式)
使用 PowerShell 替换 `.env` 中相关行:
```powershell
$content = Get-Content .env -Raw -Encoding UTF8
$content = $content -replace 'NUXT_PUBLIC_BASE_APIURL=.*', "NUXT_PUBLIC_BASE_APIURL=http://${DEPLOY_IP}:3001"
$content = $content -replace 'NUXT_PUBLIC_BASE_SOCKETURL=.*', "NUXT_PUBLIC_BASE_SOCKETURL=ws://${DEPLOY_IP}:3002"
$content = $content -replace 'NUXT_PUBLIC_SG_APIURL=.*', "NUXT_PUBLIC_SG_APIURL=http://${DEPLOY_IP}:3003"
Set-Content .env -Value $content -Encoding UTF8 -NoNewline
```
### 5.5 Docker 处理策略
- **目标**:检测并安装 **Docker Desktop for Windows**(Windows 版 Docker)
- **检测**:执行 `docker --version` 或 `docker compose version`
- **未安装时**: prompt 让用户选择
- `[1] 手动安装`:输出安装说明及 Docker Desktop for Windows 下载链接,退出脚本
- `[2] 自动安装`:通过 winget 或 Chocolatey 安装 Docker Desktop for Windows,安装后需用户重启终端/机器再继续
- 若设置 `DEPLOY_DOCKER_INSTALL=manual|auto`,脚本会直接执行对应分支,不再提示选择
### 5.6 执行策略
PowerShell 默认可能禁止执行脚本,建议在文档中说明:
```powershell
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
```
或在脚本开头提示用户使用:
```powershell
powershell -ExecutionPolicy Bypass -File .\start.ps1
```
---
## 6. 部署完成输出
成功部署后输出访问地址,例如:
- 本地访问:`http://127.0.0.1:3010`
- 局域网访问:`http://<LAN_IP>:3010`
首次安装还会在完成页直接显示管理员登录信息:
```text
登录账号: admin
登录密码: <本次部署生成的 20 位随机密码>
```
该密码只在数据库首次创建 `admin` 时使用,创建后修改 `.env` 不会重置现有密码。初始密码没有独立过期时间,首次登录后应立即修改。已有 `.env` 的部署只提示密码沿用规则,不会把可能已经失效的环境变量值显示成当前登录密码。
---
## 7. 实现清单
- [x] 创建 `start.ps1` 主脚本
- [x] 实现 `Init-ProjectDir`:项目目录校验
- [x] 实现 `Run-DeployQuestions`:本地 / 局域网 IP 选择(无域名/HTTPS)
- [x] 实现 `Setup-EnvFiles`:配置文件生成与替换
- [x] 实现 `Test-Docker`:Docker 检测,未安装时 prompt「手动安装」或「自动安装」
- [x] 实现 `Install-Docker`:自动安装 **Docker Desktop for Windows**(winget / Chocolatey)
- [x] 实现 `Start-Services`:`docker compose` 启动
- [x] 实现 `Main`:主流程串联
- [x] 支持 `DEPLOY_DRY_RUN`、`DEPLOY_IP` 环境变量非交互模式
- [x] 支持 `DEPLOY_ACCESS=local`、`DEPLOY_NON_INTERACTIVE`、`DEPLOY_DOCKER_INSTALL` 非交互控制
- [x] 在 README 或文档中补充 Windows 部署说明与执行策略
- [x] 创建 `scripts/test-start-ps1-env.py` 验证 .env 替换逻辑
---
## 8. Windows 测试说明
### 本地测试(需 Windows 或 WSL + PowerShell)
```powershell
# 非交互 + 仅生成配置(不启动 Docker)
$env:DEPLOY_DRY_RUN = "1"
$env:DEPLOY_IP = "127.0.0.1"
.\start.ps1
# 或使用本机访问模式变量
$env:DEPLOY_DRY_RUN = "1"
$env:DEPLOY_ACCESS = "local"
$env:DEPLOY_NO_WAIT = "1"
.\start.ps1
# 检查 .env 是否已正确写入
Select-String -Path .env -Pattern "NUXT_PUBLIC_BASE"
```
### 完整部署测试(需 Windows + Docker Desktop)
```powershell
.\start.ps1
# 按提示选择 [1] 本地访问 或 [2] 局域网访问
# 若未安装 Docker,选择 [1] 手动 或 [2] 自动安装
```
### 执行策略(若提示无法运行脚本)
```powershell
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
# 或
powershell -ExecutionPolicy Bypass -File .\start.ps1
```
+20 -104
View File
@@ -3,14 +3,7 @@ map $http_upgrade $connection_upgrade {
'' close;
}
# $host 会丢失客户端 authority 中的显式端口;后端公开 URL 推断需保留 $http_host。
map $http_host $easyai_forwarded_host {
default $http_host;
'' $host;
}
# EASYAI_WWW_REDIRECT_BEGIN
# www 域名重定向;IP 模式生成配置时会删除整个区块。
# www 域名重定向,使用二级域名注释掉此部分的域名重定向
server {
listen 80;
listen [::]:80;
@@ -33,34 +26,17 @@ server {
return 301 https://51easyai.com$request_uri;
}
}
# EASYAI_WWW_REDIRECT_END
server {
listen 80;
listen [::]:80;
server_name 51easyai.com;
# EasyAI response gzip: compress JSON/text API responses; SSE/WebSocket stay uncompressed by content type.
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_comp_level 4;
gzip_proxied any;
gzip_types
application/json
application/javascript
application/xml
text/css
text/javascript
text/plain
text/xml
image/svg+xml;
# ssl 版本控制
ssl_protocols TLSv1.2 TLSv1.3; # 只启用 TLS 1.2 和 TLS 1.3
# 通用安全头部
add_header X-Content-Type-Options nosniff;
# add_header X-Frame-Options "SAMEORIGIN";
add_header X-Frame-Options "SAMEORIGIN";
add_header X-XSS-Protection "1; mode=block";
# 用于 certbot 验证的配置
location /.well-known/acme-challenge/ {
@@ -81,43 +57,6 @@ server {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $host;
# 如果 X-Frame-Options 是上游返回的,隐藏它
proxy_hide_header X-Frame-Options;
# Wujie 会 fetch HTML,必须允许跨源读取
add_header Access-Control-Allow-Origin "*" always;
add_header Access-Control-Allow-Methods "GET, HEAD, OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type, Accept, Authorization" always;
add_header Cross-Origin-Resource-Policy "cross-origin" always;
}
# 规范化 API 根路径,确保 https://example.com/api 不会落到前端 location /。
location = /api {
return 308 /api/;
}
# Sandbox 只能由 easyai-server 通过 Docker 内网访问,禁止从公网 API 入口绕过。
location = /api/sandbox {
return 404;
}
location ^~ /api/sandbox/ {
return 404;
}
# 废弃的服务端任意 HTTP 调试入口不得对公网开放。
location = /api/integration/platform-api/execute {
return 404;
}
# 旧教育平台 SSO 必须先配置双方独立密钥,默认不对公网开放。
location = /api/auth/sign/token {
return 404;
}
location = /api/auth/verify/token {
return 404;
}
location /api/ {
@@ -137,9 +76,7 @@ server {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $easyai_forwarded_host;
proxy_set_header X-Forwarded-Host $easyai_forwarded_host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header Host $host;
}
location /asg-api/ {
@@ -154,19 +91,6 @@ server {
proxy_set_header Host $host;
}
# Agent 记忆服务 API(可选,用于健康检查或外部调用)
location /ams-api/ {
proxy_pass http://127.0.0.1:3004/;
proxy_read_timeout 300s;
client_max_body_size 20M;
proxy_redirect off;
proxy_set_header X-Original-Prefix '/ams-api';
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $host;
}
location /plugins/ {
proxy_pass http://127.0.0.1:3020/plugins/;
proxy_redirect off;
@@ -176,21 +100,23 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $host;
}
# Dozzle 直接暴露会泄露全部容器日志、环境与下载入口。
# 如需排障,请通过服务器本机或 SSH 隧道访问回环端口,不允许公网代理。
location = /logs-web {
return 404;
location /logs-web/ {
proxy_pass http://127.0.0.1:8080/logs-web/;
proxy_redirect off;
proxy_set_header X-Original-Prefix '/logs-web';
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $host;
}
location ^~ /logs-web/ {
return 404;
}
location = /jupyterlab {
return 404;
}
location ^~ /jupyterlab/ {
return 404;
location /jupyterlab/ {
proxy_pass http://127.0.0.1:8888/lab/;
proxy_redirect off;
proxy_set_header X-Original-Prefix '/lab';
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $host;
}
location /socket.io {
proxy_pass http://127.0.0.1:3002;
@@ -208,9 +134,7 @@ server {
location /mcp {
proxy_pass http://127.0.0.1:3001/mcp;
proxy_read_timeout 300s;
proxy_set_header Host $easyai_forwarded_host;
proxy_set_header X-Forwarded-Host $easyai_forwarded_host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
@@ -220,12 +144,4 @@ server {
proxy_buffering off; # 对于 WebSocket 连接禁用缓冲
}
location = /sandbox {
return 404;
}
location ^~ /sandbox/ {
return 404;
}
}
+7 -347
View File
@@ -1,5 +1,3 @@
#!/bin/bash
if command -v nginx &> /dev/null; then
echo "✅ Nginx 已安装,跳过安装步骤"
else
@@ -105,335 +103,8 @@ else
exit 1
fi
# ===== 域名配置交互与文件生成 =====
sanitize_domain() {
local domain=$1
domain="${domain#http://}"
domain="${domain#https://}"
domain="${domain%%/*}"
domain="${domain%;}"
echo "$domain" | xargs
}
validate_domain_value() {
local domain=$1
[[ "$domain" =~ ^[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)+$ ]]
}
get_env_domain() {
local domain="${HTTPS_DOMAIN:-${EASYAI_PROXY_DOMAIN:-${DEPLOY_DOMAIN:-}}}"
if [ -z "$domain" ]; then
return 0
fi
domain="$(sanitize_domain "$domain")"
if ! validate_domain_value "$domain"; then
echo "❌ 域名变量格式不正确: $domain" >&2
exit 1
fi
echo "$domain"
}
is_non_interactive() {
[ "${HTTPS_NON_INTERACTIVE:-${DEPLOY_NON_INTERACTIVE:-0}}" = "1" ] || [ "${CI:-}" = "true" ] || [ ! -t 0 ]
}
prompt_domain() {
local domain
domain="$(get_env_domain)"
if [ -n "$domain" ]; then
echo "$domain"
return 0
fi
if is_non_interactive; then
echo "❌ 当前为非交互环境,HTTPS 配置需要传入域名变量" >&2
echo " 请设置 DEPLOY_DOMAIN=<域名> 或 EASYAI_PROXY_DOMAIN=<域名>" >&2
exit 1
fi
while true; do
read -r -p "🌐 请输入域名(例如 demo.example.com): " domain
domain="$(sanitize_domain "$domain")"
if [ -n "$domain" ] && validate_domain_value "$domain"; then
echo "$domain"
return 0
fi
echo "❌ 域名格式不正确,请重新输入。"
done
}
escape_sed() {
echo "$1" | sed 's/[.[\*^$()+?{}|/]/\\&/g'
}
get_primary_domain_from_conf() {
local conf_file=$1
awk '
/server_name/ {
for (i = 2; i <= NF; i++) {
gsub(";", "", $i)
if ($i !~ /^www\./ && $i !~ /^\*/ && $i != "localhost") {
print $i
exit
}
}
}
' "$conf_file"
}
create_conf_by_template() {
local domain=$1
local target_file=$2
# 始终优先使用仓库维护的完整代理模板,避免仅生成前端 location /。
if [ -f "./easyai-proxy.conf.sample" ]; then
cp "./easyai-proxy.conf.sample" "./$target_file"
sed -i.bak "s/51easyai\.com/$domain/g" "./$target_file"
rm -f "./$target_file.bak"
return 0
fi
if [ -f "./demo.51easyai.com.conf" ]; then
cp "./demo.51easyai.com.conf" "./$target_file"
sed -i.bak "s/www\.demo\.51easyai\.com/www.$domain/g; s/demo\.51easyai\.com/$domain/g" "./$target_file"
rm -f "./$target_file.bak"
return 0
fi
if [ -f "./2.conf" ]; then
cp "./2.conf" "./$target_file"
sed -i.bak "s/www\.2/www.$domain/g; s/\b2\b/$domain/g" "./$target_file"
rm -f "./$target_file.bak"
return 0
fi
cat > "./$target_file" <<EOF
map \$http_upgrade \$connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
listen [::]:80;
server_name www.$domain;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
try_files \$uri =404;
}
location / {
return 301 https://$domain\$request_uri;
}
}
server {
listen 80;
listen [::]:80;
server_name $domain;
# EasyAI response gzip: compress JSON/text API responses; SSE/WebSocket stay uncompressed by content type.
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_comp_level 4;
gzip_proxied any;
gzip_types
application/json
application/javascript
application/xml
text/css
text/plain
text/xml
image/svg+xml;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
try_files \$uri =404;
}
location / {
proxy_pass http://127.0.0.1:3010/;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_set_header Host \$host;
}
}
EOF
}
conf_contains_domain() {
local conf_file=$1
local domain=$2
grep -Eq "server_name[^;]*(^|[[:space:]])(www\\.)?${domain//./\\.}([[:space:];]|$)" "./$conf_file"
}
normalize_domain_action() {
local action=$1
action="$(printf '%s' "$action" | tr '[:upper:]' '[:lower:]')"
case "$action" in
1|replace) echo "1" ;;
2|add|"") echo "2" ;;
*)
echo "❌ EASYAI_PROXY_DOMAIN_ACTION 只能是 replace 或 add,当前为: $action" >&2
exit 1
;;
esac
}
upsert_domain_for_conf() {
local conf_file=$1
local env_domain="${2:-}"
local env_action="${3:-}"
local mode
local new_domain
echo "ℹ️ 检测到当前目录已有配置文件: $conf_file"
if [ -n "$env_domain" ]; then
new_domain="$(sanitize_domain "$env_domain")"
if ! validate_domain_value "$new_domain"; then
echo "❌ 域名变量格式不正确: $new_domain"
exit 1
fi
if conf_contains_domain "$conf_file" "$new_domain"; then
echo "ℹ️ 配置文件已包含域名 $new_domain,无需修改。"
return 0
fi
mode="$(normalize_domain_action "$env_action")"
echo "使用变量配置域名: $new_domain, action=$([ "$mode" = "1" ] && echo replace || echo add)"
else
while true; do
echo "请选择域名处理方式:"
echo " 1) 替换当前域名"
echo " 2) 新增一个域名"
read -r -p "请输入选项 (1/2): " mode
case "$mode" in
1|2) break ;;
*) echo "❌ 无效选项,请输入 1 或 2" ;;
esac
done
new_domain=$(prompt_domain)
fi
if [ "$mode" = "1" ]; then
local old_domain
old_domain=$(get_primary_domain_from_conf "./$conf_file")
if [ -z "$old_domain" ]; then
echo "⚠️ 未能自动识别旧域名,将直接尝试新增域名。"
mode="2"
else
local old_domain_escaped
local new_domain_escaped
old_domain_escaped=$(escape_sed "$old_domain")
new_domain_escaped=$(escape_sed "$new_domain")
sed -i.bak "s/www\\.$old_domain_escaped/www.$new_domain_escaped/g; s/$old_domain_escaped/$new_domain_escaped/g" "./$conf_file"
rm -f "./$conf_file.bak"
echo "✅ 已将域名从 $old_domain 替换为 $new_domain"
fi
fi
if [ "$mode" = "2" ]; then
local changed=0
if ! grep -Eq "server_name[^;]*[[:space:]]$new_domain([[:space:];]|$)" "./$conf_file"; then
sed -i.bak "/server_name/s/;/ $new_domain;/g" "./$conf_file"
changed=1
fi
if ! grep -Eq "server_name[^;]*[[:space:]]www\\.$new_domain([[:space:];]|$)" "./$conf_file"; then
sed -i.bak "/server_name/s/;/ www.$new_domain;/g" "./$conf_file"
changed=1
fi
rm -f "./$conf_file.bak"
if [ "$changed" -eq 1 ]; then
echo "✅ 已新增域名:$new_domain(含 www.$new_domain)"
else
echo "ℹ️ 配置文件已包含该域名,无需新增。"
fi
fi
}
detect_existing_proxy_conf() {
local preferred_file=$1
local domain_filter="${2:-}"
local -a candidates=()
local -a matched_candidates=()
local conf
if [ -n "$preferred_file" ] && [ -f "./$preferred_file" ]; then
echo "$preferred_file"
return 0
fi
while IFS= read -r conf; do
if grep -Eq "^[[:space:]]*server_name[[:space:]]+" "./$conf"; then
candidates+=("$conf")
fi
done < <(ls -1 *.conf 2>/dev/null || true)
if [ "${#candidates[@]}" -eq 0 ]; then
return 1
fi
if [ -n "$domain_filter" ]; then
for conf in "${candidates[@]}"; do
if conf_contains_domain "$conf" "$domain_filter"; then
matched_candidates+=("$conf")
fi
done
if [ "${#matched_candidates[@]}" -eq 1 ]; then
echo "${matched_candidates[0]}"
return 0
fi
if [ "${#matched_candidates[@]}" -gt 1 ] && is_non_interactive; then
echo "❌ 多个配置文件都包含 $domain_filter,请通过 EASYAI_PROXY_CONF 指定文件" >&2
exit 1
fi
fi
if [ "${#candidates[@]}" -eq 1 ]; then
echo "${candidates[0]}"
return 0
fi
if is_non_interactive; then
echo "❌ 检测到多个可用配置文件,请通过 EASYAI_PROXY_CONF 指定文件" >&2
exit 1
fi
echo "⚠️ 检测到多个可用配置文件,请选择一个:" >&2
local i
for i in "${!candidates[@]}"; do
printf " %s) %s\n" "$((i + 1))" "${candidates[$i]}" >&2
done
while true; do
read -r -p "请输入序号: " i
if [[ "$i" =~ ^[0-9]+$ ]] && [ "$i" -ge 1 ] && [ "$i" -le "${#candidates[@]}" ]; then
echo "${candidates[$((i - 1))]}"
return 0
fi
echo "❌ 无效序号,请重新输入。"
done
}
echo "🚀 复制当前目录的配置文件到nginx配置文件目录"
# 支持 EASYAI_PROXY_CONF 指定配置文件(如 51easyai.com.conf)
PREFERRED_CONF_FILE="${EASYAI_PROXY_CONF:-}"
DEFAULT_NEW_CONF_FILE="${EASYAI_PROXY_CONF:-easyai-proxy.conf}"
HTTPS_DOMAIN_INPUT="$(get_env_domain)"
CONF_FILE=$(detect_existing_proxy_conf "$PREFERRED_CONF_FILE" "$HTTPS_DOMAIN_INPUT")
if [ -n "$CONF_FILE" ]; then
upsert_domain_for_conf "$CONF_FILE" "$HTTPS_DOMAIN_INPUT" "${EASYAI_PROXY_DOMAIN_ACTION:-}"
else
CONF_FILE="$DEFAULT_NEW_CONF_FILE"
echo "ℹ️ 当前目录未找到可用 nginx 配置文件(*.conf 且包含 server_name)"
input_domain=$(prompt_domain)
create_conf_by_template "$input_domain" "$CONF_FILE"
echo "✅ 已根据域名 $input_domain 创建配置文件: ./$CONF_FILE"
fi
cp "./$CONF_FILE" "/etc/nginx/conf.d/$CONF_FILE"
cp -r ./easyai-proxy.conf /etc/nginx/conf.d/
echo "🚀 重载nginx"
sudo nginx -s reload
@@ -442,21 +113,10 @@ sudo nginx -s stop
echo "🚀 使用certbot 自动配置证书"
# 从 Nginx 配置文件中提取所有域名
SERVER_NAME_LINES=$(
if [ -f "/etc/nginx/conf.d/$CONF_FILE" ]; then
grep "server_name" "/etc/nginx/conf.d/$CONF_FILE" 2>/dev/null || true
else
find /etc/nginx/conf.d/ -name "*.conf" -exec grep "server_name" {} \; 2>/dev/null || true
fi
)
DOMAINS=$(echo "$SERVER_NAME_LINES" | \
DOMAINS=$(find /etc/nginx/conf.d/ -name "easyai-proxy.conf" -type f -exec grep "server_name" {} \; | \
grep -v "#" | \
awk '{for(i=2;i<=NF;i++) if($i!=";") print $i}' | \
sed 's/;//g' | \
sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | \
grep -E '^[A-Za-z0-9.-]+$' | \
grep -vE '^(\*|localhost)$' | \
sort -u | \
tr '\n' ' ')
@@ -466,22 +126,22 @@ if [ -n "$DOMAINS" ]; then
sudo nginx -s stop
# 构建域名参数字符串
DOMAIN_ARGS=()
DOMAIN_ARGS=""
for domain in $DOMAINS; do
DOMAIN_ARGS+=("-d" "$domain")
DOMAIN_ARGS="$DOMAIN_ARGS -d $domain"
done
# 使用 certbot --nginx 插件安装证书
sudo certbot --nginx \
--non-interactive \
--agree-tos \
--email "${CERTBOT_EMAIL:-wangbo@51easyai.com}" \
--email wangbo@51easyai.com \
--rsa-key-size 2048 \
--preferred-challenges http \
--force-renewal \
"${DOMAIN_ARGS[@]}"
$DOMAIN_ARGS
# 启动 Nginx 服务
echo "启动 Nginx 服务..."
sudo nginx
fi
fi
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

-480
View File
@@ -1,480 +0,0 @@
#Requires -Version 5.1
<#
.SYNOPSIS
Mirror a public image (default: Docker Hub via optional China mirror) to your registry,
keeping only selected platforms (default linux/amd64 + linux/arm64), then push one multi-arch tag.
.DESCRIPTION
Uses skopeo copy per digest + docker buildx imagetools create.
Docker Desktop users: credentials are resolved from credential helper into a UTF-8 no-BOM auth file.
.PARAMETER Source
Docker Hub style: redis:latest, nginx:alpine, bitnami/redis:7, library/redis:7, docker.io/prom/prometheus:latest
Or any registry: ghcr.io/org/image:tag (no Docker-Hub mirror rewrite)
.PARAMETER DestRepo
Target repository without tag, e.g. registry.cn-shanghai.aliyuncs.com/myns/redis
.PARAMETER DestTag
Tag on destination (default: tag parsed from -Source)
.PARAMETER MirrorRegistry
Pull-through mirror host for Docker Hub only, e.g. docker.m.daocloud.io. Empty = use docker.io directly.
.PARAMETER Platforms
OS/arch list to keep, default linux/amd64 and linux/arm64.
.PARAMETER SkopeoParallelCopies
skopeo --image-parallel-copies (layers in flight). 0 = auto (higher default for better upload utilization).
If upload still looks underused, try 32 or 40; if CPU/memory spikes, lower to 12.
Other tuning (outside this script): use ACR region closest to you; Docker Desktop -> Resources give more CPUs/RAM;
avoid VPN throttling; wired Ethernet; temporarily pause heavy uploads on same link.
.EXAMPLE
.\mirror-image-to-registry.ps1 -Source nginx:alpine -DestRepo registry.cn-shanghai.aliyuncs.com/myns/nginx -DestTag alpine
.EXAMPLE
.\mirror-image-to-registry.ps1 -Source docker.io/library/redis:latest -DestRepo registry.example.com/prod/redis -MirrorRegistry ""
#>
param(
[Parameter(Mandatory = $true)][string] $Source,
[Parameter(Mandatory = $true)][string] $DestRepo,
[string] $DestTag = "",
[string] $MirrorRegistry = "docker.m.daocloud.io",
[string[]] $Platforms = @("linux/amd64", "linux/arm64"),
[string] $SkopeoImage = "quay.io/skopeo/stable:latest",
[string] $DestUsername = "",
[string] $DestPassword = "",
[int] $SkopeoParallelCopies = 0
)
$ErrorActionPreference = "Stop"
# Parallel layer copies: 0 = auto (scale with CPU cores, capped)
$nCpu = [Environment]::ProcessorCount
if ($SkopeoParallelCopies -le 0) {
$script:EffectiveParallelCopies = [Math]::Min(40, [Math]::Max(16, $nCpu * 4))
Write-Host "SkopeoParallelCopies=auto -> $script:EffectiveParallelCopies (logical CPUs: $nCpu)"
} else {
$script:EffectiveParallelCopies = $SkopeoParallelCopies
Write-Host "SkopeoParallelCopies=$script:EffectiveParallelCopies"
}
function Test-CmdInPath {
param([string]$Name)
return [bool](Get-Command $Name -ErrorAction SilentlyContinue)
}
function Get-RegistryHostFromRepo {
param([string]$Repo)
return ($Repo -split '/')[0]
}
function Get-DockerConfigPath {
return (Join-Path $env:USERPROFILE ".docker\config.json")
}
function Write-JsonFileUtf8NoBom {
param([string]$Path, [string]$JsonText)
$utf8NoBom = New-Object System.Text.UTF8Encoding $false
[System.IO.File]::WriteAllText($Path, $JsonText, $utf8NoBom)
}
function New-SkopeoAuthJsonFile {
param(
[Parameter(Mandatory = $true)][string]$RegistryHost,
[Parameter(Mandatory = $true)][string]$Username,
[Parameter(Mandatory = $true)][string]$Password
)
$pair = "${Username}:${Password}"
$authB64 = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($pair))
$payload = @{
auths = @{
$RegistryHost = @{
auth = $authB64
}
}
}
$path = [System.IO.Path]::GetTempFileName() + ".json"
$jsonText = $payload | ConvertTo-Json -Compress -Depth 6
Write-JsonFileUtf8NoBom -Path $path -JsonText $jsonText
return $path
}
function Get-InlineAuthFromDockerConfig {
param([string]$DockerConfigPath, [string]$RegistryHost)
if (-not (Test-Path -LiteralPath $DockerConfigPath)) { return $null }
$txt = Get-Content -LiteralPath $DockerConfigPath -Raw -ErrorAction Stop
$cfg = $txt | ConvertFrom-Json
if (-not $cfg.auths) { return $null }
foreach ($p in $cfg.auths.PSObject.Properties) {
if ($p.Name -eq $RegistryHost -and $p.Value.auth) {
return [string]$p.Value.auth
}
}
return $null
}
function Get-CredentialFromDockerHelper {
param([string]$RegistryHost, [string]$HelperName)
if (-not $HelperName) { return $null }
$exeName = "docker-credential-$HelperName"
$exe = Get-Command $exeName -ErrorAction SilentlyContinue
if (-not $exe) {
$candidates = @(
(Join-Path ${env:ProgramFiles} "Docker\Docker\resources\bin\$exeName.exe"),
(Join-Path ${env:ProgramFiles} "Docker\Docker\resources\bin\$HelperName.exe")
)
foreach ($c in $candidates) {
if (Test-Path -LiteralPath $c) { $exe = Get-Command $c -ErrorAction SilentlyContinue; break }
}
}
if (-not $exe) {
Write-Warning "Credential helper '$exeName' not found on PATH or Docker resources."
return $null
}
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = $exe.Source
$psi.Arguments = "get"
$psi.RedirectStandardInput = $true
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
$psi.UseShellExecute = $false
$psi.CreateNoWindow = $true
$p = New-Object System.Diagnostics.Process
$p.StartInfo = $psi
[void]$p.Start()
$p.StandardInput.WriteLine($RegistryHost)
$p.StandardInput.Close()
$out = $p.StandardOutput.ReadToEnd()
$err = $p.StandardError.ReadToEnd()
$p.WaitForExit()
if ($p.ExitCode -ne 0) {
Write-Warning "docker-credential-$HelperName get failed (exit $($p.ExitCode)): $err"
return $null
}
try {
$j = $out | ConvertFrom-Json
return @{ Username = $j.Username; Secret = $j.Secret }
} catch {
return $null
}
}
function Resolve-DestinationAuthFile {
param([string]$RegistryHost)
if ($DestUsername -and $DestPassword) {
Write-Host "Using -DestUsername / -DestPassword for registry push auth."
return (New-SkopeoAuthJsonFile -RegistryHost $RegistryHost -Username $DestUsername -Password $DestPassword)
}
if ($env:ALIYUN_CR_USER -and $env:ALIYUN_CR_PASS) {
Write-Host "Using ALIYUN_CR_USER / ALIYUN_CR_PASS for registry push auth."
return (New-SkopeoAuthJsonFile -RegistryHost $RegistryHost -Username $env:ALIYUN_CR_USER -Password $env:ALIYUN_CR_PASS)
}
if ($env:DEST_REGISTRY_USER -and $env:DEST_REGISTRY_PASS) {
Write-Host "Using DEST_REGISTRY_USER / DEST_REGISTRY_PASS for registry push auth."
return (New-SkopeoAuthJsonFile -RegistryHost $RegistryHost -Username $env:DEST_REGISTRY_USER -Password $env:DEST_REGISTRY_PASS)
}
$dockerCfg = Get-DockerConfigPath
$inline = Get-InlineAuthFromDockerConfig -DockerConfigPath $dockerCfg -RegistryHost $RegistryHost
if ($inline) {
Write-Host "Using inline auth from Docker config.json for $RegistryHost"
$path = [System.IO.Path]::GetTempFileName() + ".json"
$payload = @{ auths = @{ $RegistryHost = @{ auth = $inline } } }
$jsonText = $payload | ConvertTo-Json -Compress -Depth 6
Write-JsonFileUtf8NoBom -Path $path -JsonText $jsonText
return $path
}
if (Test-Path -LiteralPath $dockerCfg) {
$cfg = (Get-Content -LiteralPath $dockerCfg -Raw) | ConvertFrom-Json
$helperName = $null
if ($cfg.credHelpers) {
foreach ($hp in $cfg.credHelpers.PSObject.Properties) {
if ($hp.Name -eq $RegistryHost) {
$helperName = [string]$hp.Value
break
}
}
}
if (-not $helperName -and $cfg.credsStore) {
$helperName = $cfg.credsStore
}
if ($helperName) {
Write-Host "Resolving credentials via docker-credential-$helperName (Docker Desktop)..."
$c = Get-CredentialFromDockerHelper -RegistryHost $RegistryHost -HelperName $helperName
if ($c -and $c.Username -and $c.Secret) {
return (New-SkopeoAuthJsonFile -RegistryHost $RegistryHost -Username $c.Username -Password $c.Secret)
}
}
}
return $null
}
function Split-ImageRefPathAndTag {
param([string]$Ref)
if ($Ref -match '@sha256:') {
throw "Digest-pinned sources (@sha256:...) are not supported. Use a tag."
}
$tag = "latest"
$path = $Ref
if ($Ref -match '^(.+):([^:/]+)$') {
$path = $matches[1]
$tag = $matches[2]
}
return @{ Path = $path; Tag = $tag }
}
function Test-IsDockerHubOnlyPath {
param([string]$PathPart)
if ($PathPart -match '^[a-zA-Z0-9][a-zA-Z0-9.-]*\.[a-zA-Z]+/') { return $false }
return $true
}
function Resolve-DockerHubRepoPath {
param([string]$PathPart)
if ($PathPart -notmatch '/') {
return "library/$PathPart"
}
return $PathPart
}
# Returns: IsDockerHub, InspectRef (docker://...), SourceRepoPath for copy by digest (host/repo without tag), Tag
function Resolve-SourceInspectReference {
param(
[string]$Source,
[string]$MirrorRegistry
)
$s = $Source.Trim()
if ($s.StartsWith("docker://")) {
$s = $s.Substring(9)
}
$st = Split-ImageRefPathAndTag $s
$path = $st.Path
$tag = $st.Tag
if (Test-IsDockerHubOnlyPath $path) {
$repo = Resolve-DockerHubRepoPath $path
$hubRef = "docker.io/${repo}:${tag}"
if ($MirrorRegistry) {
$inspect = "docker://${MirrorRegistry}/${repo}:${tag}"
} else {
$inspect = "docker://${hubRef}"
}
return @{
IsDockerHub = $true
InspectRef = $inspect
SourceRepoPath = if ($MirrorRegistry) { "${MirrorRegistry}/${repo}" } else { "docker.io/${repo}" }
Tag = $tag
}
}
# Full registry reference e.g. ghcr.io/org/img:tag
$firstSlash = $path.IndexOf('/')
if ($firstSlash -lt 1) { throw "Invalid source: $Source" }
$hostPart = $path.Substring(0, $firstSlash)
$repoPath = $path.Substring($firstSlash + 1)
if (-not $repoPath) { throw "Invalid source: $Source" }
$inspect = "docker://${hostPart}/${repoPath}:${tag}"
return @{
IsDockerHub = $false
InspectRef = $inspect
SourceRepoPath = "${hostPart}/${repoPath}"
Tag = $tag
}
}
function Get-DockerAuthVolumeArgs {
param([string]$AuthFilePath)
if ($AuthFilePath -and (Test-Path -LiteralPath $AuthFilePath)) {
return @("-v", "${AuthFilePath}:/auth.json:ro")
}
return @()
}
function Invoke-SkopeoCopyWithAuth {
param(
[string]$SkopeoImage,
[string]$SourceRef,
[string]$DestRef,
[string]$AuthFilePath,
[int]$ParallelCopies = 16,
[int]$GoMaxProcs = 0
)
$parallelArgs = @()
if ($ParallelCopies -gt 0) {
$parallelArgs = @("--image-parallel-copies", "$ParallelCopies")
}
if ($GoMaxProcs -le 0) { $GoMaxProcs = [Environment]::ProcessorCount }
$vol = Get-DockerAuthVolumeArgs -AuthFilePath $AuthFilePath
if (-not (Test-CmdInPath "skopeo")) {
if (-not $AuthFilePath) {
throw "No auth file for skopeo copy. Use -DestUsername/-DestPassword, DEST_REGISTRY_*, ALIYUN_CR_*, or docker login with resolvable credentials."
}
# GOMAXPROCS lets the Go runtime use more threads for TLS/registry I/O alongside parallel blob copies
$dockerLine = @("run", "--rm", "-e", "GOMAXPROCS=$GoMaxProcs") + $vol + @($SkopeoImage) + @("copy", "--authfile", "/auth.json") + $parallelArgs + @($SourceRef, $DestRef)
& docker @dockerLine
$script:SkopeoLastExitCode = $LASTEXITCODE
return
}
if ($AuthFilePath) {
$env:GOMAXPROCS = "$GoMaxProcs"
try {
& skopeo copy --authfile $AuthFilePath @parallelArgs $SourceRef $DestRef
} finally {
Remove-Item Env:GOMAXPROCS -ErrorAction SilentlyContinue
}
} else {
$env:GOMAXPROCS = "$GoMaxProcs"
try {
& skopeo copy @parallelArgs $SourceRef $DestRef
} finally {
Remove-Item Env:GOMAXPROCS -ErrorAction SilentlyContinue
}
}
$script:SkopeoLastExitCode = $LASTEXITCODE
}
function Invoke-SkopeoInspectRaw {
param([string]$SkopeoImage, [string]$InspectRef)
if (Test-CmdInPath "skopeo") {
$rawLines = & skopeo inspect --raw $InspectRef 2>&1
$code = $LASTEXITCODE
} else {
$rawLines = & docker run --rm $SkopeoImage inspect --raw $InspectRef 2>&1
$code = $LASTEXITCODE
}
return [PSCustomObject]@{ RawLines = $rawLines; ExitCode = $code }
}
function Test-PlatformMatch {
param($ManifestEntry, [string]$OsWant, [string]$ArchWant)
if (-not $ManifestEntry.platform) { return $false }
$os = $ManifestEntry.platform.os
$arch = $ManifestEntry.platform.architecture
if ($os -ne $OsWant) { return $false }
if ($arch -eq $ArchWant) { return $true }
return $false
}
if (-not (Test-CmdInPath "docker")) {
Write-Error "docker not found."
}
$resolved = Resolve-SourceInspectReference -Source $Source -MirrorRegistry $MirrorRegistry
if (-not $DestTag) {
$DestTag = $resolved.Tag
}
$destHost = Get-RegistryHostFromRepo -Repo $DestRepo
$destImage = "${DestRepo}:${DestTag}"
if (-not (Test-CmdInPath "skopeo")) {
Write-Host "Using skopeo from Docker image: $SkopeoImage"
}
Write-Host "Inspect: $($resolved.InspectRef)"
Write-Host "Destination: $destImage"
Write-Host "Platforms: $($Platforms -join ', ')"
$inspectResult = Invoke-SkopeoInspectRaw -SkopeoImage $SkopeoImage -InspectRef $resolved.InspectRef
if ($inspectResult.ExitCode -ne 0) {
throw "skopeo inspect failed (exit $($inspectResult.ExitCode)): $($inspectResult.RawLines)"
}
$rawLines = $inspectResult.RawLines
$rawJson = if ($rawLines -is [array]) { $rawLines -join "`n" } else { [string]$rawLines }
$rawJson = $rawJson.Trim()
if ($rawJson -notmatch '^\s*\{') {
$idx = $rawJson.LastIndexOf('{')
if ($idx -ge 0) { $rawJson = $rawJson.Substring($idx) }
}
$index = $rawJson | ConvertFrom-Json
# Single manifest (not a list): copy tag-to-tag
if (-not $index.manifests) {
Write-Host "Source is a single manifest (not a multi-arch index). Copying tag as-is..."
$srcTagRef = $resolved.InspectRef
$authFileForPush = Resolve-DestinationAuthFile -RegistryHost $destHost
if (-not $authFileForPush) { throw "Could not resolve push credentials for $destHost." }
try {
Invoke-SkopeoCopyWithAuth -SkopeoImage $SkopeoImage -SourceRef $srcTagRef -DestRef "docker://${destImage}" -AuthFilePath $authFileForPush -ParallelCopies $script:EffectiveParallelCopies
if ($script:SkopeoLastExitCode -ne 0) { throw "skopeo copy failed (exit $($script:SkopeoLastExitCode))" }
Write-Host "Done: $destImage"
} finally {
if ($authFileForPush -and (Test-Path -LiteralPath $authFileForPush)) {
Remove-Item -LiteralPath $authFileForPush -Force -ErrorAction SilentlyContinue
}
}
exit 0
}
$digestByPlatform = @{}
foreach ($plat in $Platforms) {
$parts = $plat -split '/', 2
if ($parts.Length -ne 2) { throw "Invalid platform (use os/arch): $plat" }
$osW = $parts[0]
$archW = $parts[1]
$found = $null
foreach ($m in $index.manifests) {
if (Test-PlatformMatch -ManifestEntry $m -OsWant $osW -ArchWant $archW) {
$found = $m.digest
break
}
}
if (-not $found) {
throw "Platform $plat not found in source manifest list. Inspect with: docker buildx imagetools inspect $($resolved.InspectRef -replace '^docker://','')"
}
$digestByPlatform[$plat] = $found
}
foreach ($p in $Platforms) {
Write-Host "$p : $($digestByPlatform[$p])"
}
$srcBase = $resolved.SourceRepoPath
$dstBase = $DestRepo
$authFileForPush = Resolve-DestinationAuthFile -RegistryHost $destHost
if (-not $authFileForPush) {
throw @"
Could not resolve push credentials for $destHost.
Use: -DestUsername / -DestPassword, or env DEST_REGISTRY_USER + DEST_REGISTRY_PASS, or ALIYUN_CR_USER + ALIYUN_CR_PASS, or docker login.
"@
}
try {
Write-Host ""
Write-Host "Tip: After the 'Copying blob ...' lines, the console may stay quiet for many minutes while large layers upload to the registry (not frozen). Watch Task Manager -> Network (send) if unsure."
$step = 1
$total = $Platforms.Count
foreach ($plat in $Platforms) {
$d = $digestByPlatform[$plat]
Write-Host ""
Write-Host "[$step/$total] skopeo copy $plat ..."
$step++
Invoke-SkopeoCopyWithAuth -SkopeoImage $SkopeoImage -SourceRef "docker://${srcBase}@${d}" -DestRef "docker://${dstBase}@${d}" -AuthFilePath $authFileForPush -ParallelCopies $script:EffectiveParallelCopies
if ($script:SkopeoLastExitCode -ne 0) { throw "skopeo copy failed for $plat (exit $($script:SkopeoLastExitCode))" }
}
$refsForBuildx = @()
foreach ($plat in $Platforms) {
$refsForBuildx += "${dstBase}@$($digestByPlatform[$plat])"
}
Write-Host ""
Write-Host "[$($total + 1)/$($total + 1)] docker buildx imagetools create..."
& docker buildx imagetools create -t $destImage @refsForBuildx
if ($LASTEXITCODE -ne 0) { throw "docker buildx imagetools create failed" }
Write-Host ""
Write-Host "Done: $destImage"
Write-Host "Verify: docker buildx imagetools inspect $destImage"
} finally {
if ($authFileForPush -and (Test-Path -LiteralPath $authFileForPush)) {
Remove-Item -LiteralPath $authFileForPush -Force -ErrorAction SilentlyContinue
}
}
+12 -15
View File
@@ -15,15 +15,16 @@ daemonize no
#pidfile /data/dockerData/redis/run/redis6379.pid
#默认为no,redis持久化,可以改为yes
appendonly yes
dir /data
#当客户端闲置多长时间后关闭连接,如果指定为0,表示关闭该功能 # 增加到300秒,或设为0禁用
timeout 300
# 服务器系统默认配置参数影响 Redis 的应用
maxclients 10000
# 减少到60秒,更快检测连接问题
tcp-keepalive 60
# 禁用 RDB 快照,避免小内存机器上 fork bgsave 触发 OOM;生产保留 AOF。
save ""
#指定在多长时间内,有多少次更新操作,就将数据同步到数据文件,可以多个条件配合(分别表示900秒(15分钟)内有1个更改,300秒(5分钟)内有10个更改以及60秒内有10000个更改)
save 900 1
save 300 10
save 60 10000
# 按需求调整 Redis 线程数
tcp-backlog 511
# 设置Redis副本实例是否为只读模式
@@ -31,25 +32,18 @@ tcp-backlog 511
replica-read-only no
# 设置Redis最大内存达到时的数据淘汰策略
# 为 2GB 容器预留约 512MB,供连接缓冲区、模块、AOF 重写和 fork/COW 使用。
# noeviction:达到数据内存上限后拒绝写入,避免淘汰队列内部键导致状态不一致。
# noeviction:当内存达到最大限制时,Redis不会自动删除任何键,这可能导致进一步的操作失败,直到手动干预 # 当内存不足时,删除最近最少使用的键
# maxmemory-policy allkeys-lru
maxmemory 1536mb
maxmemory-policy noeviction
# 设置数据库数量,这里设置为16个数据库
databases 16
# AOF 常规配置
appendfsync everysec
# AOF/RDB 重写期间继续 fsync,优先保证异常退出后的数据完整性。
# 启用 AOF, AOF常规配置
appendonly yes
# 改为由操作系统决定何时同步,减少I/O压力
appendfsync no
no-appendfsync-on-rewrite no
auto-aof-rewrite-percentage 100
auto-aof-rewrite-min-size 64mb
# 自动修复意外 EOF;Redis 8.4+ 可额外截断小范围格式损坏尾部。
aof-load-truncated yes
aof-load-corrupt-tail-max-size 4096
# 使用 RDB 作为 AOF 基础文件并增量刷盘,降低重写和恢复开销。
aof-use-rdb-preamble yes
aof-rewrite-incremental-fsync yes
# 慢查询阈值
slowlog-log-slower-than 10000
slowlog-max-len 128
@@ -75,3 +69,6 @@ list-max-ziplist-value 64
set-max-intset-entries 512
zset-max-ziplist-entries 128
zset-max-ziplist-value 64
-67
View File
@@ -1,67 +0,0 @@
#Requires -RunAsAdministrator
# WSL 与 Docker 网络配置重置脚本
# 需以管理员身份运行
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
$ErrorActionPreference = "Continue"
Write-Host "========================================" -ForegroundColor Cyan
Write-Host " WSL & Docker 网络配置重置" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
Write-Host ""
# 1. 关闭 WSL
Write-Host "[1/5] 关闭 WSL..." -ForegroundColor Yellow
wsl --shutdown
Start-Sleep -Seconds 3
Write-Host " 完成" -ForegroundColor Green
# 2. 重置 Windows 网络栈(可选,可能需重启)
Write-Host "[2/5] 重置 Windows 网络栈..." -ForegroundColor Yellow
try {
netsh winsock reset 2>$null
netsh int ip reset 2>$null
Write-Host " 完成 (如有提示重启,建议执行)" -ForegroundColor Green
} catch {
Write-Host " 跳过: $($_.Exception.Message)" -ForegroundColor Gray
}
# 3. 清理 Docker 网络
Write-Host "[3/5] 清理 Docker 未使用网络..." -ForegroundColor Yellow
$dockerOk = $false
try {
$null = docker info 2>&1
$dockerOk = $true
} catch { }
if ($dockerOk) {
docker network prune -f 2>$null
Write-Host " 完成" -ForegroundColor Green
} else {
Write-Host " 跳过 (Docker 未运行,请先启动 Docker Desktop)" -ForegroundColor Gray
}
# 4. 清除 Docker 端口代理残留(若有)
Write-Host "[4/5] 检查端口代理..." -ForegroundColor Yellow
$proxies = netsh interface portproxy show all 2>$null
if ($proxies -and $proxies -match "3010|3001|3002|3003") {
Write-Host " 发现相关端口代理,请手动在管理员 CMD 执行:" -ForegroundColor Yellow
Write-Host " netsh interface portproxy reset" -ForegroundColor White
} else {
Write-Host " 无异常端口代理" -ForegroundColor Green
}
# 5. 提示
Write-Host "[5/5] 下一步操作:" -ForegroundColor Yellow
Write-Host ""
Write-Host " 1. 启动 Docker Desktop(若已关闭)" -ForegroundColor White
Write-Host " 2. 等待 Docker 完全就绪后,执行:" -ForegroundColor White
Write-Host " cd D:\01一键部署包\easyai" -ForegroundColor Cyan
Write-Host " docker compose up -d" -ForegroundColor Cyan
Write-Host ""
Write-Host " 3. 若端口仍不可访问,在 Docker Desktop 中:" -ForegroundColor White
Write-Host " [设置] -> [Troubleshoot] -> [Reset to factory defaults]" -ForegroundColor Cyan
Write-Host " (会清除所有容器/镜像,谨慎使用)" -ForegroundColor Gray
Write-Host ""
Write-Host " 4. 若执行了 netsh winsock/int ip reset,建议重启电脑" -ForegroundColor White
Write-Host ""
Read-Host "按 Enter 键退出"
-111
View File
@@ -1,111 +0,0 @@
param(
[Parameter(Mandatory = $true)][string]$EnvPath,
[Parameter(Mandatory = $true)][string]$ProjectRoot
)
$ErrorActionPreference = "Stop"
$migrationsDir = if ([string]::IsNullOrWhiteSpace($env:UPDATE_ENV_MIGRATIONS_DIR)) {
Join-Path $ProjectRoot "scripts\update-env.d"
} else {
$env:UPDATE_ENV_MIGRATIONS_DIR
}
if (-not (Test-Path $EnvPath -PathType Leaf)) {
throw "环境配置文件不存在: $EnvPath"
}
if (-not (Test-Path $migrationsDir -PathType Container)) {
Write-Host "ℹ️ 未发现环境配置迁移目录,跳过"
return
}
function Get-EnvValue {
param([string]$Path, [string]$Key)
foreach ($line in [System.IO.File]::ReadAllLines($Path)) {
if ($line.StartsWith("$Key=")) {
return $line.Substring($Key.Length + 1).Trim().Trim('"').Trim("'")
}
}
return ""
}
function Set-EnvValue {
param([string]$Path, [string]$Key, [string]$Value)
$output = New-Object System.Collections.Generic.List[string]
$replaced = $false
foreach ($line in [System.IO.File]::ReadAllLines($Path)) {
if ($line.StartsWith("$Key=")) {
if (-not $replaced) {
$output.Add("$Key=$Value")
$replaced = $true
}
} else {
$output.Add($line)
}
}
if (-not $replaced) { $output.Add("$Key=$Value") }
[System.IO.File]::WriteAllLines($Path, $output, (New-Object System.Text.UTF8Encoding($false)))
}
function Set-EnvDefault {
param([string]$Key, [string]$Value)
if (-not [string]::IsNullOrWhiteSpace((Get-EnvValue -Path $EnvPath -Key $Key))) { return }
Set-EnvValue -Path $EnvPath -Key $Key -Value $Value
Write-Host " ✓ 已补充环境配置: $Key"
}
function Add-EnvListItem {
param([string]$Key, [string]$Item)
$current = Get-EnvValue -Path $EnvPath -Key $Key
$items = @($current.Split(',') | ForEach-Object { $_.Trim() } | Where-Object { $_ })
if ($items -contains $Item) { return }
$next = if ($items.Count -eq 0) { $Item } else { (@($items) + $Item) -join ',' }
Set-EnvValue -Path $EnvPath -Key $Key -Value $next
Write-Host " ✓ 已补充环境配置项: $Key"
}
$manifests = @(Get-ChildItem -Path $migrationsDir -Filter "*.env" -File | Sort-Object Name)
if ($manifests.Count -eq 0) {
Write-Host "ℹ️ 未发现环境配置迁移文件,跳过"
return
}
foreach ($manifest in $manifests) {
$lineNumber = 0
foreach ($rawLine in [System.IO.File]::ReadAllLines($manifest.FullName)) {
$lineNumber++
$line = $rawLine.TrimEnd("`r")
if ([string]::IsNullOrWhiteSpace($line) -or $line.StartsWith('#')) { continue }
if ($line -match '^@skip-if\s+[A-Z][A-Z0-9_]*=.+$') { continue }
if ($line -notmatch '^[A-Z][A-Z0-9_]*(\+)?=.+$') {
throw "无效的环境配置迁移: $($manifest.FullName):$lineNumber"
}
}
}
foreach ($manifest in $manifests) {
$skipManifest = $false
foreach ($rawLine in [System.IO.File]::ReadAllLines($manifest.FullName)) {
$line = $rawLine.TrimEnd("`r")
if ($line -match '^@skip-if\s+([A-Z][A-Z0-9_]*)=(.+)$') {
if ((Get-EnvValue -Path $EnvPath -Key $Matches[1]) -eq $Matches[2]) {
Write-Host "ℹ️ 保留现有更新模式,跳过环境配置迁移: $($manifest.Name)"
$skipManifest = $true
break
}
}
}
if ($skipManifest) { continue }
Write-Host "📝 应用环境配置迁移: $($manifest.Name)"
foreach ($rawLine in [System.IO.File]::ReadAllLines($manifest.FullName)) {
$line = $rawLine.TrimEnd("`r")
if ([string]::IsNullOrWhiteSpace($line) -or $line.StartsWith('#')) { continue }
if ($line.StartsWith('@skip-if')) { continue }
if ($line.Contains('+=')) {
$parts = $line.Split(@('+='), 2, [System.StringSplitOptions]::None)
Add-EnvListItem -Key $parts[0] -Item $parts[1]
} else {
$parts = $line.Split(@('='), 2, [System.StringSplitOptions]::None)
Set-EnvDefault -Key $parts[0] -Value $parts[1]
}
}
}
-29
View File
@@ -1,29 +0,0 @@
# 在 Compose 重建 Redis 前,确认现有 /data 已位于当前项目的数据目录。
function Assert-RedisPersistence {
param([Parameter(Mandatory = $true)][string]$ProjectRoot)
$null = & docker info 2>&1
if ($LASTEXITCODE -ne 0) { throw "Cannot connect to Docker; Redis data location was not checked" }
$null = & docker container inspect redis 2>&1
if ($LASTEXITCODE -ne 0) { return }
$mount = (& docker inspect --format '{{range .Mounts}}{{if eq .Destination "/data"}}{{.Type}}|{{.Source}}{{end}}{{end}}' redis).Trim()
if ($LASTEXITCODE -ne 0 -or -not $mount) {
throw "Existing Redis /data is not persisted; migrate AOF/RDB before updating (see README)"
}
$parts = $mount.Split('|', 2)
if ($parts.Count -ne 2 -or $parts[0] -ne 'bind') {
throw "Existing Redis /data uses a different storage type; migrate it before updating (see README)"
}
$source = $parts[1]
# Docker Desktop 可将 Windows C:\path 显示为 /run/desktop/mnt/host/c/path。
$source = $source -replace '^/run/desktop/mnt/host/([A-Za-z])/', '$1:/'
$source = $source -replace '^/host_mnt/([A-Za-z])/', '$1:/'
$expected = [System.IO.Path]::GetFullPath((Join-Path $ProjectRoot 'data/redis'))
$actual = [System.IO.Path]::GetFullPath($source)
if (-not [string]::Equals($actual.TrimEnd('\', '/'), $expected.TrimEnd('\', '/'), [StringComparison]::OrdinalIgnoreCase)) {
throw "Existing Redis /data uses another directory; migrate it before updating (see README)"
}
}
@@ -1,20 +0,0 @@
param([Parameter(Mandatory=$true)][string]$ProjectRoot)
$ErrorActionPreference = 'Stop'
. (Join-Path $ProjectRoot 'scripts/Initialize-SecurityEnv.ps1')
$path = Join-Path $ProjectRoot '.env'
$content = [System.IO.File]::ReadAllText($path)
if ((Get-EnvValue $content 'INFRASTRUCTURE_AUTO_UPDATE_ENABLED') -eq 'false') { return }
$image = Get-EnvValue $content 'WATCHTOWER_IMAGE'
if ([string]::IsNullOrWhiteSpace($image) -or $image -match '^registry\.cn-shanghai\.aliyuncs\.com/comfy-ai/watchtower-aliyun:\d+\.\d+\.\d+$') {
$content = Set-EnvValue $content 'WATCHTOWER_IMAGE' 'registry.cn-shanghai.aliyuncs.com/comfy-ai/watchtower-aliyun:latest'
[System.IO.File]::WriteAllText($path, $content, [System.Text.UTF8Encoding]::new($false))
}
# Preserve old scope during a targeted update; unchanged containers keep their labels.
$image = Get-EnvValue $content 'WATCHTOWER_IMAGE'
$target = Get-EnvValue $content 'WATCHTOWER_UPDATE_TARGET'
$scope = Get-EnvValue $content 'WATCHTOWER_SCOPE'
if ([string]::IsNullOrWhiteSpace($env:UPDATE_SERVICES) -and [string]::IsNullOrWhiteSpace($scope) -and $image -eq 'registry.cn-shanghai.aliyuncs.com/comfy-ai/watchtower-aliyun:latest' -and $target -in @('', 'easyai-updater', 'easyai-server easyai-wsgateway easyai-web')) {
$content = Set-EnvValue $content 'WATCHTOWER_SCOPE' 'easyai'
[System.IO.File]::WriteAllText($path, $content, [System.Text.UTF8Encoding]::new($false))
}
-102
View File
@@ -1,102 +0,0 @@
function Get-PublicEnvValue {
param([string]$Content, [string]$Key)
$match = [regex]::Match($Content, "(?m)^$([regex]::Escape($Key))=(.*)$")
if (-not $match.Success) { return "" }
$value = $match.Groups[1].Value.Trim()
if ($value.Length -ge 2) {
$first = $value.Substring(0, 1)
$last = $value.Substring($value.Length - 1, 1)
if (($first -eq "'" -and $last -eq "'") -or ($first -eq '"' -and $last -eq '"')) {
return $value.Substring(1, $value.Length - 2)
}
}
return $value
}
function Set-PublicEnvValue {
param([string]$Content, [string]$Key, [string]$Value)
$pattern = "(?m)^$([regex]::Escape($Key))=.*(?:\r?\n)?"
$replacement = "$Key=$Value`r`n"
if ([regex]::IsMatch($Content, $pattern)) {
return [regex]::Replace($Content, $pattern, $replacement, 1)
}
if ($Content -and -not $Content.EndsWith("`n")) { $Content += "`r`n" }
return $Content + $replacement
}
function ConvertTo-PublicApiBaseUrl {
param([string]$Value)
$normalized = if ($null -eq $Value) { "" } else { $Value.Trim().TrimEnd('/') }
if (-not $normalized) { return $null }
$uri = $null
if (-not [Uri]::TryCreate($normalized, [UriKind]::Absolute, [ref]$uri)) { return $null }
if ($uri.Scheme -notin @("http", "https")) { return $null }
if ($uri.UserInfo -or $uri.Query -or $uri.Fragment) { return $null }
if (-not $uri.Host -or $uri.Port -lt 1 -or $uri.Port -gt 65535) { return $null }
return $normalized
}
function Get-DerivedPublicApiBaseUrl {
param([string]$Content)
$apiUrl = Get-PublicEnvValue $Content "NUXT_PUBLIC_BASE_APIURL"
$absoluteApiUrl = ConvertTo-PublicApiBaseUrl $apiUrl
if ($absoluteApiUrl) { return $absoluteApiUrl }
if (-not $apiUrl.StartsWith('/')) { return $null }
$origin = $null
$socketUrl = Get-PublicEnvValue $Content "NUXT_PUBLIC_BASE_SOCKETURL"
$socketUri = $null
if ([Uri]::TryCreate($socketUrl, [UriKind]::Absolute, [ref]$socketUri) -and
$socketUri.Scheme -in @("ws", "wss")) {
$scheme = if ($socketUri.Scheme -eq "wss") { "https" } else { "http" }
$origin = "${scheme}://$($socketUri.Authority)"
}
if (-not $origin) {
$securityOrigin = (Get-PublicEnvValue $Content "CONFIG_SECURITY_ORIGIN").Split(',')[0].Trim()
$securityUri = $null
if ([Uri]::TryCreate($securityOrigin, [UriKind]::Absolute, [ref]$securityUri) -and
$securityUri.Scheme -in @("http", "https")) {
$origin = "$($securityUri.Scheme)://$($securityUri.Authority)"
}
}
if (-not $origin) { return $null }
return ConvertTo-PublicApiBaseUrl "$($origin.TrimEnd('/'))$apiUrl"
}
function Initialize-PublicApiBaseUrl {
param(
[Parameter(Mandatory = $true)][string]$Path,
[ValidateSet("configure", "upgrade")][string]$Mode = "upgrade",
[string]$Override = $env:DEPLOY_PUBLIC_API_BASE_URL
)
if (-not (Test-Path $Path)) { throw "Environment file not found: $Path" }
$content = Get-Content $Path -Raw -Encoding UTF8
if ($null -eq $content) { $content = "" }
if (-not $Override) { $Override = Get-PublicEnvValue $content "DEPLOY_PUBLIC_API_BASE_URL" }
$current = Get-PublicEnvValue $content "CONFIG_PUBLIC_API_BASE_URL"
if ($Override) {
$selected = ConvertTo-PublicApiBaseUrl $Override
if (-not $selected) { throw "Invalid public API base URL: $Override" }
} elseif ($current) {
if (-not (ConvertTo-PublicApiBaseUrl $current)) {
throw "Invalid CONFIG_PUBLIC_API_BASE_URL: $current"
}
return
} else {
$selected = Get-DerivedPublicApiBaseUrl $content
if (-not $selected) {
throw "Cannot derive CONFIG_PUBLIC_API_BASE_URL. Set a full URL such as https://example.com/api"
}
}
$content = Set-PublicEnvValue $content "CONFIG_PUBLIC_API_BASE_URL" $selected
[System.IO.File]::WriteAllText(
(Resolve-Path $Path),
$content,
[System.Text.UTF8Encoding]::new($false)
)
Write-Host " [OK] Public API base URL: $selected" -ForegroundColor Green
}
-155
View File
@@ -1,155 +0,0 @@
function New-SecuritySecret {
$bytes = New-Object byte[] 48
$rng = [System.Security.Cryptography.RandomNumberGenerator]::Create()
try {
$rng.GetBytes($bytes)
} finally {
$rng.Dispose()
}
return [Convert]::ToBase64String($bytes)
}
function New-InitialAdminPassword {
$bytes = New-Object byte[] 10
$rng = [System.Security.Cryptography.RandomNumberGenerator]::Create()
try {
$rng.GetBytes($bytes)
} finally {
$rng.Dispose()
}
return ([BitConverter]::ToString($bytes) -replace "-", "").ToLowerInvariant()
}
function Get-EnvValue {
param([string]$Content, [string]$Key)
$match = [regex]::Match($Content, "(?m)^$([regex]::Escape($Key))=(.*)$")
if (-not $match.Success) { return "" }
$value = $match.Groups[1].Value.Trim()
if ($value.Length -ge 2) {
$first = $value.Substring(0, 1)
$last = $value.Substring($value.Length - 1, 1)
if (($first -eq "'" -and $last -eq "'") -or ($first -eq '"' -and $last -eq '"')) {
return $value.Substring(1, $value.Length - 2)
}
}
return $value
}
function Set-EnvValue {
param([string]$Content, [string]$Key, [string]$Value)
$pattern = "(?m)^$([regex]::Escape($Key))=.*(?:\r?\n)?"
$replacement = "$Key=$Value`r`n"
if ([regex]::IsMatch($Content, $pattern)) {
return [regex]::Replace($Content, $pattern, $replacement, 1)
}
if ($Content -and -not $Content.EndsWith("`n")) { $Content += "`r`n" }
return $Content + $replacement
}
function Initialize-SecurityEnv {
param(
[Parameter(Mandatory = $true)][string]$Path,
[ValidateSet("new", "upgrade")][string]$Mode = "upgrade"
)
if (-not (Test-Path $Path)) { throw "Environment file not found: $Path" }
$content = Get-Content $Path -Raw -Encoding UTF8
if ($null -eq $content) { $content = "" }
$active = Get-EnvValue $content 'CONFIG_CREDENTIAL_ENCRYPTION_ACTIVE_KEY_ID'
$ringText = Get-EnvValue $content 'CONFIG_CREDENTIAL_ENCRYPTION_KEYRING'
$legacy = Get-EnvValue $content 'CONFIG_CREDENTIAL_ALLOW_LEGACY_PLAINTEXT'
if ($legacy -and $legacy -notin @('true','false')) { throw 'Credential legacy flag must be true/false' }
if (-not $active -and -not $ringText) {
if ($Mode -ne 'new' -and $legacy -eq 'false') { throw 'Credential encryption key missing; restore the original configuration' }
if ($Mode -eq 'new' -or $env:DEPLOY_ENABLE_CREDENTIAL_ENCRYPTION -eq 'true') {
$bytes = New-Object byte[] 32
$rng = [System.Security.Cryptography.RandomNumberGenerator]::Create()
try { $rng.GetBytes($bytes) } finally { $rng.Dispose() }
$active = 'v1'
$ringText = '{"v1":"' + [Convert]::ToBase64String($bytes) + '"}'
$content = Set-EnvValue $content 'CONFIG_CREDENTIAL_ENCRYPTION_ACTIVE_KEY_ID' $active
$content = Set-EnvValue $content 'CONFIG_CREDENTIAL_ENCRYPTION_KEYRING' ("'" + $ringText + "'")
Write-Host ' [OK] Initialized independent credential encryption key (value omitted)'
} elseif ($legacy -eq 'false') {
throw 'Credential encryption key missing; restore the original configuration'
} else {
Write-Host ' [INFO] Existing deployment retains legacy compatibility; opt in using DEPLOY_ENABLE_CREDENTIAL_ENCRYPTION=true'
}
}
if ($active -or $ringText) {
try {
if ($active -notmatch '^[A-Za-z0-9_-]{1,64}$') { throw 'invalid' }
$normalized = $ringText -replace '\s', ''
if ($ringText -notmatch '^\{\s*("[A-Za-z0-9_-]{1,64}"\s*:\s*"[A-Za-z0-9+/]{43}="\s*,\s*)*"[A-Za-z0-9_-]{1,64}"\s*:\s*"[A-Za-z0-9+/]{43}="\s*\}$') { throw 'invalid' }
$ids = New-Object 'System.Collections.Generic.Dictionary[string,bool]' ([System.StringComparer]::Ordinal)
foreach ($match in [regex]::Matches($normalized, '"([A-Za-z0-9_-]+)":"([A-Za-z0-9+/]+=)"')) {
$id = $match.Groups[1].Value; $encoded = $match.Groups[2].Value
if ($ids.ContainsKey($id)) { throw 'duplicate' }
$key = [Convert]::FromBase64String($encoded)
if ($key.Length -ne 32 -or [Convert]::ToBase64String($key) -cne $encoded) { throw 'invalid' }
$ids[$id] = $true
}
if (-not $ids.ContainsKey($active)) { throw 'invalid' }
} catch { throw 'Credential keyring invalid or incomplete; restore original keys (values omitted)' }
if (-not $legacy) {
if ($Mode -eq 'new') { $legacy = 'false' } else { $legacy = 'true' }
$content = Set-EnvValue $content 'CONFIG_CREDENTIAL_ALLOW_LEGACY_PLAINTEXT' $legacy
}
if ($legacy -notin @('true', 'false')) { throw 'Credential legacy flag must be true/false' }
}
$definitions = @(
@{ Key = "CONFIG_JWT_SECRET"; Legacy = "this is a very secret secret"; MinLength = 32 },
@{ Key = "WS_AUTH_WS_TICKET_SECRET"; Legacy = ""; MinLength = 32 },
@{ Key = "SYSTEM_UPDATE_INTERNAL_TOKEN"; Legacy = ""; MinLength = 32 },
@{ Key = "WATCHTOWER_API_KEY"; Legacy = ""; MinLength = 32 },
@{ Key = "WATCHTOWER_HTTP_API_EVENTS_TOKEN"; Legacy = ""; MinLength = 32 },
@{ Key = "MODEL_CONVERSION_WORKER_TOKEN"; Legacy = ""; MinLength = 32 }
)
if ($Mode -eq "new") {
$definitions += @(
@{ Key = "CONFIG_SECURITY_CONFIG_ENCRYPTION_KEY"; Legacy = ""; MinLength = 32 },
@{ Key = "CONFIG_OTP_HASH_SECRET"; Legacy = ""; MinLength = 32 },
@{ Key = "CONFIG_AUDIT_HASH_PEPPER"; Legacy = ""; MinLength = 32 },
@{ Key = "CONFIG_AUDIT_INTEGRITY_KEY"; Legacy = ""; MinLength = 32 }
)
}
foreach ($definition in $definitions) {
$current = Get-EnvValue $content $definition.Key
$needsInitialization = $current.Length -lt $definition.MinLength -or
($definition.Legacy -and $current -eq $definition.Legacy)
if (-not $needsInitialization) { continue }
$content = Set-EnvValue $content $definition.Key (New-SecuritySecret)
Write-Host " [OK] Initialized security secret: $($definition.Key)" -ForegroundColor Green
}
$authMethods = Get-EnvValue $content "WS_AUTH_METHODS"
if ([string]::IsNullOrWhiteSpace($authMethods)) { $authMethods = "none,bearer" }
$hasWsTicket = @($authMethods.Split(",") | ForEach-Object { $_.Trim() }) -contains "ws_ticket"
if (-not $hasWsTicket) {
$content = Set-EnvValue $content "WS_AUTH_METHODS" ($authMethods + ",ws_ticket")
Write-Host " [OK] Added security config item: WS_AUTH_METHODS" -ForegroundColor Green
}
if ($Mode -eq "new") {
$adminPassword = Get-EnvValue $content "CONFIG_INITIAL_ADMIN_PASSWORD"
if ($adminPassword.Length -lt 12) {
$content = Set-EnvValue $content "CONFIG_INITIAL_ADMIN_PASSWORD" (New-InitialAdminPassword)
Write-Host " [OK] Initialized admin password: CONFIG_INITIAL_ADMIN_PASSWORD" -ForegroundColor Green
}
}
[System.IO.File]::WriteAllText(
(Resolve-Path $Path),
$content,
[System.Text.UTF8Encoding]::new($false)
)
if ($env:OS -eq 'Windows_NT') {
$identity = [System.Security.Principal.WindowsIdentity]::GetCurrent().User
$acl = New-Object System.Security.AccessControl.FileSecurity
$acl.SetAccessRuleProtection($true, $false)
$rule = New-Object System.Security.AccessControl.FileSystemAccessRule($identity, 'FullControl', 'Allow')
$acl.AddAccessRule($rule)
Set-Acl -LiteralPath $Path -AclObject $acl
}
}
-49
View File
@@ -1,49 +0,0 @@
# 浏览器来源为可选项:缺失或留空时保持宽松策略,显式配置时校验。
. (Join-Path $PSScriptRoot "Initialize-PublicApiBaseUrl.ps1")
function Initialize-SecurityOrigin {
param([Parameter(Mandatory = $true)][string]$Path)
if (-not (Test-Path $Path)) { throw "Environment file not found: $Path" }
$content = Get-Content $Path -Raw -Encoding UTF8
if ($null -eq $content) { $content = "" }
$current = Get-PublicEnvValue $content "CONFIG_SECURITY_ORIGIN"
if (-not $current) {
Write-Host " [OK] Browser origin is not configured; backend allows all origins" -ForegroundColor Green
return
}
# The legacy sample value was not a user-selected allowlist. Clear it on non-local upgrades.
if ($current -eq "http://127.0.0.1,http://localhost") {
$apiUrl = Get-PublicEnvValue $content "CONFIG_PUBLIC_API_BASE_URL"
$normalizedApiUrl = ConvertTo-PublicApiBaseUrl $apiUrl
if ($normalizedApiUrl) {
$derived = ([Uri]$normalizedApiUrl).GetLeftPart([UriPartial]::Authority)
if ($derived -notin @('http://127.0.0.1', 'http://localhost')) {
$content = Set-PublicEnvValue $content "CONFIG_SECURITY_ORIGIN" ""
[System.IO.File]::WriteAllText(
(Resolve-Path $Path),
$content,
[System.Text.UTF8Encoding]::new($false)
)
Write-Host " [OK] Cleared legacy browser-origin sample; backend allows all origins" -ForegroundColor Green
return
}
}
}
if ($current.StartsWith(',') -or $current.EndsWith(',') -or $current.Contains(',,')) {
throw "CONFIG_SECURITY_ORIGIN contains an empty origin"
}
foreach ($value in $current.Split(',')) {
$origin = $value.Trim()
$uri = $null
if (-not [Uri]::TryCreate($origin, [UriKind]::Absolute, [ref]$uri) -or
$uri.Scheme -notin @('http', 'https') -or
$uri.UserInfo -or $uri.Query -or $uri.Fragment -or
$uri.AbsolutePath -ne '/' -or -not $uri.Host) {
throw "CONFIG_SECURITY_ORIGIN must contain only http(s) origins without paths or wildcards"
}
}
}
-47
View File
@@ -1,47 +0,0 @@
function Get-ServerHttpBindEnvValue {
param([string]$Content, [string]$Key)
$match = [regex]::Match($Content, "(?m)^$([regex]::Escape($Key))=([^\r\n]*)$")
if ($match.Success) { return $match.Groups[1].Value.Trim() }
return ""
}
function Set-ServerHttpBindEnvValue {
param([string]$Content, [string]$Key, [string]$Value)
$line = "$Key=$Value"
$pattern = "(?m)^$([regex]::Escape($Key))=.*$"
if ($Content -match $pattern) { return ($Content -replace $pattern, $line) }
if ($Content -and -not $Content.EndsWith("`n")) { $Content += "`n" }
return ($Content + $line + "`n")
}
function Initialize-ServerHttpBindIp {
param(
[Parameter(Mandatory = $true)][string]$Path,
[string]$Override = ""
)
if (-not (Test-Path $Path)) { throw "Environment file not found: $Path" }
$content = Get-Content $Path -Raw -Encoding UTF8
if (-not $content) { $content = "" }
$current = Get-ServerHttpBindEnvValue $content "SERVER_HTTP_BIND_IP"
$target = if (-not [string]::IsNullOrWhiteSpace($Override)) { $Override.Trim() } else { $current }
if ([string]::IsNullOrWhiteSpace($target)) {
$publicApiUrl = Get-ServerHttpBindEnvValue $content "NUXT_PUBLIC_BASE_APIURL"
$target = if (
$publicApiUrl -eq "/api" -or
$publicApiUrl.StartsWith("/api/") -or
$publicApiUrl -match '^https?://(127\.0\.0\.1|localhost)(:\d+)?(?:/|$)'
) { "127.0.0.1" } elseif ($publicApiUrl -match '^https?://') { "0.0.0.0" } else { "127.0.0.1" }
}
if ($target -notin @("127.0.0.1", "0.0.0.0")) {
throw "SERVER_HTTP_BIND_IP only supports 127.0.0.1 or 0.0.0.0, current value: $target"
}
if ($current -ne $target) {
$content = Set-ServerHttpBindEnvValue $content "SERVER_HTTP_BIND_IP" $target
[System.IO.File]::WriteAllText($Path, $content, [System.Text.UTF8Encoding]::new($false))
Write-Host " ✓ SERVER_HTTP_BIND_IP=$target"
}
}
@@ -1,30 +0,0 @@
param(
[Parameter(Mandatory=$true)][string]$ProjectRoot,
[switch]$DryRun
)
$ErrorActionPreference = 'Stop'
$ProjectRoot = (Resolve-Path $ProjectRoot).Path
. (Join-Path $ProjectRoot 'scripts/Initialize-SecurityEnv.ps1')
$enabled = (Get-EnvValue ([System.IO.File]::ReadAllText((Join-Path $ProjectRoot '.env'))) 'INFRASTRUCTURE_AUTO_UPDATE_ENABLED') -ne 'false'
$hash = [System.Security.Cryptography.SHA256]::Create()
try { $id = ([System.BitConverter]::ToString($hash.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($ProjectRoot)))).Replace('-', '').Substring(0, 12) }
finally { $hash.Dispose() }
$name = "EasyAI-Watchtower-$id"
if ($DryRun -and -not $enabled) { Write-Host "更新组件定时任务(dry-run):已关闭"; return }
if ($DryRun) { Write-Host "Watchtower 和 updater 定时任务(dry-run):$name,每六小时检查一次"; return }
if (-not $enabled) {
$existing = Get-ScheduledTask -TaskName $name -ErrorAction SilentlyContinue
if ($existing) { Unregister-ScheduledTask -TaskName $name -Confirm:$false }
return
}
$executable = (Get-Process -Id $PID).Path
$script = Join-Path $ProjectRoot 'scripts/Update-Infrastructure.ps1'
$action = New-ScheduledTaskAction -Execute $executable -Argument "-NoProfile -NonInteractive -ExecutionPolicy Bypass -File `"$script`" -ProjectRoot `"$ProjectRoot`""
$triggers = @('00:17', '06:17', '12:17', '18:17') | ForEach-Object { New-ScheduledTaskTrigger -Daily -At $_ }
# Docker Desktop belongs to the logged-in user, not the SYSTEM account.
$user = [System.Environment]::UserName
if ($env:USERDOMAIN) { $user = "$env:USERDOMAIN\$user" }
$principal = New-ScheduledTaskPrincipal -UserId $user -LogonType Interactive -RunLevel Highest
$settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -MultipleInstances IgnoreNew -ExecutionTimeLimit (New-TimeSpan -Hours 2)
Register-ScheduledTask -TaskName $name -Action $action -Trigger $triggers -Principal $principal -Settings $settings -Force | Out-Null
Write-Host '已配置 Watchtower 和 updater 定时任务:每六小时检查一次(Docker Desktop 可用时执行)'
-68
View File
@@ -1,68 +0,0 @@
#Requires -Version 5.1
param(
[Parameter(Mandatory=$true)][ValidateSet('export','restore')][string]$Mode,
[Parameter(Mandatory=$true)][string]$Path,
[string]$EnvPath = (Join-Path (Split-Path -Parent $PSScriptRoot) '.env')
)
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'Initialize-SecurityEnv.ps1')
function Protect-CredentialFile([string]$FilePath) {
if ($env:OS -eq 'Windows_NT') {
$sid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User
$acl = New-Object System.Security.AccessControl.FileSecurity
$acl.SetAccessRuleProtection($true, $false)
$acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule($sid, 'FullControl', 'Allow')))
Set-Acl -LiteralPath $FilePath -AclObject $acl
} else { & chmod 600 $FilePath; if ($LASTEXITCODE) { throw 'Cannot protect credential file' } }
}
if (-not (Test-Path -LiteralPath $EnvPath -PathType Leaf)) { throw 'Prepare the deployment .env first' }
$EnvPath = [System.IO.Path]::GetFullPath($EnvPath)
$Path = [System.IO.Path]::GetFullPath($Path)
if ($EnvPath -eq $Path) { throw 'Keep the key backup separate from deployment configuration' }
$source = $EnvPath
if ($Mode -eq 'restore') { $source = $Path }
$content = Get-Content -LiteralPath $source -Raw -Encoding UTF8
$active = Get-EnvValue $content 'CONFIG_CREDENTIAL_ENCRYPTION_ACTIVE_KEY_ID'
$ring = Get-EnvValue $content 'CONFIG_CREDENTIAL_ENCRYPTION_KEYRING'
$legacy = Get-EnvValue $content 'CONFIG_CREDENTIAL_ALLOW_LEGACY_PLAINTEXT'
if (-not $active -or -not $ring) { throw 'Incomplete original keyring; no replacement key will be generated' }
if (-not $legacy) { $legacy = 'false' }
$keyContent = "CONFIG_CREDENTIAL_ENCRYPTION_ACTIVE_KEY_ID=$active`nCONFIG_CREDENTIAL_ENCRYPTION_KEYRING='$ring'`nCONFIG_CREDENTIAL_ALLOW_LEGACY_PLAINTEXT=$legacy`n"
foreach ($name in @('CONFIG_SECURITY_CONFIG_ENCRYPTION_KEY','CONFIG_JWT_SECRET')) {
$match = [regex]::Match($content, "(?m)^$name=(.*)$")
if ($match.Success) { $keyContent += $match.Value.TrimEnd("`r") + "`n" }
elseif ($Mode -eq 'export') { $keyContent += "$name=`n" }
}
$tmp = $EnvPath + '.credential-keys.' + [Guid]::NewGuid().ToString('N')
try {
[System.IO.File]::WriteAllText($tmp, '', (New-Object System.Text.UTF8Encoding($false)))
Protect-CredentialFile $tmp
[System.IO.File]::WriteAllText($tmp, $keyContent, (New-Object System.Text.UTF8Encoding($false)))
Initialize-SecurityEnv -Path $tmp -Mode upgrade *> $null
if ($Mode -eq 'export') {
$reservation = [System.IO.File]::Open($Path, 'CreateNew', 'Write', 'None'); $reservation.Dispose()
Protect-CredentialFile $Path
[System.IO.File]::WriteAllText($Path, $keyContent, (New-Object System.Text.UTF8Encoding($false)))
Write-Host 'Exported full keyring, including old keys. Store privately and separately from database backups.'
} else {
$original = Get-Content -LiteralPath $EnvPath -Raw -Encoding UTF8
$previous = $EnvPath + '.pre-credential-restore.' + [Guid]::NewGuid().ToString('N')
[System.IO.File]::WriteAllText($previous, '', (New-Object System.Text.UTF8Encoding($false)))
Protect-CredentialFile $previous
[System.IO.File]::WriteAllText($previous, $original, (New-Object System.Text.UTF8Encoding($false)))
foreach ($name in @('CONFIG_CREDENTIAL_ENCRYPTION_ACTIVE_KEY_ID','CONFIG_CREDENTIAL_ENCRYPTION_KEYRING','CONFIG_CREDENTIAL_ALLOW_LEGACY_PLAINTEXT','CONFIG_SECURITY_CONFIG_ENCRYPTION_KEY','CONFIG_JWT_SECRET')) {
$saved = [regex]::Match($keyContent, "(?m)^$name=(.*)$")
if (-not $saved.Success) { continue }
$literalLine = $saved.Value.TrimEnd("`r") + "`r`n"
$pattern = "(?m)^$name=.*(?:\r?\n)?"
if ([regex]::IsMatch($original, $pattern)) {
$evaluator = [System.Text.RegularExpressions.MatchEvaluator]{ param($match) return $literalLine }.GetNewClosure()
$original = [regex]::Replace($original, $pattern, $evaluator)
} else { $original = $original.TrimEnd("`r","`n") + "`r`n" + $literalLine }
}
[System.IO.File]::WriteAllText($tmp, $original, (New-Object System.Text.UTF8Encoding($false)))
Move-Item -LiteralPath $tmp -Destination $EnvPath -Force
Protect-CredentialFile $EnvPath
Write-Host 'Restored original keyring; other configuration preserved. Recreate all related containers before database restore.'
}
} finally { if (Test-Path -LiteralPath $tmp) { Remove-Item -LiteralPath $tmp -Force } }
-28
View File
@@ -1,28 +0,0 @@
#Requires -Version 5.1
param(
[ValidateSet('inventory','dry-run','apply','verify','finalize')][string]$Mode = 'inventory',
[Parameter(Mandatory=$true)][string]$ExpectedTarget
)
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
Set-Location $root
. (Join-Path $root 'scripts/Initialize-SecurityEnv.ps1')
Initialize-SecurityEnv -Path (Join-Path $root '.env') -Mode upgrade
$content = Get-Content (Join-Path $root '.env') -Raw
if (-not (Get-EnvValue $content 'CONFIG_CREDENTIAL_ENCRYPTION_KEYRING')) { throw 'Configure independent encryption keys and prepare database/queue backup before migrating' }
$checkpointDir = Join-Path $root 'data/credential-migration'
[System.IO.Directory]::CreateDirectory($checkpointDir) | Out-Null
if ($env:OS -eq 'Windows_NT') {
$identity = [System.Security.Principal.WindowsIdentity]::GetCurrent().User
$acl = New-Object System.Security.AccessControl.DirectorySecurity
$acl.SetAccessRuleProtection($true, $false)
$rule = New-Object System.Security.AccessControl.FileSystemAccessRule($identity, 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow')
$acl.AddAccessRule($rule)
Set-Acl -LiteralPath $checkpointDir -AclObject $acl
}
$toolArgs = @("--mode=$Mode", '--production=true', "--expected-target=$ExpectedTarget", '--batch-size=100', '--checkpoint=/credential-migration/checkpoint.json')
if ($Mode -eq 'finalize') { $toolArgs = @('--mode=verify', '--finalize=true', '--production=true', "--expected-target=$ExpectedTarget", '--batch-size=100') }
docker compose version *> $null
if ($LASTEXITCODE -eq 0) { docker compose run --rm --no-deps -v "${checkpointDir}:/credential-migration" easyai-server node /app/dist/credential-migrate.js @toolArgs }
else { docker-compose run --rm --no-deps -v "${checkpointDir}:/credential-migration" easyai-server node /app/dist/credential-migrate.js @toolArgs }
if ($LASTEXITCODE -ne 0) { throw 'Credential migration failed; preserve checkpoint and resolve configuration or concurrency conflicts' }
-50
View File
@@ -1,50 +0,0 @@
#Requires -Version 5.1
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'Initialize-SecurityEnv.ps1')
$temp = Join-Path ([System.IO.Path]::GetTempPath()) ('easyai-key-recovery-' + [Guid]::NewGuid().ToString('N'))
[System.IO.Directory]::CreateDirectory($temp) | Out-Null
$envFile = Join-Path $temp '.env'; $keyFile = Join-Path $temp 'keys.env'; $logFile = Join-Path $temp 'result.log'
function Assert-KeyTest([bool]$Value, [string]$Label) { if (-not $Value) { throw "FAILED: $Label" } }
try {
[System.IO.File]::WriteAllText($envFile, '')
Initialize-SecurityEnv -Path $envFile -Mode new *> $logFile
$original = Get-Content $envFile -Raw
$ring = Get-EnvValue $original 'CONFIG_CREDENTIAL_ENCRYPTION_KEYRING'
$second = [Convert]::FromBase64String((New-SecuritySecret))
$second = [Convert]::ToBase64String($second[0..31])
$multi = $ring.Substring(0, $ring.Length - 1) + ',"v2":"' + $second + '"}'
$content = Set-EnvValue $original 'CONFIG_CREDENTIAL_ENCRYPTION_KEYRING' ("'" + $multi + "'")
$content = Set-EnvValue $content 'CONFIG_CREDENTIAL_ENCRYPTION_ACTIVE_KEY_ID' 'v2'
$content = Set-EnvValue $content 'UNRELATED_CONFIG' 'keep-me'
[System.IO.File]::WriteAllText($envFile, $content)
& (Join-Path $PSScriptRoot 'Manage-CredentialKeys.ps1') -Mode export -Path $keyFile -EnvPath $envFile *>> $logFile
Assert-KeyTest ((Get-EnvValue (Get-Content $keyFile -Raw) 'CONFIG_CREDENTIAL_ENCRYPTION_KEYRING') -ceq $multi) 'full ring exported'
$refused = $false
try { & (Join-Path $PSScriptRoot 'Manage-CredentialKeys.ps1') -Mode export -Path $keyFile -EnvPath $envFile *>> $logFile } catch { $refused = $true }
Assert-KeyTest $refused 'existing backup never overwritten'
[System.IO.File]::WriteAllText($envFile, (Set-EnvValue $original 'UNRELATED_CONFIG' 'keep-me'))
& (Join-Path $PSScriptRoot 'Manage-CredentialKeys.ps1') -Mode restore -Path $keyFile -EnvPath $envFile *>> $logFile
$restored = Get-Content $envFile -Raw
foreach ($name in @('CONFIG_SECURITY_CONFIG_ENCRYPTION_KEY','CONFIG_JWT_SECRET')) { Assert-KeyTest ((Get-EnvValue $restored $name) -ceq (Get-EnvValue $original $name)) 'original config master and JWT retained' }
Assert-KeyTest ((Get-EnvValue $restored 'CONFIG_CREDENTIAL_ENCRYPTION_KEYRING') -ceq $multi) 'both original keys restored'
Assert-KeyTest ((Get-EnvValue $restored 'CONFIG_CREDENTIAL_ENCRYPTION_ACTIVE_KEY_ID') -ceq 'v2') 'active key restored'
Assert-KeyTest ((Get-EnvValue $restored 'UNRELATED_CONFIG') -ceq 'keep-me') 'unrelated config preserved'
$backups = @(Get-ChildItem $temp -Force -Filter '.env.pre-credential-restore.*')
Assert-KeyTest ($backups.Count -eq 1) 'previous env saved'
$bad = Join-Path $temp 'bad.env'
[System.IO.File]::WriteAllText($bad, "CONFIG_CREDENTIAL_ENCRYPTION_ACTIVE_KEY_ID=v2`nCONFIG_CREDENTIAL_ENCRYPTION_KEYRING='{}'`n")
$refused = $false
try { & (Join-Path $PSScriptRoot 'Manage-CredentialKeys.ps1') -Mode restore -Path $bad -EnvPath $envFile *>> $logFile } catch { $refused = $true }
Assert-KeyTest $refused 'invalid recovery refused'
Assert-KeyTest ((Get-Content $envFile -Raw) -ceq $restored) 'invalid recovery never mutates env'
Assert-KeyTest (-not (Get-Content $logFile -Raw).Contains($second)) 'no key leaked to log'
if ($env:OS -eq 'Windows_NT') {
$sid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value
foreach ($file in @($envFile, $keyFile, $backups[0].FullName)) {
$acl = Get-Acl -LiteralPath $file
Assert-KeyTest $acl.AreAccessRulesProtected 'private DACL'
foreach ($rule in $acl.Access) { Assert-KeyTest ($rule.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]).Value -eq $sid) 'only current user access' }
}
}
Write-Host 'Credential key backup/restore self-test passed.'
} finally { Remove-Item -LiteralPath $temp -Recurse -Force }
-31
View File
@@ -1,31 +0,0 @@
# Test scheduler registration without changing the host's real scheduled tasks.
$ErrorActionPreference = 'Stop'
$started = Get-Date
$root = Join-Path ([System.IO.Path]::GetTempPath()) ('easyai-task-' + [guid]::NewGuid().ToString('N'))
[System.IO.Directory]::CreateDirectory((Join-Path $root 'scripts')) | Out-Null
try {
Copy-Item (Join-Path $PSScriptRoot 'Initialize-SecurityEnv.ps1') (Join-Path $root 'scripts')
Copy-Item (Join-Path $PSScriptRoot 'Install-InfrastructureUpdateTask.ps1') (Join-Path $root 'scripts')
$global:infraTaskTestTasks = @{ 'Unrelated-Backup' = @{ keep = $true } }
function New-ScheduledTaskAction { param($Execute, $Argument); return @{Execute=$Execute; Argument=$Argument} }
function New-ScheduledTaskTrigger { param([switch]$Daily, $At); return @{Daily=$Daily; At=$At} }
function New-ScheduledTaskPrincipal { param($UserId, $LogonType, $RunLevel); return @{User=$UserId; Logon=$LogonType; Level=$RunLevel} }
function New-ScheduledTaskSettingsSet { param([switch]$StartWhenAvailable, $MultipleInstances, $ExecutionTimeLimit); return @{Concurrent=$MultipleInstances; Available=$StartWhenAvailable} }
function Register-ScheduledTask { param($TaskName, $Action, $Trigger, $Principal, $Settings, [switch]$Force); $global:infraTaskTestTasks[$TaskName]=@{Action=$Action; Trigger=$Trigger; Principal=$Principal; Settings=$Settings}; return @{} }
function Get-ScheduledTask { param($TaskName, $ErrorAction); return $global:infraTaskTestTasks[$TaskName] }
function Unregister-ScheduledTask { param($TaskName, $Confirm); $global:infraTaskTestTasks.Remove($TaskName) }
$envFile = Join-Path $root '.env'
[System.IO.File]::WriteAllText($envFile, 'INFRASTRUCTURE_AUTO_UPDATE_ENABLED=true')
& (Join-Path $root 'scripts/Install-InfrastructureUpdateTask.ps1') -ProjectRoot $root
& (Join-Path $root 'scripts/Install-InfrastructureUpdateTask.ps1') -ProjectRoot $root
if ($global:infraTaskTestTasks.Count -ne 2) { throw 'Repeated install duplicated tasks or deleted unrelated tasks' }
$key = @($global:infraTaskTestTasks.Keys | Where-Object { $_ -like 'EasyAI-Watchtower-*' })[0]
$task = $global:infraTaskTestTasks[$key]
if ($task.Trigger.Count -ne 4 -or $task.Settings.Concurrent -ne 'IgnoreNew') { throw 'Schedule or concurrency policy changed' }
if ($task.Principal.Logon -ne 'Interactive') { throw 'Task would not use the Docker Desktop user' }
if ($task.Action.Argument -notlike '*Update-Infrastructure.ps1*' -or $task.Action.Argument -notlike '*-NonInteractive*') { throw 'Incorrect task command' }
[System.IO.File]::WriteAllText($envFile, 'INFRASTRUCTURE_AUTO_UPDATE_ENABLED=false')
& (Join-Path $root 'scripts/Install-InfrastructureUpdateTask.ps1') -ProjectRoot $root
if ($global:infraTaskTestTasks.Count -ne 1 -or -not $global:infraTaskTestTasks.ContainsKey('Unrelated-Backup')) { throw 'Disable removed an unrelated task' }
Write-Host "PowerShell scheduler contract passed in $([math]::Round(((Get-Date)-$started).TotalSeconds,2)) seconds"
} finally { Remove-Item -Recurse -Force $root; Remove-Variable infraTaskTestTasks -Scope Global -ErrorAction SilentlyContinue }
-93
View File
@@ -1,93 +0,0 @@
#Requires -Version 5.1
$ErrorActionPreference = "Stop"
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
. (Join-Path $scriptDir "Initialize-PublicApiBaseUrl.ps1")
$tempDir = Join-Path ([System.IO.Path]::GetTempPath()) ("easyai-public-url-" + [Guid]::NewGuid())
[System.IO.Directory]::CreateDirectory($tempDir) | Out-Null
function Write-TestEnv {
param([string]$Name, [string[]]$Lines)
$path = Join-Path $tempDir $Name
[System.IO.File]::WriteAllText($path, ($Lines -join "`r`n") + "`r`n", [System.Text.UTF8Encoding]::new($false))
return $path
}
function Assert-PublicUrl {
param([string]$Path, [string]$Expected)
$content = Get-Content $Path -Raw -Encoding UTF8
$actual = Get-PublicEnvValue $content "CONFIG_PUBLIC_API_BASE_URL"
if ($actual -ne $Expected) { throw "Expected '$Expected', got '$actual'" }
}
try {
$existing = Write-TestEnv "existing.env" @(
"CONFIG_PUBLIC_API_BASE_URL=https://existing.example.com:8443/api",
"NUXT_PUBLIC_BASE_APIURL=/api"
)
Initialize-PublicApiBaseUrl -Path $existing -Mode upgrade
Assert-PublicUrl $existing "https://existing.example.com:8443/api"
$absolute = Write-TestEnv "absolute.env" @(
"CONFIG_PUBLIC_API_BASE_URL=",
"NUXT_PUBLIC_BASE_APIURL=http://10.0.0.8:4100"
)
Initialize-PublicApiBaseUrl -Path $absolute -Mode upgrade
Assert-PublicUrl $absolute "http://10.0.0.8:4100"
$websocket = Write-TestEnv "websocket.env" @(
"CONFIG_PUBLIC_API_BASE_URL=",
"NUXT_PUBLIC_BASE_APIURL=/api",
"NUXT_PUBLIC_BASE_SOCKETURL=wss://demo.example.com/socket.io"
)
Initialize-PublicApiBaseUrl -Path $websocket -Mode upgrade
Assert-PublicUrl $websocket "https://demo.example.com/api"
$securityOrigin = Write-TestEnv "security-origin.env" @(
"CONFIG_PUBLIC_API_BASE_URL=",
"NUXT_PUBLIC_BASE_APIURL=/api",
"NUXT_PUBLIC_BASE_SOCKETURL=",
"CONFIG_SECURITY_ORIGIN=http://demo.example.com,https://demo.example.com"
)
Initialize-PublicApiBaseUrl -Path $securityOrigin -Mode upgrade
Assert-PublicUrl $securityOrigin "http://demo.example.com/api"
$fileOverride = Write-TestEnv "file-override.env" @(
"CONFIG_PUBLIC_API_BASE_URL=",
"DEPLOY_PUBLIC_API_BASE_URL=https://proxy.example.com:9443/custom-api",
"NUXT_PUBLIC_BASE_APIURL=/api"
)
Initialize-PublicApiBaseUrl -Path $fileOverride -Mode upgrade
Assert-PublicUrl $fileOverride "https://proxy.example.com:9443/custom-api"
$explicitOverride = Write-TestEnv "explicit-override.env" @(
"CONFIG_PUBLIC_API_BASE_URL=",
"NUXT_PUBLIC_BASE_APIURL=/api"
)
Initialize-PublicApiBaseUrl `
-Path $explicitOverride `
-Mode configure `
-Override "http://192.168.1.20:4567"
Assert-PublicUrl $explicitOverride "http://192.168.1.20:4567"
$invalid = Write-TestEnv "invalid.env" @(
"CONFIG_PUBLIC_API_BASE_URL=ftp://bad.example.com/api",
"NUXT_PUBLIC_BASE_APIURL=https://good.example.com/api"
)
$blocked = $false
try { Initialize-PublicApiBaseUrl -Path $invalid -Mode upgrade } catch { $blocked = $true }
if (-not $blocked) { throw "Invalid existing CONFIG_PUBLIC_API_BASE_URL must block startup" }
$unresolved = Write-TestEnv "unresolved.env" @(
"CONFIG_PUBLIC_API_BASE_URL=",
"NUXT_PUBLIC_BASE_APIURL=/api"
)
$blocked = $false
try { Initialize-PublicApiBaseUrl -Path $unresolved -Mode upgrade } catch { $blocked = $true }
if (-not $blocked) { throw "Unresolved public API URL must block startup" }
Write-Host "Public API base URL PowerShell tests passed" -ForegroundColor Green
} finally {
Remove-Item $tempDir -Recurse -Force -ErrorAction SilentlyContinue
}
-37
View File
@@ -1,37 +0,0 @@
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'Assert-RedisPersistence.ps1')
$projectRoot = Split-Path $PSScriptRoot -Parent
$expected = [System.IO.Path]::GetFullPath((Join-Path $projectRoot 'data/redis'))
$script:state = 'absent'
function docker {
$global:LASTEXITCODE = 0
switch ($args[0]) {
'info' {
if ($script:state -eq 'unavailable') { $global:LASTEXITCODE = 1 }
return
}
'container' {
if ($script:state -eq 'absent') { $global:LASTEXITCODE = 1 }
return
}
'inspect' {
switch ($script:state) {
'mounted' { return "bind|$expected" }
'wrong_mount' { return 'bind|/other/redis' }
default { return '' }
}
}
}
}
Assert-RedisPersistence -ProjectRoot $projectRoot
$script:state = 'mounted'
Assert-RedisPersistence -ProjectRoot $projectRoot
foreach ($unsafe in @('unavailable', 'no_mount', 'wrong_mount')) {
$script:state = $unsafe
$rejected = $false
try { Assert-RedisPersistence -ProjectRoot $projectRoot } catch { $rejected = $true }
if (-not $rejected) { throw "Unsafe Redis state accepted: $unsafe" }
}
Write-Host 'Redis persistence PowerShell tests passed'
-106
View File
@@ -1,106 +0,0 @@
#Requires -Version 5.1
$ErrorActionPreference = "Stop"
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
. (Join-Path $scriptDir "Initialize-SecurityEnv.ps1")
$tempDir = Join-Path ([System.IO.Path]::GetTempPath()) ("easyai-security-env-" + [Guid]::NewGuid())
[System.IO.Directory]::CreateDirectory($tempDir) | Out-Null
function Write-TestEnv {
param([string]$Name, [string[]]$Lines)
$path = Join-Path $tempDir $Name
[System.IO.File]::WriteAllText($path, ($Lines -join "`r`n") + "`r`n", [System.Text.UTF8Encoding]::new($false))
return $path
}
function Assert-MinLength {
param([string]$Content, [string]$Key, [int]$Minimum)
$value = Get-EnvValue $Content $Key
if ($value.Length -lt $Minimum) { throw "$Key must contain at least $Minimum characters" }
}
function Assert-Value {
param([string]$Content, [string]$Key, [string]$Expected)
$actual = Get-EnvValue $Content $Key
if ($actual -ne $Expected) { throw "$Key expected '$Expected', got '$actual'" }
}
try {
$upgrade = Write-TestEnv "upgrade.env" @(
"CONFIG_JWT_SECRET='this is a very secret secret'",
"CONFIG_SECURITY_CONFIG_ENCRYPTION_KEY=",
"WS_AUTH_WS_TICKET_SECRET=",
"WS_AUTH_METHODS=none,bearer"
)
Initialize-SecurityEnv -Path $upgrade -Mode upgrade
$upgradeContent = Get-Content $upgrade -Raw -Encoding UTF8
Assert-MinLength $upgradeContent "CONFIG_JWT_SECRET" 32
Assert-MinLength $upgradeContent "WS_AUTH_WS_TICKET_SECRET" 32
Assert-Value $upgradeContent "CONFIG_SECURITY_CONFIG_ENCRYPTION_KEY" ""
Assert-Value $upgradeContent "WS_AUTH_METHODS" "none,bearer,ws_ticket"
$upgradeBeforeRepeat = $upgradeContent
Initialize-SecurityEnv -Path $upgrade -Mode upgrade
$upgradeAfterRepeat = Get-Content $upgrade -Raw -Encoding UTF8
if ($upgradeAfterRepeat -cne $upgradeBeforeRepeat) { throw "Repeated upgrade must preserve generated values byte-for-byte" }
$preservedSecret = "existing-ws-ticket-secret-that-is-long-enough"
$preserved = Write-TestEnv "preserved.env" @(
"CONFIG_JWT_SECRET=existing-jwt-secret-that-is-long-enough",
"WS_AUTH_WS_TICKET_SECRET=$preservedSecret",
"WS_AUTH_METHODS=bearer,ws_ticket"
)
Initialize-SecurityEnv -Path $preserved -Mode upgrade
$preservedContent = Get-Content $preserved -Raw -Encoding UTF8
Assert-Value $preservedContent "WS_AUTH_WS_TICKET_SECRET" $preservedSecret
Assert-Value $preservedContent "WS_AUTH_METHODS" "bearer,ws_ticket"
$fresh = Write-TestEnv "new.env" @(
"CONFIG_JWT_SECRET=",
"CONFIG_SECURITY_CONFIG_ENCRYPTION_KEY=",
"CONFIG_OTP_HASH_SECRET=",
"CONFIG_AUDIT_HASH_PEPPER=",
"CONFIG_AUDIT_INTEGRITY_KEY=",
"CONFIG_INITIAL_ADMIN_PASSWORD=",
"WS_AUTH_WS_TICKET_SECRET=",
"WS_AUTH_METHODS="
)
Initialize-SecurityEnv -Path $fresh -Mode new
$freshContent = Get-Content $fresh -Raw -Encoding UTF8
foreach ($key in @(
"CONFIG_JWT_SECRET",
"CONFIG_SECURITY_CONFIG_ENCRYPTION_KEY",
"CONFIG_OTP_HASH_SECRET",
"CONFIG_AUDIT_HASH_PEPPER",
"CONFIG_AUDIT_INTEGRITY_KEY",
"WS_AUTH_WS_TICKET_SECRET"
)) {
Assert-MinLength $freshContent $key 32
}
Assert-MinLength $freshContent "CONFIG_INITIAL_ADMIN_PASSWORD" 12
Assert-Value $freshContent "WS_AUTH_METHODS" "none,bearer,ws_ticket"
$freshRing = Get-EnvValue $freshContent 'CONFIG_CREDENTIAL_ENCRYPTION_KEYRING'
$freshId = Get-EnvValue $freshContent 'CONFIG_CREDENTIAL_ENCRYPTION_ACTIVE_KEY_ID'
$parsedRing = $freshRing | ConvertFrom-Json
if ([Convert]::FromBase64String($parsedRing.$freshId).Length -ne 32) { throw 'Invalid credential key length' }
Assert-Value $freshContent 'CONFIG_CREDENTIAL_ALLOW_LEGACY_PLAINTEXT' 'false'
Initialize-SecurityEnv -Path $fresh -Mode upgrade
if ((Get-EnvValue (Get-Content $fresh -Raw) 'CONFIG_CREDENTIAL_ENCRYPTION_KEYRING') -cne $freshRing) { throw 'Credential key must survive repeat upgrade' }
if (Get-EnvValue $upgradeContent 'CONFIG_CREDENTIAL_ENCRYPTION_KEYRING') { throw 'Existing deployment must not silently opt in' }
$env:DEPLOY_ENABLE_CREDENTIAL_ENCRYPTION = 'true'
Initialize-SecurityEnv -Path $upgrade -Mode upgrade
$env:DEPLOY_ENABLE_CREDENTIAL_ENCRYPTION = $null
$opted = Get-Content $upgrade -Raw
Assert-Value $opted 'CONFIG_CREDENTIAL_ALLOW_LEGACY_PLAINTEXT' 'true'
$before = Get-EnvValue $opted 'CONFIG_CREDENTIAL_ENCRYPTION_KEYRING'
Initialize-SecurityEnv -Path $upgrade -Mode upgrade
Assert-Value (Get-Content $upgrade -Raw) 'CONFIG_CREDENTIAL_ENCRYPTION_KEYRING' $before
$bad = Write-TestEnv 'bad.env' @('CONFIG_CREDENTIAL_ENCRYPTION_ACTIVE_KEY_ID=v1', 'CONFIG_CREDENTIAL_ENCRYPTION_KEYRING=')
$rejected = $false
try { Initialize-SecurityEnv -Path $bad -Mode upgrade } catch { $rejected = $true }
if (-not $rejected) { throw 'Partial key configuration must fail' }
Write-Host "Security environment PowerShell tests passed" -ForegroundColor Green
} finally {
Remove-Item $tempDir -Recurse -Force -ErrorAction SilentlyContinue
}
-36
View File
@@ -1,36 +0,0 @@
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'Initialize-SecurityOrigin.ps1')
$tempDir = Join-Path ([System.IO.Path]::GetTempPath()) ("easyai-origin-" + [guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Path $tempDir | Out-Null
try {
$path = Join-Path $tempDir 'test.env'
[System.IO.File]::WriteAllText($path, "CONFIG_PUBLIC_API_BASE_URL=https://zaowua.com/api`n")
$before = Get-Content $path -Raw
Initialize-SecurityOrigin -Path $path
$content = Get-Content $path -Raw
if ((Get-PublicEnvValue $content 'CONFIG_SECURITY_ORIGIN')) { throw 'Missing origin was unexpectedly initialized' }
if ($content -ne $before) { throw 'Missing origin changed the environment' }
$before = $content
Initialize-SecurityOrigin -Path $path
if ((Get-Content $path -Raw) -ne $before) { throw 'Repeated initialization changed the environment' }
[System.IO.File]::WriteAllText($path, "CONFIG_PUBLIC_API_BASE_URL=https://zaowua.com/api`nCONFIG_SECURITY_ORIGIN=http://127.0.0.1,http://localhost`n")
Initialize-SecurityOrigin -Path $path
if ((Get-PublicEnvValue (Get-Content $path -Raw) 'CONFIG_SECURITY_ORIGIN')) { throw 'Sample origin was not cleared' }
[System.IO.File]::WriteAllText($path, "CONFIG_PUBLIC_API_BASE_URL=https://zaowua.com/api`nCONFIG_SECURITY_ORIGIN=https://zaowua.com,https://www.zaowua.com`n")
$before = Get-Content $path -Raw
Initialize-SecurityOrigin -Path $path
if ((Get-Content $path -Raw) -ne $before) { throw 'Custom origins were not preserved' }
foreach ($invalid in @('*', 'https://zaowua.com/api', 'https://zaowua.com,', 'https://user:pass@zaowua.com')) {
[System.IO.File]::WriteAllText($path, "CONFIG_PUBLIC_API_BASE_URL=https://zaowua.com/api`nCONFIG_SECURITY_ORIGIN=$invalid`n")
$rejected = $false
try { Initialize-SecurityOrigin -Path $path } catch { $rejected = $true }
if (-not $rejected) { throw "Accepted invalid origin: $invalid" }
}
Write-Host 'Security origin PowerShell tests passed'
} finally {
Remove-Item -Recurse -Force $tempDir
}
-40
View File
@@ -1,40 +0,0 @@
$ErrorActionPreference = "Stop"
. (Join-Path $PSScriptRoot "Initialize-ServerHttpBindIp.ps1")
$tempDir = Join-Path ([System.IO.Path]::GetTempPath()) ("easyai-bind-test-" + [guid]::NewGuid().ToString("N"))
New-Item -ItemType Directory -Path $tempDir | Out-Null
$envPath = Join-Path $tempDir ".env"
function Assert-BindIp {
param([string]$PublicApiUrl, [string]$Expected)
[System.IO.File]::WriteAllText($envPath, "NUXT_PUBLIC_BASE_APIURL=$PublicApiUrl`n", [System.Text.UTF8Encoding]::new($false))
Initialize-ServerHttpBindIp -Path $envPath
$content = Get-Content $envPath -Raw -Encoding UTF8
if ($content -notmatch "(?m)^SERVER_HTTP_BIND_IP=$([regex]::Escape($Expected))$") {
throw "Expected SERVER_HTTP_BIND_IP=$Expected for $PublicApiUrl"
}
}
try {
Assert-BindIp "/api" "127.0.0.1"
Assert-BindIp "http://10.0.0.8:3001" "0.0.0.0"
Assert-BindIp "http://127.0.0.1:3001" "127.0.0.1"
[System.IO.File]::WriteAllText($envPath, "NUXT_PUBLIC_BASE_APIURL=/api`nSERVER_HTTP_BIND_IP=0.0.0.0`n", [System.Text.UTF8Encoding]::new($false))
Initialize-ServerHttpBindIp -Path $envPath
if ((Get-Content $envPath -Raw -Encoding UTF8) -notmatch '(?m)^SERVER_HTTP_BIND_IP=0\.0\.0\.0$') {
throw "Existing explicit bind value was not preserved"
}
$invalidAccepted = $false
try {
Initialize-ServerHttpBindIp -Path $envPath -Override "192.168.1.8"
$invalidAccepted = $true
} catch { }
if ($invalidAccepted) { throw "Invalid SERVER_HTTP_BIND_IP unexpectedly accepted" }
Write-Host "Server HTTP bind IP PowerShell tests passed"
} finally {
Remove-Item -Recurse -Force $tempDir -ErrorAction SilentlyContinue
}
-74
View File
@@ -1,74 +0,0 @@
param([string]$ProjectRoot = (Split-Path $PSScriptRoot -Parent))
$ErrorActionPreference = 'Stop'
Set-Location $ProjectRoot
. (Join-Path $ProjectRoot 'scripts/Initialize-SecurityEnv.ps1')
$content = [System.IO.File]::ReadAllText((Join-Path $ProjectRoot '.env'))
if ((Get-EnvValue $content 'INFRASTRUCTURE_AUTO_UPDATE_ENABLED') -eq 'false') { return }
$state = Join-Path $ProjectRoot 'data/infrastructure-update'
[System.IO.Directory]::CreateDirectory($state) | Out-Null
try { $lock = [System.IO.File]::Open((Join-Path $state 'task.lock'), 'OpenOrCreate', 'ReadWrite', 'None') }
catch [System.IO.IOException] { return }
$log = Join-Path $state 'last-run.log'
$previousConfig = $env:DOCKER_CONFIG
try {
[System.IO.File]::WriteAllText($log, "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') 检查 Watchtower 和 updater 更新`n")
$configFile = Get-EnvValue $content 'DOCKER_CONFIG_FILE'
if (-not [string]::IsNullOrWhiteSpace($configFile) -and (Test-Path $configFile -PathType Leaf)) {
$env:DOCKER_CONFIG = Split-Path $configFile -Parent
}
& docker compose version *> $null
$v2 = $LASTEXITCODE -eq 0
function Invoke-InfrastructureCompose {
param([string[]]$ComposeArguments)
if ($v2) { $output = & docker compose @ComposeArguments 2>&1 }
else { $output = & docker-compose @ComposeArguments 2>&1 }
$code = $LASTEXITCODE
$output | Out-File $log -Append -Encoding utf8
if ($code -ne 0) { throw '组件更新命令未成功,现有容器状态需查看日志' }
}
if ($v2) { $available = @(& docker compose config --services) }
else { $available = @(& docker-compose config --services) }
if ($LASTEXITCODE -ne 0 -or $available -notcontains 'watchtower') { throw '无法读取 Watchtower 部署配置' }
$failed = @()
foreach ($service in @('watchtower', 'easyai-updater')) {
# Do not enable updater in profile-disabled legacy deployments.
if ($available -notcontains $service) {
"$service 未启用,跳过维护" | Out-File $log -Append -Encoding utf8
continue
}
try {
"检查 $service" | Out-File $log -Append -Encoding utf8
Invoke-InfrastructureCompose -ComposeArguments @('pull', $service)
Invoke-InfrastructureCompose -ComposeArguments @('up', '-d', '--no-deps', $service)
$healthy = $false
for ($attempt=0; $attempt -lt 12; $attempt++) {
if ($v2) { $containerId = & docker compose ps -q $service }
else { $containerId = & docker-compose ps -q $service }
if ($LASTEXITCODE -ne 0) { throw "无法读取 $service 状态" }
if ($containerId) {
$status = & docker inspect -f '{{.State.Running}} {{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' $containerId
if ($LASTEXITCODE -ne 0) { throw "无法读取 $service 状态" }
if ($status -eq 'true healthy') { $healthy = $true; break }
if ($status -eq 'true none') {
if ($service -eq 'watchtower') {
& docker exec $containerId /watchtower --health-check *> $null
} else {
& docker exec $containerId node -e "require('http').get('http://127.0.0.1:3015/health',r=>{r.resume();process.exit(r.statusCode===200?0:1)}).on('error',()=>process.exit(1))" *> $null
}
if ($LASTEXITCODE -eq 0) { $healthy = $true; break }
}
}
Start-Sleep -Seconds 5
}
if (-not $healthy) { throw "$service 暂未恢复可用" }
"$service 已恢复可用" | Out-File $log -Append -Encoding utf8
} catch {
"$service 本次维护未成功,下次定时任务将继续检查:$($_.Exception.Message)" | Out-File $log -Append -Encoding utf8
$failed += $service
}
}
if ($failed.Count -gt 0) { throw "更新组件维护未全部成功:$($failed -join ', ')" }
} finally {
$env:DOCKER_CONFIG = $previousConfig
$lock.Dispose()
}
-151
View File
@@ -1,151 +0,0 @@
#!/bin/bash
set -euo pipefail
ENV_FILE="${1:-.env}"
PROJECT_ROOT="${2:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
MIGRATIONS_DIR="${UPDATE_ENV_MIGRATIONS_DIR:-$PROJECT_ROOT/scripts/update-env.d}"
if [ ! -f "$ENV_FILE" ]; then
echo "❌ 环境配置文件不存在: $ENV_FILE" >&2
exit 1
fi
if [ ! -d "$MIGRATIONS_DIR" ]; then
echo "ℹ️ 未发现环境配置迁移目录,跳过"
exit 0
fi
read_env_value() {
local file="$1"
local key="$2"
local value
value="$(awk -v key="$key" 'index($0, key "=") == 1 { print substr($0, length(key) + 2); exit }' "$file")"
value="$(printf '%s' "$value" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
case "$value" in
\"*\") value="${value#\"}"; value="${value%\"}" ;;
\'*\') value="${value#\'}"; value="${value%\'}" ;;
esac
printf '%s' "$value"
}
write_env_value() {
local file="$1"
local key="$2"
local value="$3"
local temporary
temporary="$(mktemp "${file}.tmp.XXXXXX")"
awk -v key="$key" -v value="$value" '
BEGIN { replaced = 0 }
index($0, key "=") == 1 {
if (!replaced) {
print key "=" value
replaced = 1
}
next
}
{ print }
END { if (!replaced) print key "=" value }
' "$file" > "$temporary"
chmod 600 "$temporary"
mv "$temporary" "$file"
}
apply_default() {
local key="$1"
local value="$2"
if [ -n "$(read_env_value "$ENV_FILE" "$key")" ]; then
return 0
fi
write_env_value "$ENV_FILE" "$key" "$value"
echo " ✓ 已补充环境配置: $key"
}
ensure_list_item() {
local key="$1"
local item="$2"
local current normalized
current="$(read_env_value "$ENV_FILE" "$key")"
normalized="$(printf '%s' "$current" | tr -d '[:space:]')"
case ",${normalized}," in
*",${item},"*) return 0 ;;
esac
if [ -n "$current" ]; then
current="${current},${item}"
else
current="$item"
fi
write_env_value "$ENV_FILE" "$key" "$current"
echo " ✓ 已补充环境配置项: $key"
}
validate_manifest() {
local manifest="$1"
local line line_number=0
while IFS= read -r line || [ -n "$line" ]; do
line_number=$((line_number + 1))
line="${line%$'\r'}"
case "$line" in
""|'#'*) continue ;;
esac
if [[ "$line" =~ ^@skip-if[[:space:]]+[A-Z][A-Z0-9_]*=.+$ ]]; then
continue
fi
if ! [[ "$line" =~ ^[A-Z][A-Z0-9_]*(\+)?=.+$ ]]; then
echo "❌ 无效的环境配置迁移: ${manifest}:${line_number}" >&2
return 1
fi
done < "$manifest"
}
apply_manifest() {
local manifest="$1"
local line key value current
while IFS= read -r line || [ -n "$line" ]; do
line="${line%$'\r'}"
if [[ "$line" =~ ^@skip-if[[:space:]]+([A-Z][A-Z0-9_]*)=(.+)$ ]]; then
key="${BASH_REMATCH[1]}"
value="${BASH_REMATCH[2]}"
current="$(read_env_value "$ENV_FILE" "$key")"
if [ "$current" = "$value" ]; then
echo "ℹ️ 保留现有更新模式,跳过环境配置迁移: $(basename "$manifest")"
return 0
fi
fi
done < "$manifest"
echo "📝 应用环境配置迁移: $(basename "$manifest")"
while IFS= read -r line || [ -n "$line" ]; do
line="${line%$'\r'}"
case "$line" in
""|'#'*) continue ;;
esac
if [[ "$line" == @skip-if* ]]; then
continue
fi
if [[ "$line" == *'+='* ]]; then
key="${line%%+=*}"
value="${line#*+=}"
ensure_list_item "$key" "$value"
else
key="${line%%=*}"
value="${line#*=}"
apply_default "$key" "$value"
fi
done < "$manifest"
}
shopt -s nullglob
manifests=("$MIGRATIONS_DIR"/*.env)
if [ "${#manifests[@]}" -eq 0 ]; then
echo "ℹ️ 未发现环境配置迁移文件,跳过"
exit 0
fi
for manifest in "${manifests[@]}"; do
validate_manifest "$manifest"
done
for manifest in "${manifests[@]}"; do
apply_manifest "$manifest"
done
chmod 600 "$ENV_FILE"
-24
View File
@@ -1,24 +0,0 @@
#!/usr/bin/env bash
# 在 Compose 可能重建 Redis 前,阻止把容器写层或旧 volume 中的数据静默替换为空目录。
set -euo pipefail
project_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
expected_dir="$(cd "${project_dir}/data" && pwd -P)/redis"
container="${REDIS_CONTAINER_NAME:-redis}"
if ! docker info >/dev/null 2>&1; then
echo "❌ 无法连接 Docker,未检查 Redis 数据位置,停止部署" >&2
exit 1
fi
if ! docker container inspect "$container" >/dev/null 2>&1; then
exit 0
fi
mount="$(docker inspect --format '{{range .Mounts}}{{if eq .Destination "/data"}}{{.Type}}|{{.Source}}{{end}}{{end}}' "$container")"
if [ "$mount" != "bind|$expected_dir" ]; then
echo "❌ 现有 Redis 的 /data 未挂载到 ${expected_dir},停止部署以保护 AOF/RDB 数据" >&2
echo " 请按 README 的 Redis 数据迁移步骤先备份、停机、复制并校验,再重建 Redis" >&2
exit 1
fi
-50
View File
@@ -1,50 +0,0 @@
#!/bin/bash
set -euo pipefail
umask 077
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=scripts/init-security-env.sh
. "$SCRIPT_DIR/init-security-env.sh"
mode="${1:-}"; keyfile="${2:-}"; envfile="${3:-$SCRIPT_DIR/../.env}"
case "$mode" in export|restore) ;; *) echo '用法: credential-keys.sh export|restore 私有密钥备份文件 [部署.env]' >&2; exit 1 ;; esac
[ -n "$keyfile" ] && [ -f "$envfile" ] || { echo '请指定密钥备份文件,并先准备部署 .env' >&2; exit 1; }
[ "$keyfile" != "$envfile" ] || { echo '密钥备份必须与部署配置分开' >&2; exit 1; }
sourcefile="$envfile"
if [ "$mode" = restore ]; then sourcefile="$keyfile"; fi
[ -f "$sourcefile" ] || { echo '密钥备份不存在' >&2; exit 1; }
active="$(read_env_value "$sourcefile" CONFIG_CREDENTIAL_ENCRYPTION_ACTIVE_KEY_ID)"
ring="$(read_env_value "$sourcefile" CONFIG_CREDENTIAL_ENCRYPTION_KEYRING)"
legacy="$(read_env_value "$sourcefile" CONFIG_CREDENTIAL_ALLOW_LEGACY_PLAINTEXT)"
[ -n "$active" ] && [ -n "$ring" ] || { echo '没有完整加密密钥,不能导出或恢复;不会生成替代密钥' >&2; exit 1; }
tmp="$(mktemp "${envfile}.credential-keys.XXXXXX")"
trap 'rm -f "$tmp"' EXIT
printf 'CONFIG_CREDENTIAL_ENCRYPTION_ACTIVE_KEY_ID=%s\nCONFIG_CREDENTIAL_ENCRYPTION_KEYRING=%s\nCONFIG_CREDENTIAL_ALLOW_LEGACY_PLAINTEXT=%s\n' "$active" "'$ring'" "${legacy:-false}" > "$tmp"
init_credential_encryption_env "$tmp" upgrade >/dev/null
# Preserve the independent master and JWT fallback used by earlier encrypted settings.
for name in CONFIG_SECURITY_CONFIG_ENCRYPTION_KEY CONFIG_JWT_SECRET; do
if [ "$mode" = export ]; then
awk -v key="$name" 'index($0, key "=") == 1 { print; found=1; exit } END { if (!found) print key "=" }' "$sourcefile" >> "$tmp"
else
awk -v key="$name" 'index($0, key "=") == 1 { print; exit }' "$sourcefile" >> "$tmp"
fi
done
if [ "$mode" = export ]; then
# noclobber reserves an immutable backup instead of truncating an earlier keyring.
( set -o noclobber; cat "$tmp" > "$keyfile" ) || { echo '密钥备份已存在或路径不可写,请使用新文件' >&2; exit 1; }
chmod 600 "$keyfile"
echo '已导出完整 keyring(含旧密钥);请与数据库备份分开私有保管。'
else
previous="${envfile}.pre-credential-restore.$(date +%Y%m%dT%H%M%S).$$"
cp "$envfile" "$previous"; chmod 600 "$previous"
key_content="$(cat "$tmp")"
awk 'index($0,"CONFIG_CREDENTIAL_ENCRYPTION_ACTIVE_KEY_ID=")==1 || index($0,"CONFIG_CREDENTIAL_ENCRYPTION_KEYRING=")==1 || index($0,"CONFIG_CREDENTIAL_ALLOW_LEGACY_PLAINTEXT=")==1 { next } { print }' "$envfile" > "$tmp"
for name in CONFIG_SECURITY_CONFIG_ENCRYPTION_KEY CONFIG_JWT_SECRET; do
if printf '%s\n' "$key_content" | awk -v key="$name" 'index($0,key "=")==1 { found=1 } END { exit !found }'; then
filtered="$(mktemp "${envfile}.filtered.XXXXXX")"
awk -v key="$name" 'index($0,key "=")!=1 { print }' "$tmp" > "$filtered"
mv "$filtered" "$tmp"
fi
done
printf '%s\n' "$key_content" >> "$tmp"
chmod 600 "$tmp"; mv "$tmp" "$envfile"
echo '已恢复原 keyring,其他配置保留;重建全部相关容器后再恢复数据库。'
fi
-20
View File
@@ -1,20 +0,0 @@
#!/bin/bash
set -euo pipefail
PROJECT_ROOT="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
. "$PROJECT_ROOT/scripts/init-security-env.sh"
[ "$(read_env_value "$PROJECT_ROOT/.env" INFRASTRUCTURE_AUTO_UPDATE_ENABLED)" != false ] || exit 0
image="$(read_env_value "$PROJECT_ROOT/.env" WATCHTOWER_IMAGE)"
if [ -z "$image" ] || [[ "$image" =~ ^registry\.cn-shanghai\.aliyuncs\.com/comfy-ai/watchtower-aliyun:[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
write_env_value "$PROJECT_ROOT/.env" WATCHTOWER_IMAGE registry.cn-shanghai.aliyuncs.com/comfy-ai/watchtower-aliyun:latest
fi
# Scope changes require the selected containers to receive matching labels.
# A targeted update must not strand unchanged containers outside the new scope.
image="$(read_env_value "$PROJECT_ROOT/.env" WATCHTOWER_IMAGE)"
target="$(read_env_value "$PROJECT_ROOT/.env" WATCHTOWER_UPDATE_TARGET)"
if [ -z "${UPDATE_SERVICES:-}" ] && [ -z "$(read_env_value "$PROJECT_ROOT/.env" WATCHTOWER_SCOPE)" ] && [ "$image" = registry.cn-shanghai.aliyuncs.com/comfy-ai/watchtower-aliyun:latest ]; then
case "$target" in
''|'easyai-updater'|'easyai-server easyai-wsgateway easyai-web')
write_env_value "$PROJECT_ROOT/.env" WATCHTOWER_SCOPE easyai
;;
esac
fi
-157
View File
@@ -1,157 +0,0 @@
#!/bin/bash
# 初始化并持久化后端对外公开 API 地址。不会 source .env,避免执行其中内容。
public_url_read_env_value() {
local file="$1"
local key="$2"
local value
value="$(awk -v key="$key" 'index($0, key "=") == 1 { print substr($0, length(key) + 2); exit }' "$file")"
value="$(printf '%s' "$value" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
case "$value" in
\"*\") value="${value#\"}"; value="${value%\"}" ;;
\'*\') value="${value#\'}"; value="${value%\'}" ;;
esac
printf '%s' "$value"
}
public_url_write_env_value() {
local file="$1"
local key="$2"
local value="$3"
local tmp
tmp="$(mktemp "${file}.tmp.XXXXXX")"
awk -v key="$key" -v value="$value" '
BEGIN { replaced = 0 }
index($0, key "=") == 1 {
if (!replaced) {
print key "=" value
replaced = 1
}
next
}
{ print }
END {
if (!replaced) print key "=" value
}
' "$file" > "$tmp"
chmod 600 "$tmp"
mv "$tmp" "$file"
}
public_url_normalize() {
local value
value="$(printf '%s' "$1" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
while [ "${value%/}" != "$value" ]; do value="${value%/}"; done
printf '%s' "$value"
}
public_url_validate() {
local value rest authority host port
value="$(public_url_normalize "$1")"
case "$value" in
http://*|https://*) ;;
*) return 1 ;;
esac
case "$value" in
*[[:space:]@?#]*) return 1 ;;
esac
rest="${value#*://}"
authority="${rest%%/*}"
[ -n "$authority" ] || return 1
if [[ "$authority" == \[* ]]; then
[[ "$authority" =~ ^\[[0-9A-Fa-f:.]+\](:[0-9]+)?$ ]] || return 1
if [[ "$authority" == *]:* ]]; then port="${authority##*:}"; fi
else
[[ "$authority" =~ ^[A-Za-z0-9._-]+(:[0-9]+)?$ ]] || return 1
if [[ "$authority" == *:* ]]; then
host="${authority%:*}"
port="${authority##*:}"
[ -n "$host" ] || return 1
fi
fi
if [ -n "${port:-}" ]; then
[[ "$port" =~ ^[0-9]+$ ]] || return 1
[ "$port" -ge 1 ] && [ "$port" -le 65535 ] || return 1
fi
}
public_url_derive_from_env() {
local file="$1"
local api_url socket_url security_origin origin scheme combined
api_url="$(public_url_read_env_value "$file" "NUXT_PUBLIC_BASE_APIURL")"
if public_url_validate "$api_url"; then
public_url_normalize "$api_url"
return 0
fi
[[ "$api_url" == /* ]] || return 1
socket_url="$(public_url_read_env_value "$file" "NUXT_PUBLIC_BASE_SOCKETURL")"
if [[ "$socket_url" =~ ^(ws|wss)://([^/?#[:space:]]+) ]]; then
scheme="http"
[ "${BASH_REMATCH[1]}" = "wss" ] && scheme="https"
origin="${scheme}://${BASH_REMATCH[2]}"
fi
if [ -z "${origin:-}" ]; then
security_origin="$(public_url_read_env_value "$file" "CONFIG_SECURITY_ORIGIN")"
security_origin="${security_origin%%,*}"
security_origin="$(public_url_normalize "$security_origin")"
if [[ "$security_origin" =~ ^(https?://[^/]+) ]]; then
origin="${BASH_REMATCH[1]}"
fi
fi
[ -n "${origin:-}" ] || return 1
combined="$(public_url_normalize "${origin}${api_url}")"
public_url_validate "$combined" || return 1
printf '%s' "$combined"
}
init_public_api_base_url() {
local file="${1:-.env}"
local mode="${2:-upgrade}"
local override="${3:-${DEPLOY_PUBLIC_API_BASE_URL:-}}"
local current selected
if [ ! -f "$file" ]; then
echo "❌ 环境配置文件不存在: $file" >&2
return 1
fi
if [ "$mode" != "configure" ] && [ "$mode" != "upgrade" ]; then
echo "❌ 公开 API 地址初始化模式无效: $mode(仅支持 configure/upgrade)" >&2
return 1
fi
if [ -z "$override" ]; then
override="$(public_url_read_env_value "$file" "DEPLOY_PUBLIC_API_BASE_URL")"
fi
current="$(public_url_read_env_value "$file" "CONFIG_PUBLIC_API_BASE_URL")"
if [ -n "$override" ]; then
selected="$(public_url_normalize "$override")"
elif [ -n "$current" ]; then
if ! public_url_validate "$current"; then
echo "❌ CONFIG_PUBLIC_API_BASE_URL 配置无效: $current" >&2
return 1
fi
return 0
else
selected="$(public_url_derive_from_env "$file")" || {
echo "❌ 无法从现有前端配置推导 CONFIG_PUBLIC_API_BASE_URL" >&2
echo " 请设置完整地址,例如 https://example.com/api" >&2
return 1
}
fi
if ! public_url_validate "$selected"; then
echo "❌ 公开 API 地址配置无效: $selected" >&2
return 1
fi
selected="$(public_url_normalize "$selected")"
public_url_write_env_value "$file" "CONFIG_PUBLIC_API_BASE_URL" "$selected"
echo " ✓ 已配置公开 API 地址: $selected"
}
if [ "${BASH_SOURCE[0]}" = "$0" ]; then
init_public_api_base_url "${1:-.env}" "${2:-upgrade}" "${3:-}"
fi
-205
View File
@@ -1,205 +0,0 @@
#!/bin/bash
# 为本地 .env 初始化持久化安全密钥。只处理空值和已知历史默认值,
# 已存在的自定义值保持不变;数据库中的 JWT 轮转配置仍由后端优先使用。
generate_security_secret() {
if command -v openssl >/dev/null 2>&1; then
openssl rand -base64 48 | tr -d '\r\n'
return
fi
if [ -r /dev/urandom ]; then
od -An -N48 -tx1 /dev/urandom | tr -d ' \r\n'
return
fi
echo "❌ 无法生成安全随机值:需要 openssl 或 /dev/urandom" >&2
return 1
}
generate_initial_admin_password() {
if command -v openssl >/dev/null 2>&1; then
# 10 random bytes encoded as hexadecimal: exactly 20 alphanumeric characters.
openssl rand -hex 10 | tr -d '\r\n'
return
fi
if [ -r /dev/urandom ]; then
od -An -N10 -tx1 /dev/urandom | tr -d ' \r\n'
return
fi
echo "❌ 无法生成初始管理员密码:需要 openssl 或 /dev/urandom" >&2
return 1
}
read_env_value() {
local file="$1"
local key="$2"
local value
value="$(awk -v key="$key" 'index($0, key "=") == 1 { print substr($0, length(key) + 2); exit }' "$file")"
value="$(printf '%s' "$value" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
case "$value" in
\"*\") value="${value#\"}"; value="${value%\"}" ;;
\'*\') value="${value#\'}"; value="${value%\'}" ;;
esac
printf '%s' "$value"
}
write_env_value() {
local file="$1"
local key="$2"
local value="$3"
local tmp
tmp="$(mktemp "${file}.tmp.XXXXXX")"
awk -v key="$key" -v value="$value" '
BEGIN { replaced = 0 }
index($0, key "=") == 1 {
if (!replaced) {
print key "=" value
replaced = 1
}
next
}
{ print }
END {
if (!replaced) print key "=" value
}
' "$file" > "$tmp"
chmod 600 "$tmp"
mv "$tmp" "$file"
}
ensure_security_secret() {
local file="$1"
local key="$2"
local legacy_value="${3:-}"
local minimum_length="${4:-32}"
local current
current="$(read_env_value "$file" "$key")"
if [ "${#current}" -ge "$minimum_length" ] && { [ -z "$legacy_value" ] || [ "$current" != "$legacy_value" ]; }; then
return 0
fi
write_env_value "$file" "$key" "$(generate_security_secret)"
echo " ✓ 已初始化安全密钥: ${key}"
}
ensure_env_list_item() {
local file="$1"
local key="$2"
local required_item="$3"
local default_value="${4:-}"
local current normalized
current="$(read_env_value "$file" "$key")"
if [ -z "$current" ]; then
current="$default_value"
fi
normalized="$(printf '%s' "$current" | tr -d '[:space:]')"
case ",${normalized}," in
*",${required_item},"*) return 0 ;;
esac
if [ -n "$current" ]; then
current="${current},${required_item}"
else
current="$required_item"
fi
write_env_value "$file" "$key" "$current"
echo " ✓ 已补充安全配置项: ${key}"
}
ensure_initial_admin_password() {
local file="$1"
local current
current="$(read_env_value "$file" "CONFIG_INITIAL_ADMIN_PASSWORD")"
if [ "${#current}" -ge 12 ]; then
return 0
fi
write_env_value "$file" "CONFIG_INITIAL_ADMIN_PASSWORD" "$(generate_initial_admin_password)"
echo " ✓ 已初始化管理员密码: CONFIG_INITIAL_ADMIN_PASSWORD"
}
init_credential_encryption_env() {
local file="$1" mode="$2" active ring normalized entry id encoded decoded reencoded ids='' legacy
active="$(read_env_value "$file" CONFIG_CREDENTIAL_ENCRYPTION_ACTIVE_KEY_ID)"
ring="$(read_env_value "$file" CONFIG_CREDENTIAL_ENCRYPTION_KEYRING)"
legacy="$(read_env_value "$file" CONFIG_CREDENTIAL_ALLOW_LEGACY_PLAINTEXT)"
case "$legacy" in ''|true|false) ;; *) echo '❌ 旧凭据兼容开关只能为 true/false' >&2; return 1 ;; esac
if [ -z "$active" ] && [ -z "$ring" ]; then
if [ "$mode" != new ] && [ "$legacy" = false ]; then
echo '❌ 已关闭旧凭据兼容但缺少加密密钥,请恢复原配置' >&2
return 1
fi
# Existing sites opt in explicitly: never invent a replacement after key loss.
if [ "$mode" != new ] && [ "${DEPLOY_ENABLE_CREDENTIAL_ENCRYPTION:-false}" != true ]; then
if [ "$legacy" = false ]; then
echo '❌ 已关闭旧凭据兼容但缺少加密密钥,请恢复原配置' >&2
return 1
fi
echo ' ℹ 凭据加密尚未接入,保留旧部署兼容;首次接入使用 DEPLOY_ENABLE_CREDENTIAL_ENCRYPTION=true'
return 0
fi
command -v openssl >/dev/null 2>&1 || { echo '❌ 凭据加密初始化需要 openssl' >&2; return 1; }
active=v1
encoded="$(openssl rand -base64 32 | tr -d '\r\n')" || return 1
ring="{\"${active}\":\"${encoded}\"}"
write_env_value "$file" CONFIG_CREDENTIAL_ENCRYPTION_ACTIVE_KEY_ID "$active"
write_env_value "$file" CONFIG_CREDENTIAL_ENCRYPTION_KEYRING "'$ring'"
echo ' ✓ 已初始化独立凭据加密密钥(未输出密钥)'
fi
# Values have a restricted JSON grammar; no eval/source or secret command arguments.
normalized="$(printf '%s' "$ring" | tr -d ' \t\r\n')"
if ! [[ "$active" =~ ^[A-Za-z0-9_-]{1,64}$ ]] ||
! printf '%s' "$ring" | LC_ALL=C grep -Eq '^\{[[:space:]]*("[A-Za-z0-9_-]{1,64}"[[:space:]]*:[[:space:]]*"[A-Za-z0-9+/]{43}="[[:space:]]*,[[:space:]]*)*"[A-Za-z0-9_-]{1,64}"[[:space:]]*:[[:space:]]*"[A-Za-z0-9+/]{43}="[[:space:]]*\}$'; then
echo '❌ 凭据加密配置不完整或无效,请恢复原密钥;不会自动替换' >&2
return 1
fi
command -v openssl >/dev/null 2>&1 || { echo '❌ 凭据密钥校验需要 openssl' >&2; return 1; }
while IFS= read -r entry; do
id="${entry%%:*}"; id="${id#\"}"; id="${id%\"}"
encoded="${entry#*:}"; encoded="${encoded#\"}"; encoded="${encoded%\"}"
case ",$ids," in *",$id,"*) echo '❌ 凭据密钥 ID 重复' >&2; return 1 ;; esac
ids="${ids:+$ids,}$id"
decoded="$(printf '%s' "$encoded" | openssl base64 -d -A | wc -c | tr -d ' ')"
reencoded="$(printf '%s' "$encoded" | openssl base64 -d -A | openssl base64 -A)"
if [ "$decoded" != 32 ] || [ "$reencoded" != "$encoded" ]; then
echo '❌ 凭据密钥必须为标准 Base64 编码的 32 字节随机值' >&2; return 1
fi
done < <(printf '%s\n' "$normalized" | sed 's/^{//;s/}$//' | tr ',' '\n')
case ",$ids," in *",$active,"*) ;; *) echo '❌ 当前凭据密钥 ID 不在 keyring 中' >&2; return 1 ;; esac
if [ -z "$legacy" ]; then
if [ "$mode" = new ]; then legacy=false; else legacy=true; fi
write_env_value "$file" CONFIG_CREDENTIAL_ALLOW_LEGACY_PLAINTEXT "$legacy"
fi
case "$legacy" in true|false) ;; *) echo '❌ 旧凭据兼容开关只能为 true/false' >&2; return 1 ;; esac
}
init_security_env() {
local file="${1:-.env}"
local mode="${2:-upgrade}"
if [ ! -f "$file" ]; then
echo "❌ 环境配置文件不存在: $file" >&2
return 1
fi
if [ "$mode" != "new" ] && [ "$mode" != "upgrade" ]; then
echo "❌ 安全环境初始化模式无效: $mode(仅支持 new/upgrade)" >&2
return 1
fi
init_credential_encryption_env "$file" "$mode" || return 1
ensure_security_secret "$file" "CONFIG_JWT_SECRET" "this is a very secret secret"
ensure_security_secret "$file" "WS_AUTH_WS_TICKET_SECRET"
ensure_security_secret "$file" "SYSTEM_UPDATE_INTERNAL_TOKEN"
ensure_security_secret "$file" "WATCHTOWER_API_KEY"
ensure_security_secret "$file" "WATCHTOWER_HTTP_API_EVENTS_TOKEN"
ensure_security_secret "$file" "MODEL_CONVERSION_WORKER_TOKEN"
ensure_env_list_item "$file" "WS_AUTH_METHODS" "ws_ticket" "none,bearer"
if [ "$mode" = "new" ]; then
ensure_security_secret "$file" "CONFIG_SECURITY_CONFIG_ENCRYPTION_KEY"
ensure_security_secret "$file" "CONFIG_OTP_HASH_SECRET"
ensure_security_secret "$file" "CONFIG_AUDIT_HASH_PEPPER"
ensure_security_secret "$file" "CONFIG_AUDIT_INTEGRITY_KEY"
ensure_initial_admin_password "$file"
fi
chmod 600 "$file"
}
if [ "${BASH_SOURCE[0]}" = "$0" ]; then
init_security_env "${1:-.env}" "${2:-upgrade}"
fi
-68
View File
@@ -1,68 +0,0 @@
#!/usr/bin/env bash
# 浏览器来源为可选项:缺失或留空时保持宽松策略,显式配置时校验。
# 不 source .env,避免执行环境文件中的内容。
security_origin_script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=scripts/init-public-api-base-url.sh
. "${security_origin_script_dir}/init-public-api-base-url.sh"
security_origin_valid() {
local value rest
value="$(public_url_normalize "$1")"
public_url_validate "$value" || return 1
rest="${value#*://}"
[[ "$rest" != */* ]]
}
init_security_origin() {
local file="${1:-.env}"
local api_url origin current item scheme rest authority
[ -f "$file" ] || {
echo "❌ 环境配置文件不存在: $file" >&2
return 1
}
current="$(public_url_read_env_value "$file" "CONFIG_SECURITY_ORIGIN")"
if [ -z "$current" ]; then
echo " ✓ 未配置浏览器来源,后端将默认允许所有来源"
return 0
fi
# 旧版样例值不是用户选择的白名单;非本机部署升级时清空,恢复缺省策略。
if [ "$current" = 'http://127.0.0.1,http://localhost' ]; then
api_url="$(public_url_read_env_value "$file" "CONFIG_PUBLIC_API_BASE_URL")"
if public_url_validate "$api_url"; then
scheme="${api_url%%://*}"
rest="${api_url#*://}"
authority="${rest%%/*}"
origin="${scheme}://${authority}"
if [ "$origin" != 'http://127.0.0.1' ] && [ "$origin" != 'http://localhost' ]; then
public_url_write_env_value "$file" "CONFIG_SECURITY_ORIGIN" ""
echo " ✓ 已清理历史浏览器来源样例值,后端将默认允许所有来源"
return 0
fi
fi
fi
local -a origins
case "$current" in
,*|*,|*,,*)
echo "❌ CONFIG_SECURITY_ORIGIN 包含空的来源地址" >&2
return 1
;;
esac
IFS=',' read -r -a origins <<< "$current"
[ "${#origins[@]}" -gt 0 ] || return 1
for item in "${origins[@]}"; do
item="$(printf '%s' "$item" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
if ! security_origin_valid "$item"; then
echo "❌ CONFIG_SECURITY_ORIGIN 包含无效或非浏览器来源,请填写 http(s) Origin,不能使用通配符或路径" >&2
return 1
fi
done
}
if [ "${BASH_SOURCE[0]}" = "$0" ]; then
init_security_origin "${1:-.env}"
fi
-83
View File
@@ -1,83 +0,0 @@
#!/bin/bash
server_bind_read_env_value() {
local file="$1"
local key="$2"
awk -F= -v key="$key" '$1 == key { sub(/^[^=]*=/, ""); print; exit }' "$file" | tr -d '\r'
}
server_bind_write_env_value() {
local file="$1"
local key="$2"
local value="$3"
local tmp_file
tmp_file="$(mktemp "${file}.XXXXXX")"
awk -v key="$key" -v value="$value" '
BEGIN { replaced = 0 }
$0 ~ "^" key "=" {
if (!replaced) {
print key "=" value
replaced = 1
}
next
}
{ print }
END {
if (!replaced) print key "=" value
}
' "$file" > "$tmp_file"
chmod --reference="$file" "$tmp_file" 2>/dev/null || true
mv "$tmp_file" "$file"
}
infer_server_http_bind_ip() {
local file="$1"
local public_api_url
public_api_url="$(server_bind_read_env_value "$file" "NUXT_PUBLIC_BASE_APIURL")"
case "$public_api_url" in
/api|/api/*|http://127.0.0.1:*|https://127.0.0.1:*|http://localhost:*|https://localhost:*)
printf '%s\n' "127.0.0.1"
;;
http://*|https://*)
printf '%s\n' "0.0.0.0"
;;
*)
printf '%s\n' "127.0.0.1"
;;
esac
}
init_server_http_bind_ip() {
local file="${1:-.env}"
local override="${2:-}"
local current target
if [ ! -f "$file" ]; then
echo "❌ 未找到环境文件: $file" >&2
return 1
fi
current="$(server_bind_read_env_value "$file" "SERVER_HTTP_BIND_IP")"
target="${override:-$current}"
if [ -z "$target" ]; then
target="$(infer_server_http_bind_ip "$file")"
fi
case "$target" in
127.0.0.1|0.0.0.0) ;;
*)
echo "❌ SERVER_HTTP_BIND_IP 仅支持 127.0.0.1 或 0.0.0.0,当前为: $target" >&2
return 1
;;
esac
if [ "$current" != "$target" ]; then
server_bind_write_env_value "$file" "SERVER_HTTP_BIND_IP" "$target"
echo " ✓ SERVER_HTTP_BIND_IP=$target"
fi
}
if [ "${BASH_SOURCE[0]}" = "$0" ]; then
init_server_http_bind_ip "${1:-.env}" "${2:-}"
fi
@@ -1,57 +0,0 @@
#!/bin/bash
set -euo pipefail
PROJECT_ROOT="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
PROJECT_ROOT="$(cd "$PROJECT_ROOT" && pwd)"
. "$PROJECT_ROOT/scripts/init-security-env.sh"
# A path is shell-quoted and percent-escaped before embedding it in crontab.
[[ "$PROJECT_ROOT" != *$'\n'* ]] || { echo '部署目录不能包含换行'; exit 1; }
task_id="$(printf '%s' "$PROJECT_ROOT" | cksum | awk '{print $1}')"
marker="# easyai-infrastructure-update-$task_id"
job="$(printf '%q' "$PROJECT_ROOT/scripts/update-infrastructure.sh")"
job="${job//%/\\%}"
entry="17 */6 * * * PATH=/usr/local/bin:/opt/homebrew/bin:/usr/sbin:/usr/bin:/sbin:/bin /bin/bash $job >/dev/null 2>&1 $marker"
if [ "${2:-}" = --dry-run ]; then
if [ "$(read_env_value "$PROJECT_ROOT/.env" INFRASTRUCTURE_AUTO_UPDATE_ENABLED)" = false ]; then echo "更新组件定时任务(dry-run):已关闭"; exit 0; fi
printf 'Watchtower 和 updater 定时任务(dry-run):%s\n' "$entry"
exit 0
fi
cron() {
if [ "$(id -u)" -eq 0 ]; then crontab "$@"; else sudo crontab "$@"; fi
}
if ! command -v crontab >/dev/null; then
[ "$(read_env_value "$PROJECT_ROOT/.env" INFRASTRUCTURE_AUTO_UPDATE_ENABLED)" != false ] || exit 0
if command -v apt-get >/dev/null; then
sudo apt-get update -qq
sudo apt-get install -y cron
else
echo '缺少 crontab,无法安装更新组件定时任务' >&2
exit 1
fi
fi
if [ "$(uname -s)" = Linux ]; then
if [ -d /run/systemd/system ]; then
sudo systemctl enable --now cron
elif command -v service >/dev/null; then
sudo service cron start
fi
fi
error_file="$(mktemp)"
trap 'rm -f "$error_file"' EXIT
if ! current="$(LC_ALL=C cron -l 2>"$error_file")"; then
if ! grep -qi 'no crontab for' "$error_file"; then
echo '无法读取现有定时任务,未修改任务配置' >&2
exit 1
fi
current=""
fi
# Keep all unrelated tasks and replace this deployment's single entry.
next="$(printf '%s\n' "$current" | awk -v marker="$marker" 'substr($0, length($0)-length(marker)+1) != marker')"
if [ "$(read_env_value "$PROJECT_ROOT/.env" INFRASTRUCTURE_AUTO_UPDATE_ENABLED)" != false ]; then
next="${next:+$next$'\n'}$entry"
fi
printf '%s\n' "$next" | cron -
if [ "$(read_env_value "$PROJECT_ROOT/.env" INFRASTRUCTURE_AUTO_UPDATE_ENABLED)" = false ]; then
echo '已移除本部署的更新组件定时任务'
else
echo '已配置 Watchtower 和 updater 定时任务:每六小时检查一次'
fi
-21
View File
@@ -1,21 +0,0 @@
#!/bin/bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "${SCRIPT_DIR}/.."
mode="${1:-inventory}"
expected="${2:-}"
case "$mode" in inventory|dry-run|apply|verify|finalize) ;; *) echo '用法: scripts/migrate-credential-encryption.sh inventory|dry-run|apply|verify|finalize 主机/数据库' >&2; exit 1 ;; esac
[ -n "$expected" ] || { echo '需要明确指定容器内 MongoDB 主机/数据库,例如 172.21.0.3/aidraw' >&2; exit 1; }
[ -f .env ] || { echo '未找到部署 .env' >&2; exit 1; }
# shellcheck source=scripts/init-security-env.sh
. "$SCRIPT_DIR/init-security-env.sh"
init_credential_encryption_env .env upgrade
[ -n "$(read_env_value .env CONFIG_CREDENTIAL_ENCRYPTION_KEYRING)" ] || { echo '请先接入独立加密密钥,完成备份并暂停相关写入/消费者' >&2; exit 1; }
mkdir -p data/credential-migration
chmod 700 data/credential-migration
compose=(docker compose)
if ! docker compose version >/dev/null 2>&1; then compose=(docker-compose); fi
args=("--mode=$mode" --production=true "--expected-target=$expected" --batch-size=100 --checkpoint=/credential-migration/checkpoint.json)
if [ "$mode" = finalize ]; then args=(--mode=verify --finalize=true --production=true "--expected-target=$expected" --batch-size=100); fi
# Credentials come exclusively from the existing service environment.
"${compose[@]}" run --rm --no-deps -v "$PWD/data/credential-migration:/credential-migration" easyai-server node /app/dist/credential-migrate.js "${args[@]}"
-105
View File
@@ -1,105 +0,0 @@
#!/usr/bin/env python3
"""Prepare one published release record from already pushed immutable images."""
import argparse
import hashlib
import json
import os
from pathlib import Path
import re
import subprocess
import tempfile
from datetime import datetime, timezone
IMAGES = {
'server-main': 'registry.cn-shanghai.aliyuncs.com/comfy-ai/comfy-server',
'ws-gateway': 'registry.cn-shanghai.aliyuncs.com/easyaigc/wsgateway',
'web': 'registry.cn-shanghai.aliyuncs.com/comfy-ai/one-ai',
}
POLICY = ('managedUpdateAllowed', 'scheduledUpdateAllowed', 'rollbackSafe',
'minimumUpdaterVersion', 'minimumDeploymentSchemaVersion', 'requiredFreeBytes')
def inspect_json(reference, template, run=subprocess.check_output):
args = ['docker', 'buildx', 'imagetools', 'inspect', reference]
args += ['--format', template] if template else ['--raw']
return json.loads(run(args, text=True, timeout=90))
def resolve_component(key, reference, version=None):
image = IMAGES[key]
if not reference.startswith(image + ':') and not reference.startswith(image + '@sha256:'):
raise ValueError('Unexpected image for ' + key)
manifest = inspect_json(reference, None)
# Pin a platform manifest; refuse mixed platform indexes rather than guessing a digest.
if manifest.get('manifests'):
matching = [m for m in manifest['manifests'] if m.get('platform', {}).get('os') == 'linux'
and m.get('platform', {}).get('architecture') == 'amd64']
if len(matching) != 1:
raise ValueError('Missing unique linux/amd64 image for ' + key)
digest = matching[0]['digest']
else:
# The formatted descriptor is resolved by buildx, not a local cached image ID.
descriptor = inspect_json(reference, '{{json .Manifest}}')
digest = descriptor['digest']
pinned = image + '@' + digest
metadata = inspect_json(pinned, '{{json .Image}}')
if metadata.get('os') != 'linux' or metadata.get('architecture') != 'amd64':
raise ValueError('Invalid platform for ' + key)
labels = metadata.get('config', {}).get('Labels', {})
if version and labels.get('org.opencontainers.image.version') != version:
raise ValueError('Published image version differs from release: ' + key)
resolved_manifest = inspect_json(pinned, None)
return {'key': key, 'image': image, 'digest': digest,
'buildId': labels.get('org.opencontainers.image.revision', 'retained-formal-image'),
'imageSizeBytes': sum(layer['size'] for layer in resolved_manifest['layers'])}
def prepare(version, policy, components, now=None):
if not re.fullmatch(r'\d+\.\d+\.\d+\.\d{8}(?:\.\d+)?', version):
raise ValueError('Invalid product version')
if {c['key'] for c in components} != set(IMAGES) or len(components) != 3:
raise ValueError('A release requires all three components')
for key in POLICY[:3]:
if type(policy.get(key)) is not bool:
raise ValueError('Explicit update policy required: ' + key)
identity = hashlib.sha256(json.dumps(components, sort_keys=True).encode()).hexdigest()[:12]
return {'schemaVersion': 1, 'channel': 'stable', 'releaseId': version + '-' + identity,
'productVersion': version, 'platform': 'linux/amd64',
'publishedAt': now or datetime.now(timezone.utc).isoformat(),
**{key: policy[key] for key in POLICY if key in policy}, 'components': components}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--version', required=True)
parser.add_argument('--policy', type=Path, required=True,
help='Reviewed release policy; never infer rollback safety from image labels')
parser.add_argument('--gateway-ref', help='Retain the last released gateway digest when unchanged')
parser.add_argument('--output', type=Path, required=True)
args = parser.parse_args()
policy = json.loads(args.policy.read_text())
gateway = args.gateway_ref
if not gateway:
retained = next(c for c in policy['components'] if c['key'] == 'ws-gateway')
gateway = retained['image'] + '@' + retained['digest']
components = [resolve_component('server-main', IMAGES['server-main'] + ':' + args.version, args.version),
resolve_component('ws-gateway', gateway),
resolve_component('web', IMAGES['web'] + ':' + args.version, args.version)]
release = prepare(args.version, policy, components)
args.output.parent.mkdir(parents=True, exist_ok=True)
# No partial or one-component release becomes visible if inspection fails.
handle, name = tempfile.mkstemp(dir=args.output.parent, prefix='.release-')
try:
with os.fdopen(handle, 'w') as stream:
json.dump(release, stream, indent=2)
stream.write('\n')
os.replace(name, args.output)
finally:
if os.path.exists(name):
os.unlink(name)
print(json.dumps({'productVersion': release['productVersion'], 'releaseId': release['releaseId'],
'output': str(args.output), 'published': False}))
if __name__ == '__main__':
main()
-231
View File
@@ -1,231 +0,0 @@
#!/usr/bin/env python3
"""Adopt reviewed legacy deployment edits into a local Compose override.
Only deployment configuration is accepted. Business code and arbitrary untracked
files remain untouched. Backups contain private settings and use mode 0700/0600.
"""
import copy
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import time
try:
import yaml
except ImportError:
sys.exit('❌ 迁移旧部署配置需要 Python PyYAML;尚未修改配置或容器')
root = Path(sys.argv[1]).resolve()
os.chdir(root)
def git(*args):
return subprocess.check_output(['git', *args], text=True)
upstream = git('rev-parse', '--abbrev-ref', '@{upstream}').strip()
subprocess.run(['git', 'fetch', '--quiet', upstream.split('/')[0]], check=True)
subprocess.run(['git', 'merge-base', '--is-ancestor', 'HEAD', upstream], check=True)
restore_images = set(os.environ.get('UPDATE_RESTORE_REPOSITORY_IMAGES', '').split())
selected_services = set(os.environ.get('UPDATE_SERVICES', '').split())
if restore_images - {'easyai-server', 'easyai-web', 'ws-gateway'} or restore_images - selected_services:
sys.exit('❌ 恢复仓库镜像只支持本次 UPDATE_SERVICES 中的核心服务;尚未修改配置或容器')
changed = set(git('diff', 'HEAD', '--name-only').splitlines())
if changed - {'docker-compose.yml'}:
sys.exit('❌ 存在部署配置以外的本地代码修改;尚未修改配置或容器')
tracked = set(git('ls-tree', '-r', '--name-only', upstream).splitlines())
untracked = set(git('ls-files', '--others', '--exclude-standard').splitlines())
collisions = tracked & untracked
allowed = {'scripts/init-security-env.sh'}
if any(p not in allowed and not (Path(p).parent == Path('.') and p.endswith('.conf')) for p in collisions):
sys.exit('❌ 存在新版仓库会覆盖的其他本地文件;尚未修改配置或容器')
if not changed and not collisions:
existing_override_path = root / 'data/deployment-config/legacy-compose.json'
restore_existing_pin = False
if restore_images and existing_override_path.exists():
if existing_override_path.is_symlink() or not existing_override_path.is_file():
sys.exit('❌ 旧配置覆盖文件类型不受支持;尚未修改配置或容器')
existing_override = json.loads(existing_override_path.read_text())
restore_existing_pin = any('image' in existing_override.get('services', {}).get(service, {}) for service in restore_images)
if not restore_existing_pin:
sys.exit(0)
for name in changed | collisions:
if not (root / name).is_file() or (root / name).is_symlink():
sys.exit('❌ 配置文件类型不受支持;尚未修改配置或容器')
MISSING = object()
def clone(value):
return MISSING if value is MISSING else copy.deepcopy(value)
def volume_target(value):
# Ignore colons inside ${VARIABLE:-default} when identifying the mount target.
depth = 0
separators = []
for i, char in enumerate(value):
if char == '{': depth += 1
elif char == '}': depth -= 1
elif char == ':' and depth == 0: separators.append(i)
if not separators: return value
end = separators[1] if len(separators) > 1 else len(value)
return value[separators[0] + 1:end]
def canonical(value, key=''):
if isinstance(value, dict):
return {k: canonical(v, k) for k, v in value.items()}
if isinstance(value, list):
if key in ('environment', 'labels'):
return dict((v.partition('=')[0], v.partition('=')[2] if '=' in v else None) for v in value)
if key == 'networks': return {v: {} for v in value}
if key == 'volumes' and all(isinstance(v, str) for v in value):
return {volume_target(v): v for v in value}
return value
preserved = []
def merge(base, local, remote, path=()):
if local == base: return clone(remote)
if local == remote: return clone(remote)
if isinstance(local, dict) and isinstance(remote, dict) and (isinstance(base, dict) or base is MISSING):
old = base if isinstance(base, dict) else {}
result = {}
for key in sorted(set(old) | set(local) | set(remote)):
value = merge(old.get(key, MISSING), local.get(key, MISSING), remote.get(key, MISSING), path + (key,))
if value is not MISSING: result[key] = value
return result
if local is MISSING and base is MISSING: return clone(remote)
if local is MISSING: return MISSING
if remote is MISSING and base is not MISSING:
sys.exit('❌ 新版删除了仍被本地修改的字段,无法安全合并;尚未修改配置或容器')
preserved.append('.'.join(path))
return clone(local)
def delta(remote, merged, path=()):
if remote == merged: return MISSING
if isinstance(remote, dict) and isinstance(merged, dict):
result = {}
for key in merged:
value = delta(remote.get(key, MISSING), merged[key], path + (key,))
if value is not MISSING: result[key] = value
# Removal cannot be safely represented by an ordinary Compose override.
if set(remote) - set(merged):
fields = ', '.join('.'.join(path + (key,)) for key in sorted(set(remote) - set(merged)))
sys.exit('❌ 本地配置删除了新版字段,无法安全合并;尚未修改配置或容器: ' + fields)
return result
return copy.deepcopy(merged)
def serialize(value, key=''):
if isinstance(value, dict):
if key == 'volumes' and value and all(isinstance(v, str) for v in value.values()): return list(value.values())
return {k: serialize(v, k) for k, v in value.items()}
return value
base = canonical(yaml.safe_load(git('show', 'HEAD:docker-compose.yml')))
local = canonical(yaml.safe_load((root / 'docker-compose.yml').read_text()))
remote_text = git('show', upstream + ':docker-compose.yml')
remote = canonical(yaml.safe_load(remote_text))
# Explicitly release reviewed local image pins through the deployment project.
# Other local settings and pins on services outside this update remain intact.
for service in sorted(restore_images):
remote_service = remote.get('services', {}).get(service, {})
local_service = local.get('services', {}).get(service, {})
if not isinstance(remote_service.get('image'), str) or not isinstance(local_service, dict) or not local_service:
sys.exit('❌ 无法读取目标服务的仓库镜像定义;尚未修改配置或容器')
local_service['image'] = remote_service['image']
# Early managed deployments removed Watchtower's old token while disabling its
# HTTP trigger. Current deployments require a shared internal token; restore the
# upstream reference, whose value is initialized by update.sh, rather than
# preserving an unauthenticated monitor. Other removals still stop adoption.
monitor = local.get('services', {}).get('watchtower', {}).get('environment', {})
old_monitor = base.get('services', {}).get('watchtower', {}).get('environment', {})
new_monitor = remote.get('services', {}).get('watchtower', {}).get('environment', {})
token_key = 'WATCHTOWER_HTTP_API_TOKEN'
if token_key in old_monitor and token_key not in monitor and token_key in new_monitor:
monitor = local['services']['watchtower'].setdefault('environment', {})
monitor[token_key] = new_monitor[token_key]
merged = merge(base, local, remote)
override = delta(remote, merged)
config_dir = root / 'data/deployment-config'
override_path = config_dir / 'legacy-compose.json'
def apply_override(original, additional):
if isinstance(original, dict) and isinstance(additional, dict):
result = copy.deepcopy(original)
for key, value in additional.items():
result[key] = apply_override(result.get(key, MISSING), value)
return result
return copy.deepcopy(additional)
# Repeated adoption must preserve already migrated settings. A newly reviewed
# main-file difference has the same precedence as the old main file; only
# explicitly restored target images are released back to the repository.
if override_path.exists():
if override_path.is_symlink() or not override_path.is_file():
sys.exit('❌ 旧配置覆盖文件类型不受支持;尚未修改配置或容器')
previous_override = canonical(json.loads(override_path.read_text()))
for service in restore_images:
previous_override.get('services', {}).get(service, {}).pop('image', None)
override = apply_override(previous_override, {} if override is MISSING else override)
merged = apply_override(remote, override)
with tempfile.NamedTemporaryFile(mode='w', dir=root, prefix='.update-compose-', suffix='.json', delete=False) as candidate:
json.dump(serialize(merged), candidate)
candidate_path = Path(candidate.name)
try:
candidate_path.chmod(0o600)
compose = ['docker', 'compose'] if shutil.which('docker') else ['docker-compose']
validation = subprocess.run(compose + ['--env-file', '.env', '-f', str(candidate_path), 'config', '--quiet'], capture_output=True, text=True)
if validation.returncode:
sys.exit('❌ 合并后的部署配置未通过校验;尚未修改配置或容器')
finally:
candidate_path.unlink()
backup_root = root / 'data/deployment-backups'
backup_root.mkdir(parents=True, exist_ok=True, mode=0o700)
backup = Path(tempfile.mkdtemp(prefix='legacy-update-' + time.strftime('%Y%m%d-%H%M%S') + '-', dir=backup_root))
backup.chmod(0o700)
backup_files = changed | collisions | {'.env'}
if override_path.exists(): backup_files.add(str(override_path.relative_to(root)))
for name in backup_files:
destination = backup / name
destination.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
shutil.copy2(root / name, destination)
destination.chmod(0o600)
config_dir = root / 'data/deployment-config'
config_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
override_path = config_dir / 'legacy-compose.json'
if override is not MISSING and override:
override_path.write_text(json.dumps(serialize(override), indent=2) + '\n')
override_path.chmod(0o600)
env_path = root / '.env'
lines = env_path.read_text().splitlines()
existing = next((line.partition('=')[2].strip().strip('"').strip("'") for line in lines if line.startswith('COMPOSE_FILE=')), '')
files = existing.split(os.pathsep) if existing else ['docker-compose.yml']
relative = str(override_path.relative_to(root))
if relative not in files: files.append(relative)
lines = [line for line in lines if not line.startswith('COMPOSE_FILE=')]
lines.append('COMPOSE_FILE=' + os.pathsep.join(files))
env_path.write_text('\n'.join(lines) + '\n')
env_path.chmod(0o600)
for name in collisions:
(root / name).unlink()
if changed:
subprocess.run(['git', 'restore', '--source=HEAD', '--staged', '--worktree', '--', 'docker-compose.yml'], check=True)
(backup / 'migration.json').write_text(json.dumps({'from': git('rev-parse', 'HEAD').strip(), 'to': git('rev-parse', upstream).strip(), 'preservedFields': preserved, 'restoredRepositoryImages': sorted(restore_images), 'files': sorted(backup_files)}, indent=2))
(backup / 'migration.json').chmod(0o600)
print('✅ 已备份旧配置并迁移本地差异;新版部署字段由仓库继续管理')
print(' 备份目录: ' + str(backup))
if restore_images:
print(' 已恢复仓库镜像定义: ' + ', '.join(sorted(restore_images)))
-176
View File
@@ -1,176 +0,0 @@
#!/usr/bin/env node
import { execFileSync } from 'node:child_process';
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { resolve, join } from 'node:path';
import { pathToFileURL } from 'node:url';
export const DEFAULT_REPOSITORIES = [
{ name: 'backend', label: '后端', path: join(homedir(), 'projects/easyai-backend'), targetBranch: 'main' },
{ name: 'web', label: '前端', path: join(homedir(), 'projects/easyai-main-web'), targetBranch: 'master' },
{ name: 'doc', label: '文档', path: join(homedir(), 'projects/easyai-doc'), targetBranch: 'master' },
{ name: 'deploy', label: '部署', path: join(homedir(), 'projects/easyai'), targetBranch: 'main' },
];
function git(path, ...args) {
return execFileSync('git', args, {
cwd: path, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, timeout: 120_000,
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GCM_INTERACTIVE: 'never' },
}).trim();
}
function errorMessage(error) {
return String(error.stderr || error.message).trim().replace(/https?:\/\/[^\s/@]+@/g, 'https://[redacted]@');
}
function commits(path, range, filter) {
const output = git(path, 'log', filter, '--format=%H%x00%cI%x00%an%x00%s', range);
return output ? output.split('\n').map(line => {
const [sha, committedAt, author, subject] = line.split('\0');
return { sha, shortSha: sha.slice(0, 8), committedAt, author, subject };
}) : [];
}
export function inspectRepository(repository, { fetch = false } = {}) {
const remote = repository.remote || 'origin';
const targetRef = `refs/remotes/${remote}/${repository.targetBranch}`;
const result = { ...repository, remote, targetRef, branches: [], errors: [], status: 'complete' };
try {
if (fetch) git(repository.path, 'fetch', '--prune', remote, `+refs/heads/*:refs/remotes/${remote}/*`);
result.targetSha = git(repository.path, 'rev-parse', '--verify', `${targetRef}^{commit}`);
const output = git(repository.path, 'for-each-ref', '--format=%(refname)%00%(objectname)%00%(symref)', `refs/remotes/${remote}/`);
const cache = new Map();
const knownEquivalent = new Set();
const mergeBases = new Map();
const isolatedEquivalence = new Map();
const mergeBase = sha => {
if (!mergeBases.has(sha)) {
try { mergeBases.set(sha, git(repository.path, 'merge-base', result.targetSha, sha)); }
catch (error) { if (error.status === 1 && !String(error.stderr || '').trim()) mergeBases.set(sha, null); else throw error; }
}
return mergeBases.get(sha);
};
for (const line of output.split('\n').filter(Boolean)) {
const [ref, sha, symbolicRef] = line.split('\0');
if (symbolicRef || ref === targetRef) continue;
const branch = { name: ref.slice(`refs/remotes/${remote}/`.length), ref, sha };
try {
let comparison = cache.get(sha);
if (!comparison) {
// Pin the inventory SHAs so another fetch cannot change this comparison midway.
const range = `${result.targetSha}..${sha}`;
const ahead = Number(git(repository.path, 'rev-list', '--count', range));
comparison = { ahead, unmergedCommits: [], equivalentCommits: [], mergeCommits: [] };
if (ahead > 0) {
const cherry = git(repository.path, 'cherry', result.targetSha, sha);
const equivalence = new Map(cherry.split('\n').filter(Boolean).map(line => [line.slice(2), line[0]]));
comparison.mergeCommits = commits(repository.path, range, '--merges');
for (const commit of commits(repository.path, range, '--no-merges')) {
if (equivalence.get(commit.sha) === '-') knownEquivalent.add(commit.sha);
// If the branch merged the target, Git's merge base can hide older
// upstream patches. Compare the individual incoming commit too.
if (comparison.mergeCommits.length && !knownEquivalent.has(commit.sha) && mergeBase(sha) !== mergeBase(commit.sha)) {
if (!isolatedEquivalence.has(commit.sha)) {
const isolated = git(repository.path, 'cherry', result.targetSha, commit.sha);
isolatedEquivalence.set(commit.sha, isolated.split('\n').includes('- ' + commit.sha));
}
if (isolatedEquivalence.get(commit.sha)) knownEquivalent.add(commit.sha);
}
// A changed SHA alone does not mean the change is absent from the target branch.
const list = knownEquivalent.has(commit.sha) ? comparison.equivalentCommits : comparison.unmergedCommits;
list.push(commit);
}
}
comparison.status = comparison.unmergedCommits.length || comparison.mergeCommits.length
? 'needs-review' : comparison.equivalentCommits.length ? 'equivalent-integrated' : 'merged';
cache.set(sha, comparison);
}
result.branches.push({ ...branch, ...comparison });
} catch (error) {
result.errors.push({ branch: branch.name, message: errorMessage(error) });
}
}
} catch (error) {
result.errors.push({ message: errorMessage(error) });
}
if (result.errors.length) result.status = 'incomplete';
result.branches.sort((a, b) => b.unmergedCommits.length - a.unmergedCommits.length || a.name.localeCompare(b.name));
result.summary = {
checkedBranches: result.branches.length,
branchesNeedingReview: result.branches.filter(x => x.status === 'needs-review').length,
uniqueUnmergedCommits: new Set(result.branches.flatMap(x => x.unmergedCommits.map(c => c.sha))).size,
uniqueMergeCommits: new Set(result.branches.flatMap(x => x.mergeCommits.map(c => c.sha))).size,
equivalentOnlyBranches: result.branches.filter(x => x.status === 'equivalent-integrated').length,
};
return result;
}
export function createReport(repositories = DEFAULT_REPOSITORIES, options = {}) {
const results = repositories.map(repository => inspectRepository(repository, options));
return {
schemaVersion: 1, checkedAt: new Date().toISOString(), timezone: 'Asia/Shanghai',
fetchedByScript: Boolean(options.fetch), status: results.every(x => x.status === 'complete') ? 'complete' : 'incomplete',
repositories: results,
};
}
function markdown(value) { return String(value).replace(/[\\`*_[\]<>|]/g, '\\$&').replace(/[\r\n]/g, ' '); }
export function renderMarkdown(report, limit = 10) {
const time = new Date(report.checkedAt).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false });
const lines = ['# 每日分支合入检查', '', `检查时间:${time}(Asia/Shanghai)。`, '',
'检查四库 origin 远端分支。未合入不代表未上线;补丁等价的提交单独标记,合并提交单独列为待核对。squash 或重写后无法匹配的提交仍需按实际代码核对。此报告不自动合并或发布支线内容。', '',
'| 仓库 | 发布主分支 | 已检查分支 | 待核对分支 | 未合入普通提交(去重) | 待核对合并提交(去重) | 状态 |',
'| --- | --- | ---: | ---: | ---: | ---: | --- |'];
for (const repo of report.repositories) {
const s = repo.summary;
lines.push(`| ${markdown(repo.label || repo.name)} | ${markdown(repo.remote + '/' + repo.targetBranch)} | ${s.checkedBranches} | ${s.branchesNeedingReview} | ${s.uniqueUnmergedCommits} | ${s.uniqueMergeCommits} | ${repo.status === 'complete' ? '完成' : '检查不完整'} |`);
}
for (const repo of report.repositories) {
lines.push('', `## ${markdown(repo.label || repo.name)}`, '');
if (repo.errors.length) for (const error of repo.errors) lines.push(`- 检查失败${error.branch ? '(' + markdown(error.branch) + ')' : ''}:${markdown(error.message)}`);
const pending = repo.branches.filter(x => x.status === 'needs-review');
if (!pending.length && !repo.errors.length) lines.push('其他远端分支无未合入改动。');
for (const branch of pending) {
lines.push(`- **${markdown(branch.name)}**:${branch.unmergedCommits.length} 个未合入普通提交;${branch.equivalentCommits.length} 个补丁等价提交已集成;${branch.mergeCommits.length} 个合并提交待核对。`);
for (const commit of branch.unmergedCommits.slice(0, limit)) lines.push(` - \`${commit.shortSha}\` ${markdown(commit.subject)}(${markdown(commit.author)},${commit.committedAt})`);
for (const commit of branch.mergeCommits.slice(0, limit)) lines.push(` - 合并提交 \`${commit.shortSha}\` ${markdown(commit.subject)}`);
if (branch.unmergedCommits.length > limit || branch.mergeCommits.length > limit) lines.push(' - 其余提交见同目录 unmerged-branches.json。');
}
const equivalent = repo.branches.filter(x => x.status === 'equivalent-integrated');
if (equivalent.length) lines.push('', '仅 SHA/历史不同、补丁已等价集成的分支:' + equivalent.map(x => markdown(x.name)).join('、') + '。');
}
return lines.join('\n') + '\n';
}
function main() {
const args = process.argv.slice(2);
let outputDir, config, fetch = false;
for (let i = 0; i < args.length; i++) {
if (args[i] === '--fetch') fetch = true;
else if (args[i] === '--output-dir' || args[i] === '--config') {
const option = args[i], value = args[++i];
if (!value || value.startsWith('--')) throw new Error(`${option} 缺少参数`);
if (option === '--output-dir') outputDir = resolve(value); else config = value;
} else if (args[i] === '--help') {
console.log('node report-unmerged-branches.mjs [--fetch] [--output-dir 目录] [--config 仓库配置.json]\n默认只读已 fetch 的远端 refs;--fetch 同步四库全部 origin 分支并清除失效远端 refs。配置为包含 name、path、targetBranch 的数组。');
return;
} else throw new Error(`未知参数:${args[i]}`);
}
const repositories = config ? JSON.parse(readFileSync(config, 'utf8')) : DEFAULT_REPOSITORIES;
if (!Array.isArray(repositories) || !repositories.length || repositories.some(x => !x.name || !x.path || !x.targetBranch)) throw new Error('仓库配置必须是非空数组,包含 name、path、targetBranch');
const report = createReport(repositories, { fetch });
const text = renderMarkdown(report);
if (outputDir) {
mkdirSync(outputDir, { recursive: true, mode: 0o700 });
writeFileSync(join(outputDir, 'unmerged-branches.json'), JSON.stringify(report, null, 2) + '\n', { mode: 0o600 });
writeFileSync(join(outputDir, 'unmerged-branches.md'), text, { mode: 0o600 });
}
console.log(text);
if (report.status !== 'complete') process.exitCode = 1;
}
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
try { main(); } catch (error) { console.error(errorMessage(error)); process.exitCode = 1; }
}
-99
View File
@@ -1,99 +0,0 @@
import assert from 'node:assert/strict';
import { execFileSync, spawnSync } from 'node:child_process';
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
import { DEFAULT_REPOSITORIES, createReport, inspectRepository, renderMarkdown } from './report-unmerged-branches.mjs';
function fixture(t) {
const path = mkdtempSync(join(tmpdir(), 'easyai-branch-report-'));
t.after(() => rmSync(path, { recursive: true, force: true }));
const git = (...args) => execFileSync('git', args, { cwd: path, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
git('init', '-b', 'master');
git('config', 'user.name', '分支验收');
git('config', 'user.email', 'branch-test@example.invalid');
const commit = (file, contents, subject) => {
writeFileSync(join(path, file), contents); git('add', file); git('commit', '-m', subject);
return git('rev-parse', 'HEAD');
};
const base = commit('base.txt', 'base\n', 'chore: 基线');
git('checkout', '-b', 'equivalent');
const original = commit('equivalent.txt', 'integrated\n', 'fix: 原始修复');
git('checkout', 'master'); git('cherry-pick', '--no-commit', original); git('commit', '-m', 'fix: 等价集成修复');
const target = git('rev-parse', 'HEAD');
assert.notEqual(original, target);
git('update-ref', 'refs/remotes/origin/master', target);
git('update-ref', 'refs/remotes/origin/equivalent', original);
git('update-ref', 'refs/remotes/origin/merged', base);
git('symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/master');
git('checkout', '-b', 'feature');
const unmerged = commit('feature.txt', 'new\n', 'feat: 未合入能力');
git('update-ref', 'refs/remotes/origin/feature', unmerged);
return { path, git, original, target, unmerged, repository: { name: 'web', label: '前端', path, targetBranch: 'master' } };
}
test('默认前端发布和分支检查均以 master 为主分支', () => {
assert.equal(DEFAULT_REPOSITORIES.find(x => x.name === 'web').targetBranch, 'master');
});
test('报告真实未合入提交,排除已合入历史和 cherry-pick 等价补丁,且不改工作区', t => {
const f = fixture(t);
writeFileSync(join(f.path, 'base.txt'), '用户未提交编辑\n');
const before = f.git('status', '--porcelain');
const result = inspectRepository(f.repository);
assert.equal(result.status, 'complete');
assert.equal(result.summary.checkedBranches, 3);
assert.equal(result.summary.uniqueUnmergedCommits, 1);
assert.equal(result.branches.find(x => x.name === 'feature').unmergedCommits[0].sha, f.unmerged);
assert.equal(result.branches.find(x => x.name === 'equivalent').status, 'equivalent-integrated');
assert.equal(result.branches.find(x => x.name === 'equivalent').equivalentCommits[0].sha, f.original);
assert.equal(result.branches.find(x => x.name === 'merged').status, 'merged');
assert.equal(f.git('status', '--porcelain'), before);
assert.equal(f.git('rev-parse', 'HEAD'), f.unmerged);
});
test('相同提交位于多个分支时,仓库提交总数去重并保留各分支', t => {
const f = fixture(t);
f.git('update-ref', 'refs/remotes/origin/feature-copy', f.unmerged);
const result = inspectRepository(f.repository);
assert.equal(result.summary.branchesNeedingReview, 2);
assert.equal(result.summary.uniqueUnmergedCommits, 1);
});
test('仅合并提交尚未进入主分支也标为待核对,不冒充普通功能提交', t => {
const f = fixture(t);
const tree = f.git('rev-parse', f.target + '^{tree}');
const merge = f.git('commit-tree', tree, '-p', f.target, '-p', f.original, '-m', 'chore: 合并历史待核对');
f.git('update-ref', 'refs/remotes/origin/integration', merge);
const result = inspectRepository(f.repository);
const branch = result.branches.find(x => x.name === 'integration');
assert.equal(branch.status, 'needs-review');
assert.equal(branch.unmergedCommits.length, 0);
assert.equal(branch.mergeCommits[0].sha, merge);
assert.match(renderMarkdown(createReport([f.repository])), /合并提交.*合并历史待核对/);
});
test('主分支缺失时明确检查失败,CLI 保存证据且返回非零', t => {
const f = fixture(t);
const repository = { ...f.repository, targetBranch: 'missing' };
const result = createReport([repository]);
assert.equal(result.status, 'incomplete');
assert.equal(result.repositories[0].errors.length, 1);
assert.doesNotMatch(renderMarkdown(result), /其他远端分支无未合入改动/);
const config = join(f.path, 'repos.json'), output = join(f.path, 'report');
writeFileSync(config, JSON.stringify([repository]));
const cli = spawnSync(process.execPath, [fileURLToPath(new URL('./report-unmerged-branches.mjs', import.meta.url)), '--config', config, '--output-dir', output], { encoding: 'utf8' });
assert.equal(cli.status, 1);
assert.equal(JSON.parse(readFileSync(join(output, 'unmerged-branches.json'), 'utf8')).status, 'incomplete');
assert.match(readFileSync(join(output, 'unmerged-branches.md'), 'utf8'), /检查失败/);
});
test('fetch 失败不能把旧 refs 当成本日检查成功', t => {
const f = fixture(t);
const result = inspectRepository(f.repository, { fetch: true });
assert.equal(result.status, 'incomplete');
assert.equal(result.summary.checkedBranches, 0);
assert.match(result.errors[0].message, /origin/);
});
-39
View File
@@ -1,39 +0,0 @@
#!/bin/bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=scripts/init-security-env.sh
. "$SCRIPT_DIR/init-security-env.sh"
tmp_dir="$(mktemp -d)"; trap 'rm -rf "$tmp_dir"' EXIT
touch "$tmp_dir/.env"
write_env_value "$tmp_dir/.env" CONFIG_SECURITY_CONFIG_ENCRYPTION_KEY "original-master-with-dollar-\$1-and-backslash-\\"
write_env_value "$tmp_dir/.env" CONFIG_JWT_SECRET original-jwt-fallback
init_credential_encryption_env "$tmp_dir/.env" new > "$tmp_dir/log"
old_ring="$(read_env_value "$tmp_dir/.env" CONFIG_CREDENTIAL_ENCRYPTION_KEYRING)"
second="$(openssl rand -base64 32 | tr -d '\r\n')"
multi="${old_ring%\}},\"v2\":\"$second\"}"
write_env_value "$tmp_dir/.env" CONFIG_CREDENTIAL_ENCRYPTION_KEYRING "'$multi'"
write_env_value "$tmp_dir/.env" CONFIG_CREDENTIAL_ENCRYPTION_ACTIVE_KEY_ID v2
write_env_value "$tmp_dir/.env" UNRELATED_CONFIG keep-me
bash "$SCRIPT_DIR/credential-keys.sh" export "$tmp_dir/keys.env" "$tmp_dir/.env" >> "$tmp_dir/log"
[ "$(read_env_value "$tmp_dir/keys.env" CONFIG_CREDENTIAL_ENCRYPTION_KEYRING)" = "$multi" ]
if bash "$SCRIPT_DIR/credential-keys.sh" export "$tmp_dir/keys.env" "$tmp_dir/.env" >> "$tmp_dir/log" 2>&1; then exit 1; fi
write_env_value "$tmp_dir/.env" CONFIG_CREDENTIAL_ENCRYPTION_KEYRING "'$old_ring'"
write_env_value "$tmp_dir/.env" CONFIG_CREDENTIAL_ENCRYPTION_ACTIVE_KEY_ID v1
bash "$SCRIPT_DIR/credential-keys.sh" restore "$tmp_dir/keys.env" "$tmp_dir/.env" >> "$tmp_dir/log"
[ "$(read_env_value "$tmp_dir/.env" CONFIG_CREDENTIAL_ENCRYPTION_KEYRING)" = "$multi" ]
[ "$(read_env_value "$tmp_dir/.env" CONFIG_CREDENTIAL_ENCRYPTION_ACTIVE_KEY_ID)" = v2 ]
[ "$(read_env_value "$tmp_dir/.env" UNRELATED_CONFIG)" = keep-me ]
[ "$(read_env_value "$tmp_dir/.env" CONFIG_JWT_SECRET)" = original-jwt-fallback ]
[ "$(read_env_value "$tmp_dir/.env" CONFIG_SECURITY_CONFIG_ENCRYPTION_KEY)" = "$(read_env_value "$tmp_dir/keys.env" CONFIG_SECURITY_CONFIG_ENCRYPTION_KEY)" ]
cp "$tmp_dir/.env" "$tmp_dir/before"
cp "$tmp_dir/keys.env" "$tmp_dir/bad.env"
write_env_value "$tmp_dir/bad.env" CONFIG_CREDENTIAL_ENCRYPTION_KEYRING '{}'
if bash "$SCRIPT_DIR/credential-keys.sh" restore "$tmp_dir/bad.env" "$tmp_dir/.env" >> "$tmp_dir/log" 2>&1; then exit 1; fi
cmp -s "$tmp_dir/.env" "$tmp_dir/before"
touch "$tmp_dir/legacy.env"
if bash "$SCRIPT_DIR/credential-keys.sh" export "$tmp_dir/missing.env" "$tmp_dir/legacy.env" >> "$tmp_dir/log" 2>&1; then exit 1; fi
[ ! -e "$tmp_dir/missing.env" ]
if grep -Fq "$second" "$tmp_dir/log"; then exit 1; fi
if stat -f '%Lp' "$tmp_dir/keys.env" > /dev/null 2>&1; then permission="$(stat -f '%Lp' "$tmp_dir/keys.env")"; else permission="$(stat -c '%a' "$tmp_dir/keys.env")"; fi
[ "$permission" = 600 ]
echo 'Credential key backup/restore self-test passed (rotation, preservation, refusal, permissions, logs).'
-372
View File
@@ -1,372 +0,0 @@
#!/bin/bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TMP_DIR="$(mktemp -d)"
trap 'rm -rf "$TMP_DIR"' EXIT
cp \
"$REPO_ROOT/start.sh" \
"$REPO_ROOT/https.sh" \
"$REPO_ROOT/docker-compose.yml" \
"$REPO_ROOT/.env.sample" \
"$REPO_ROOT/.env.tools.sample" \
"$REPO_ROOT/.env.ASG.sample" \
"$REPO_ROOT/.env.AMS.sample" \
"$REPO_ROOT/easyai-proxy.conf.sample" \
"$TMP_DIR/"
mkdir -p "$TMP_DIR/scripts"
mkdir -p "$TMP_DIR/system-update"
cp \
"$REPO_ROOT/scripts/init-security-env.sh" \
"$REPO_ROOT/scripts/init-infrastructure-update-env.sh" \
"$REPO_ROOT/scripts/install-infrastructure-update-task.sh" \
"$REPO_ROOT/scripts/update-infrastructure.sh" \
"$REPO_ROOT/scripts/init-security-origin.sh" \
"$REPO_ROOT/scripts/init-public-api-base-url.sh" \
"$REPO_ROOT/scripts/init-server-http-bind-ip.sh" \
"$REPO_ROOT/scripts/check-redis-persistence.sh" \
"$TMP_DIR/scripts/"
cp "$REPO_ROOT/system-update/release-manifest.json" "$TMP_DIR/system-update/"
cd "$TMP_DIR"
reset_case() {
rm -f \
.env \
.env.tools \
.env.ASG \
.env.AMS \
easyai-proxy.conf \
10.0.0.8.conf \
demo.example.com.conf
}
assert_compression_config() {
local config_file="$1"
grep -q '^ gzip on;' "$config_file"
grep -q '^ gzip_vary on;' "$config_file"
grep -q '^ gzip_min_length 1024;' "$config_file"
grep -q '^ application/javascript$' "$config_file"
grep -q '^ text/css$' "$config_file"
grep -q '^ text/javascript$' "$config_file"
if grep -q 'text/event-stream' "$config_file"; then
echo "Unexpected SSE compression type in $config_file" >&2
return 1
fi
}
assert_sandbox_public_access_disabled() {
local config_file="$1"
grep -q '^ location = /api/sandbox {' "$config_file"
grep -q '^ location \^~ /api/sandbox/ {' "$config_file"
grep -q '^ location = /jupyterlab {' "$config_file"
grep -q '^ location \^~ /jupyterlab/ {' "$config_file"
grep -q '^ location = /sandbox {' "$config_file"
grep -q '^ location \^~ /sandbox/ {' "$config_file"
if grep -Eq 'proxy_pass http://127\.0\.0\.1:(8081|8888)' "$config_file"; then
echo "Unexpected public Sandbox/Jupyter proxy in $config_file" >&2
return 1
fi
}
assert_high_risk_public_access_disabled() {
local config_file="$1"
grep -q '^ location = /api/integration/platform-api/execute {' "$config_file"
grep -q '^ location = /api/auth/sign/token {' "$config_file"
grep -q '^ location = /api/auth/verify/token {' "$config_file"
grep -q '^ location = /logs-web {' "$config_file"
grep -q '^ location \^~ /logs-web/ {' "$config_file"
if grep -Eq 'proxy_pass .*(:8080|platform-api/execute|auth/(sign|verify)/token)' "$config_file"; then
echo "Unexpected high-risk public proxy in $config_file" >&2
return 1
fi
}
assert_compose_exposure() {
local expected_server_host_ip="$1"
local expected_proxy_host_ip="$2"
docker compose config --format json | python3 -c '
import json, sys
expected_server = sys.argv[1]
expected_proxy = sys.argv[2]
config = json.load(sys.stdin)
sandbox_ports = config["services"]["sandbox"].get("ports") or []
assert sandbox_ports == [], f"sandbox ports published: {sandbox_ports}"
server_ports = config["services"]["easyai-server"].get("ports") or []
assert len(server_ports) == 1, server_ports
assert server_ports[0].get("host_ip") == expected_server, server_ports
for service in ("easyai-web", "ws-gateway", "easyai-asg"):
ports = config["services"][service].get("ports") or []
assert len(ports) == 1, (service, ports)
assert ports[0].get("host_ip") == expected_proxy, (service, ports)
' "$expected_server_host_ip" "$expected_proxy_host_ip"
}
assert_canvas_ws_auth_config() {
docker compose config --format json | python3 -c '
import json, sys
config = json.load(sys.stdin)
server_env = config["services"]["easyai-server"].get("environment") or {}
gateway_env = config["services"]["ws-gateway"].get("environment") or {}
server_secret = str(server_env.get("WS_AUTH_WS_TICKET_SECRET") or "")
gateway_secret = str(gateway_env.get("WS_AUTH_WS_TICKET_SECRET") or "")
assert len(server_secret.encode()) >= 32, "server-main WS ticket secret is missing or too short"
assert server_secret == gateway_secret, "server-main and ws-gateway WS ticket secrets differ"
methods = {item.strip() for item in str(gateway_env.get("WS_AUTH_METHODS") or "").split(",") if item.strip()}
assert "ws_ticket" in methods, f"ws-gateway does not advertise ws_ticket: {sorted(methods)}"
'
}
assert_redis_startup_contract() {
docker compose config --format json | python3 -c '
import json, sys
services = json.load(sys.stdin)["services"]
redis = services["redis"]
assert any(volume.get("target") == "/data" for volume in redis.get("volumes") or []), redis.get("volumes")
assert redis["healthcheck"]["test"] == ["CMD", "redis-cli", "ping"]
assert services["easyai-server"]["depends_on"]["redis"]["condition"] == "service_healthy"
'
}
assert_compose_security() {
docker compose config --format json | python3 -c '
import json, sys
config = json.load(sys.stdin)
services = config["services"]
assert set(services["sandbox"]["networks"]) == {"sandbox-network"}, services["sandbox"]["networks"]
assert {"easyai", "sandbox-network"}.issubset(services["easyai-server"]["networks"]), services["easyai-server"]["networks"]
assert services["easyai-server"]["environment"]["GATEWAY_INBOUND_TCP_LISTEN_HOST"] == "172.21.0.6", services["easyai-server"]["environment"]["GATEWAY_INBOUND_TCP_LISTEN_HOST"]
assert services["mongo"].get("privileged") is not True, services["mongo"].get("privileged")
for service in ("mongo", "rabbitmq", "video-edit", "dozzle", "agent-memory"):
for port in services[service].get("ports") or []:
assert port.get("host_ip") == "127.0.0.1", (service, port)
watchtower = services["watchtower"]
command = " ".join(watchtower.get("command") or [])
assert "http-api-update" not in command, command
watchtower_env = watchtower.get("environment") or {}
assert watchtower.get("ports") in (None, []), watchtower.get("ports")
assert set(watchtower_env["WATCHTOWER_HTTP_API_ENDPOINTS"].split(",")) == {"health", "update", "metrics", "events", "history"}
assert len(watchtower_env["WATCHTOWER_HTTP_API_TOKEN"]) >= 32
assert len(watchtower_env["WATCHTOWER_HTTP_API_EVENTS_TOKEN"]) >= 32
assert watchtower_env["WATCHTOWER_HTTP_API_TOKEN"] != watchtower_env["WATCHTOWER_HTTP_API_EVENTS_TOKEN"]
watchtower_mounts = {(item.get("source"), item.get("target")) for item in watchtower.get("volumes") or []}
assert any(target == "/config.json" for _, target in watchtower_mounts), watchtower_mounts
assert services["dozzle"]["environment"]["DOZZLE_ENABLE_DOWNLOAD"] == "false", services["dozzle"]["environment"]
'
}
assert_managed_update_contract() {
docker compose config --format json | python3 -c '
import json, sys
services = json.load(sys.stdin)["services"]
updater = services["easyai-updater"]
server = services["easyai-server"]
watchtower = services["watchtower"]
assert updater.get("ports") in (None, []), updater.get("ports")
assert updater.get("read_only") is True, updater.get("read_only")
assert updater["networks"]["easyai"]["ipv4_address"] == "172.21.0.31", updater["networks"]
assert set(updater.get("cap_drop") or []) == {"ALL"}, updater.get("cap_drop")
assert "no-new-privileges:true" in (updater.get("security_opt") or []), updater.get("security_opt")
mounts = {(item.get("source"), item.get("target")) for item in updater.get("volumes") or []}
assert ("/var/run/docker.sock", "/var/run/docker.sock") in mounts, mounts
assert any(target == "/var/lib/easyai-updater" for _, target in mounts), mounts
assert any(target == "/run/secrets/docker-config.json" for _, target in mounts), mounts
assert any(target == "/etc/easyai-updater/release-manifest.json" for _, target in mounts), mounts
server_env = server.get("environment") or {}
updater_env = updater.get("environment") or {}
token = str(server_env.get("SYSTEM_UPDATE_INTERNAL_TOKEN") or "")
assert len(token.encode()) >= 32, "managed updater token is missing or too short"
assert token == str(updater_env.get("SYSTEM_UPDATE_INTERNAL_TOKEN") or ""), "server/updater tokens differ"
assert server_env.get("SYSTEM_UPDATE_MODE") == "managed", server_env.get("SYSTEM_UPDATE_MODE")
assert str(server_env.get("EASYAI_DEPLOYMENT_SCHEMA_VERSION")) == "2"
assert str(updater_env.get("UPDATE_HEALTH_TIMEOUT_MS")) == "300000"
assert updater_env.get("DOCKER_CONFIG_FILE") == "/run/secrets/docker-config.json"
assert updater_env.get("UPDATE_MANIFEST_FILE") == "/etc/easyai-updater/release-manifest.json"
assert updater_env.get("UPDATE_MANIFEST_SOURCE") == "remote"
assert updater_env.get("UPDATE_MANIFEST_URL") == ""
assert updater.get("labels", {}).get("com.centurylinklabs.watchtower.enable") == "true"
assert watchtower.get("environment", {}).get("WATCHTOWER_POLL_INTERVAL") == "86400"
assert watchtower.get("environment", {}).get("WATCHTOWER_CLEANUP") == "true"
assert "easyai-updater" in (watchtower.get("command") or []), watchtower.get("command")
for service in ("easyai-server", "ws-gateway", "easyai-web"):
labels = services[service].get("labels") or {}
assert labels.get("com.centurylinklabs.watchtower.enable") == "false", (service, labels)
'
python3 - <<'PY'
import json
import re
from pathlib import Path
manifest = json.loads(Path("system-update/release-manifest.json").read_text())
assert manifest["schemaVersion"] == 1
assert manifest["channel"] == "stable"
assert manifest["platform"] == "linux/amd64"
assert manifest["managedUpdateAllowed"] is True
assert manifest["rollbackSafe"] is True
assert manifest["minimumUpdaterVersion"] == "1.1.0"
assert manifest["minimumDeploymentSchemaVersion"] == 2
assert int(manifest["requiredFreeBytes"]) > 0
components = {item["key"]: item for item in manifest["components"]}
assert set(components) == {"server-main", "ws-gateway", "web"}
expected_images = {
"server-main": "registry.cn-shanghai.aliyuncs.com/comfy-ai/comfy-server",
"ws-gateway": "registry.cn-shanghai.aliyuncs.com/easyaigc/wsgateway",
"web": "registry.cn-shanghai.aliyuncs.com/comfy-ai/one-ai",
}
for key, image in expected_images.items():
assert components[key]["image"] == image
assert re.fullmatch(r"sha256:[a-f0-9]{64}", components[key]["digest"])
assert int(components[key]["imageSizeBytes"]) > 0
PY
}
assert_legacy_update_contract() {
local legacy_env="$TMP_DIR/.env.legacy"
grep -Ev '^(SYSTEM_UPDATE_MODE|COMPOSE_PROFILES|WATCHTOWER_CORE_UPDATE_ENABLED|WATCHTOWER_UPDATE_TARGET|SYSTEM_UPDATER_UPDATE_INTERVAL_SECONDS|WATCHTOWER_CLEANUP|UPDATE_HEALTH_TIMEOUT_MS|DOCKER_CONFIG_FILE|SYSTEM_UPDATE_INTERNAL_TOKEN|EASYAI_DEPLOYMENT_SCHEMA_VERSION)=' .env > "$legacy_env"
docker compose --env-file "$legacy_env" config --format json 2>/dev/null | python3 -c '
import json, sys
services = json.load(sys.stdin)["services"]
assert "easyai-updater" not in services, sorted(services)
server_env = services["easyai-server"].get("environment") or {}
assert server_env.get("SYSTEM_UPDATE_MODE") == "legacy_watchtower", server_env.get("SYSTEM_UPDATE_MODE")
assert str(server_env.get("EASYAI_DEPLOYMENT_SCHEMA_VERSION")) == "1"
assert "easyai-updater" not in (services["watchtower"].get("command") or []), services["watchtower"].get("command")
for service in ("easyai-server", "ws-gateway", "easyai-web"):
labels = services[service].get("labels") or {}
assert labels.get("com.centurylinklabs.watchtower.enable") == "true", (service, labels)
'
}
sed -i.bak 's/^SERVER_HTTP_PORT=.*/SERVER_HTTP_PORT=4100/' .env.sample
rm -f .env.sample.bak
DEPLOY_NON_INTERACTIVE=1 \
DEPLOY_DRY_RUN=1 \
DEPLOY_ACCESS=ip \
DEPLOY_IP=10.0.0.8 \
bash start.sh > "$TMP_DIR/first-install.log"
grep -qx 'NUXT_PUBLIC_BASE_APIURL=/api' .env
grep -qx 'NUXT_PUBLIC_BASE_SOCKETURL=ws://10.0.0.8/socket.io' .env
grep -qx 'NUXT_PUBLIC_SG_APIURL=/asg-api' .env
grep -qx 'CONFIG_PUBLIC_API_BASE_URL=http://10.0.0.8/api' .env
grep -qx 'CONFIG_SECURITY_ORIGIN=' .env
grep -qx 'EASYAI_PROXY_BIND_IP=127.0.0.1' .env
grep -qx 'EASYAI_INFRA_BIND_IP=127.0.0.1' .env
grep -qx 'GATEWAY_INBOUND_TCP_LISTEN_HOST=172.21.0.6' .env
grep -qx 'SERVER_HTTP_BIND_IP=127.0.0.1' .env
grep -q 'server_name 10.0.0.8;' 10.0.0.8.conf
if grep -q 'www.10.0.0.8' 10.0.0.8.conf; then
echo "IP proxy config must not contain the domain-only www redirect" >&2
exit 1
fi
grep -q 'proxy_pass http://127.0.0.1:3010/;' 10.0.0.8.conf
grep -q 'proxy_pass http://127.0.0.1:4100/;' 10.0.0.8.conf
grep -q 'proxy_pass http://127.0.0.1:3002;' 10.0.0.8.conf
grep -q 'proxy_pass http://127.0.0.1:3003/;' 10.0.0.8.conf
grep -q 'location /socket.io {' 10.0.0.8.conf
assert_compression_config 10.0.0.8.conf
assert_sandbox_public_access_disabled 10.0.0.8.conf
assert_high_risk_public_access_disabled 10.0.0.8.conf
grep -Fq '访问地址: http://10.0.0.8' "$TMP_DIR/first-install.log"
initial_admin_password="$(awk -F= '$1 == "CONFIG_INITIAL_ADMIN_PASSWORD" { print $2; exit }' .env)"
[ "${#initial_admin_password}" -ge 12 ] || {
echo "Initial admin password was not generated" >&2
exit 1
}
grep -Fq '登录账号: admin' "$TMP_DIR/first-install.log"
grep -Fq "登录密码: ${initial_admin_password}" "$TMP_DIR/first-install.log"
grep -Fq '生命周期: 仅在数据库首次创建 admin 时使用;创建后修改 .env 不会重置密码。' "$TMP_DIR/first-install.log"
grep -Fq '有效期: 初始密码没有独立过期时间,在管理员修改密码前持续有效。' "$TMP_DIR/first-install.log"
DEPLOY_NON_INTERACTIVE=1 \
DEPLOY_DRY_RUN=1 \
DEPLOY_ACCESS=ip \
DEPLOY_IP=10.0.0.8 \
bash start.sh > "$TMP_DIR/existing-env.log"
grep -Fq '管理员账号: admin(本次部署不会生成或重置已有密码)' "$TMP_DIR/existing-env.log"
if grep -Fq "登录密码: ${initial_admin_password}" "$TMP_DIR/existing-env.log"; then
echo "Existing-env deployment must not report CONFIG_INITIAL_ADMIN_PASSWORD as the current login password" >&2
exit 1
fi
if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then
assert_compose_exposure 127.0.0.1 127.0.0.1
assert_canvas_ws_auth_config
assert_compose_security
assert_redis_startup_contract
assert_managed_update_contract
assert_legacy_update_contract
fi
reset_case
DEPLOY_NON_INTERACTIVE=1 \
DEPLOY_DRY_RUN=1 \
DEPLOY_ACCESS=domain \
DEPLOY_DOMAIN=demo.example.com \
DEPLOY_HTTPS=false \
bash start.sh >/dev/null
grep -qx 'NUXT_PUBLIC_BASE_APIURL=/api' .env
grep -qx 'NUXT_PUBLIC_BASE_SOCKETURL=ws://demo.example.com/socket.io' .env
grep -qx 'NUXT_PUBLIC_SG_APIURL=/asg-api' .env
grep -qx 'CONFIG_PUBLIC_API_BASE_URL=http://demo.example.com/api' .env
grep -q 'proxy_set_header X-Forwarded-Host $easyai_forwarded_host;' demo.example.com.conf
grep -q 'proxy_set_header X-Forwarded-Port $server_port;' demo.example.com.conf
grep -q "proxy_set_header X-Original-Prefix '/api';" demo.example.com.conf
grep -q 'location = /api {' demo.example.com.conf
assert_compression_config demo.example.com.conf
assert_sandbox_public_access_disabled demo.example.com.conf
assert_high_risk_public_access_disabled demo.example.com.conf
grep -qx 'SERVER_HTTP_BIND_IP=127.0.0.1' .env
grep -qx 'EASYAI_PROXY_BIND_IP=127.0.0.1' .env
grep -qx 'EASYAI_INFRA_BIND_IP=127.0.0.1' .env
grep -qx 'GATEWAY_INBOUND_TCP_LISTEN_HOST=172.21.0.6' .env
if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then
assert_compose_exposure 127.0.0.1 127.0.0.1
assert_compose_security
fi
# 直接验证 https.sh 的缺省配置生成函数会采用完整模板,而不是只代理 3010。
eval "$(awk '
/^create_conf_by_template\(\)/ { capture = 1 }
/^conf_contains_domain\(\)/ { capture = 0 }
capture { print }
' https.sh)"
create_conf_by_template demo.example.com easyai-proxy.conf
grep -q 'server_name demo.example.com;' easyai-proxy.conf
grep -q 'location = /api {' easyai-proxy.conf
grep -q 'location /api/ {' easyai-proxy.conf
grep -q 'proxy_pass http://127.0.0.1:3001/;' easyai-proxy.conf
grep -q 'location /socket.io {' easyai-proxy.conf
grep -q 'proxy_set_header X-Forwarded-Host $easyai_forwarded_host;' easyai-proxy.conf
assert_compression_config easyai-proxy.conf
assert_sandbox_public_access_disabled easyai-proxy.conf
assert_high_risk_public_access_disabled easyai-proxy.conf
reset_case
DEPLOY_NON_INTERACTIVE=1 \
DEPLOY_DRY_RUN=1 \
DEPLOY_ACCESS=domain \
DEPLOY_DOMAIN=demo.example.com \
DEPLOY_HTTPS=true \
bash start.sh >/dev/null
grep -qx 'NUXT_PUBLIC_BASE_APIURL=/api' .env
grep -qx 'NUXT_PUBLIC_BASE_SOCKETURL=wss://demo.example.com/socket.io' .env
grep -qx 'NUXT_PUBLIC_SG_APIURL=/asg-api' .env
grep -qx 'CONFIG_PUBLIC_API_BASE_URL=https://demo.example.com/api' .env
reset_case
DEPLOY_NON_INTERACTIVE=1 \
DEPLOY_DRY_RUN=1 \
DEPLOY_ACCESS=ip \
DEPLOY_IP=10.0.0.8 \
DEPLOY_PUBLIC_API_BASE_URL=https://edge.example.com:8443/custom-api \
bash start.sh >/dev/null
grep -qx 'CONFIG_PUBLIC_API_BASE_URL=https://edge.example.com:8443/custom-api' .env
echo "Deployment public URL dry-run tests passed"
-112
View File
@@ -1,112 +0,0 @@
#!/usr/bin/env python3
"""Exercise real entry scripts in temporary projects; never edits host schedulers."""
from pathlib import Path
import json,os,subprocess,tempfile,time,shutil
REPO=Path(__file__).resolve().parents[1]
started=time.monotonic()
with tempfile.TemporaryDirectory(prefix='easyai infrastructure % ') as tmp:
root=Path(tmp); project=root/'project space';(project/'scripts').mkdir(parents=True)
for name in ['init-security-env.sh','Initialize-SecurityEnv.ps1','init-infrastructure-update-env.sh','Initialize-InfrastructureUpdateEnv.ps1','install-infrastructure-update-task.sh','Install-InfrastructureUpdateTask.ps1','update-infrastructure.sh','Update-Infrastructure.ps1']:
shutil.copy2(REPO/'scripts'/name,project/'scripts'/name)
bins=root/'bin';bins.mkdir();calls=root/'calls.jsonl';cron=root/'crontab';cron.write_text('5 2 * * * existing-backup\n# existing comment\n')
environment={**os.environ,'PATH':str(bins)+os.pathsep+os.environ['PATH'],'INFRA_TEST_CALLS':str(calls),'INFRA_TEST_CRON':str(cron)}
def stub(name,body):
p=bins/name;p.write_text(body);p.chmod(0o755)
stub('sudo','#!/bin/sh\nexec "$@"\n')
stub('service','#!/bin/sh\nexit 0\n')
stub('systemctl','#!/bin/sh\nexit 0\n')
stub('crontab','''#!/usr/bin/env python3
import sys,os
from pathlib import Path
p=Path(os.environ['INFRA_TEST_CRON'])
if os.environ.get('INFRA_TEST_CRON_ERROR'):
print('permission denied',file=sys.stderr);sys.exit(1)
if sys.argv[1]=='-l':print(p.read_text(),end='')
else:p.write_text(sys.stdin.read())
''')
stub('docker','''#!/usr/bin/env python3
import sys,os,json,time
from pathlib import Path
args=sys.argv[1:]
with Path(os.environ['INFRA_TEST_CALLS']).open('a') as f:f.write(json.dumps(args)+'\\n')
if args[:1]==['--config']:args=args[2:]
if args==['compose','version']:sys.exit(1 if os.environ.get('INFRA_TEST_V1') else 0)
if args[:3]==['compose','ps','-q']:print(args[3]+'-test-id')
if args[:1]==['inspect']:print('true none' if os.environ.get('INFRA_TEST_NO_HEALTH') else 'true healthy')
if args==['compose','config','--services']:print('watchtower\\nmongo' if os.environ.get('INFRA_TEST_LEGACY') else 'watchtower\\neasyai-updater\\nmongo')
if args[:2]==['compose','pull']:
time.sleep(float(os.environ.get('INFRA_TEST_SLEEP','0')))
if os.environ.get('INFRA_TEST_PULL_FAIL') in ['1',args[2]]:sys.exit(1)
''')
stub('docker-compose','''#!/usr/bin/env python3
import sys,os,json
from pathlib import Path
with Path(os.environ['INFRA_TEST_CALLS']).open('a') as f:f.write(json.dumps(['v1']+sys.argv[1:])+'\\n')
if sys.argv[1:3]==['ps','-q']:print(sys.argv[3]+'-test-id')
if sys.argv[1:]==['config','--services']:print('watchtower\\nmongo' if os.environ.get('INFRA_TEST_LEGACY') else 'watchtower\\neasyai-updater\\nmongo')
if sys.argv[1:2]==['pull'] and os.environ.get('INFRA_TEST_PULL_FAIL') in ['1',sys.argv[2]]:sys.exit(1)
''')
def run(args,extra=None):
return subprocess.run(args,cwd=project,env={**environment,**(extra or {})},capture_output=True,text=True)
def task():return ['bash',str(project/'scripts/update-infrastructure.sh')]
def recorded():return [json.loads(line) for line in calls.read_text().splitlines()]
for shell in ['bash','pwsh']:
for image,enabled,expected in [('registry.cn-shanghai.aliyuncs.com/comfy-ai/watchtower-aliyun:1.22.3','true','latest'),('registry.cn-shanghai.aliyuncs.com/comfy-ai/watchtower-aliyun:1.13.1','true','latest'),('customer.invalid/watcher:1.22.3','true','1.22.3'),('registry.cn-shanghai.aliyuncs.com/comfy-ai/watchtower-aliyun:1.22.3','false','1.22.3')]:
(project/'.env').write_text(f'WATCHTOWER_IMAGE={image}\nINFRASTRUCTURE_AUTO_UPDATE_ENABLED={enabled}\nKEEP=original\n')
args=['bash',str(project/'scripts/init-infrastructure-update-env.sh'),str(project)] if shell=='bash' else ['pwsh','-NoProfile','-File',str(project/'scripts/Initialize-InfrastructureUpdateEnv.ps1'),'-ProjectRoot',str(project)]
p=run(args);assert p.returncode==0,p.stderr
content=(project/'.env').read_text();assert f'WATCHTOWER_IMAGE={image.rsplit(":",1)[0]}:{expected}' in content,content
assert 'KEEP=original' in content
assert run(args).returncode==0 and (project/'.env').read_text()==content
for shell in ['bash','pwsh']:
(project/'.env').write_text('WATCHTOWER_IMAGE=registry.cn-shanghai.aliyuncs.com/comfy-ai/watchtower-aliyun:latest\nWATCHTOWER_UPDATE_TARGET=easyai-updater\n')
args=['bash',str(project/'scripts/init-infrastructure-update-env.sh'),str(project)] if shell=='bash' else ['pwsh','-NoProfile','-File',str(project/'scripts/Initialize-InfrastructureUpdateEnv.ps1'),'-ProjectRoot',str(project)]
assert run(args,{'UPDATE_SERVICES':'easyai-server'}).returncode==0
assert 'WATCHTOWER_SCOPE=' not in (project/'.env').read_text()
assert run(args,{'UPDATE_SERVICES':''}).returncode==0
assert 'WATCHTOWER_SCOPE=easyai' in (project/'.env').read_text()
(project/'.env').write_text('INFRASTRUCTURE_AUTO_UPDATE_ENABLED=true\n')
install=['bash',str(project/'scripts/install-infrastructure-update-task.sh'),str(project)]
assert run(install).returncode==0
first=cron.read_text();assert 'existing-backup' in first and first.count('# easyai-infrastructure-update-')==1
assert '\\%' in first and 'project\\ space' in first,first
assert run(install).returncode==0 and cron.read_text()==first
assert run(install,{'INFRA_TEST_CRON_ERROR':'1'}).returncode!=0 and cron.read_text()==first
(project/'.env').write_text('INFRASTRUCTURE_AUTO_UPDATE_ENABLED=false\n')
assert run(install).returncode==0 and 'easyai-infrastructure-update' not in cron.read_text() and 'existing-backup' in cron.read_text()
(project/'.env').write_text('INFRASTRUCTURE_AUTO_UPDATE_ENABLED=true\n')
calls.write_text('');assert run(task()).returncode==0
assert ['compose','pull','watchtower'] in recorded() and ['compose','up','-d','--no-deps','watchtower'] in recorded()
assert ['compose','pull','easyai-updater'] in recorded() and ['compose','up','-d','--no-deps','easyai-updater'] in recorded()
assert not any('mongo' in args for args in recorded())
calls.write_text('');assert run(task(),{'INFRA_TEST_PULL_FAIL':'1'}).returncode!=0
assert not any('up' in args for args in recorded());assert not (project/'data/infrastructure-update/lock').exists()
calls.write_text('');p=subprocess.Popen(task(),cwd=project,env={**environment,'INFRA_TEST_SLEEP':'1'},stdout=subprocess.PIPE,stderr=subprocess.PIPE)
for _ in range(100):
if (project/'data/infrastructure-update/lock/pid').exists():break
time.sleep(.01)
assert run(task()).returncode==0
assert p.wait(timeout=5)==0 and sum('pull' in args for args in recorded())==2
calls.write_text('');assert run(task(),{'INFRA_TEST_V1':'1'}).returncode==0
assert ['v1','up','-d','--no-deps','watchtower'] in recorded() and ['v1','up','-d','--no-deps','easyai-updater'] in recorded()
calls.write_text('');p=run(['pwsh','-NoProfile','-File',str(project/'scripts/Update-Infrastructure.ps1'),'-ProjectRoot',str(project)])
assert p.returncode==0,p.stderr
assert ['compose','pull','watchtower'] in recorded() and ['compose','up','-d','--no-deps','watchtower'] in recorded(),recorded()
calls.write_text('');p=run(['pwsh','-NoProfile','-File',str(project/'scripts/Update-Infrastructure.ps1'),'-ProjectRoot',str(project)],{'INFRA_TEST_PULL_FAIL':'1'})
assert p.returncode!=0 and not any('up' in args for args in recorded())
for shell in ['bash','pwsh']:
args=task() if shell=='bash' else ['pwsh','-NoProfile','-File',str(project/'scripts/Update-Infrastructure.ps1'),'-ProjectRoot',str(project)]
for transport in ['', '1']:
kind='v1' if transport else 'compose'
for failed,other in [('watchtower','easyai-updater'),('easyai-updater','watchtower')]:
calls.write_text('');p=run(args,{'INFRA_TEST_PULL_FAIL':failed,'INFRA_TEST_V1':transport})
assert p.returncode!=0,(shell,p.stderr)
assert [kind,'pull',failed] in recorded() and [kind,'pull',other] in recorded()
assert [kind,'up','-d','--no-deps',failed] not in recorded()
assert [kind,'up','-d','--no-deps',other] in recorded(),(shell,recorded())
calls.write_text('');p=run(args,{'INFRA_TEST_LEGACY':'1','INFRA_TEST_V1':transport})
assert p.returncode==0 and not any('easyai-updater' in a for a in recorded()),(shell,p.stderr,recorded())
calls.write_text('');p=run(args,{'INFRA_TEST_NO_HEALTH':'1'})
assert p.returncode==0,(shell,p.stderr)
assert any(a[:3]==['exec','easyai-updater-test-id','node'] for a in recorded())
print(json.dumps({'passed':True,'cases':['Bash/PowerShell default migration and explicit choices','cron registration deduplication and existing task preservation','cron read failure leaves jobs untouched','Bash/PowerShell independent component maintenance and partial failure reporting','legacy profile skips updater','concurrent task excluded','Compose v1 compatibility','only Watchtower and updater updated','service health probes'],'durationSeconds':round(time.monotonic()-started,3)}))
-126
View File
@@ -1,126 +0,0 @@
#!/usr/bin/env python3
"""Real Git/config adoption regression; container mutation is not used here."""
import json
import os
from pathlib import Path
import shutil
import subprocess
import tempfile
import time
source = Path(__file__).resolve().parent.parent
started = time.monotonic()
def run(args, root, env=None):
return subprocess.run(args, cwd=root, env=env, capture_output=True, text=True, timeout=30)
with tempfile.TemporaryDirectory(prefix='easyai-legacy-update-') as directory:
base = Path(directory)
root = base / 'easyai'
root.mkdir()
(root / 'docker-compose.yml').write_text('services:\n easyai-server:\n image: server:old\n environment:\n - EXISTING=keep\n watchtower:\n image: watcher:old\n environment:\n - WATCHTOWER_HTTP_API_TOKEN=${LEGACY_TOKEN}\n')
(root / '.gitignore').write_text('.env\ndata/\n')
(root / '.env').write_text('VERSION=old\nCONFIG_JWT_SECRET=keep-this-private-key\n')
for args in [['git','init','-q'], ['git','config','user.name','Update test'], ['git','config','user.email','test@example.invalid'], ['git','add','.'], ['git','commit','-qm','test: 旧部署基准']]:
assert run(args, root).returncode == 0
remote = base / 'remote.git'
seed = base / 'seed'
assert run(['git','clone','-q','--bare',str(root),str(remote)], base).returncode == 0
assert run(['git','remote','add','origin',str(remote)], root).returncode == 0
assert run(['git','fetch','-q'],root).returncode == 0
branch = run(['git','branch','--show-current'], root).stdout.strip()
assert run(['git','branch','--set-upstream-to','origin/'+branch],root).returncode == 0
assert run(['git','clone','-q',str(remote),str(seed)],base).returncode == 0
(seed / 'docker-compose.yml').write_text('''services:
easyai-server:
image: server:new
environment:
- EXISTING=keep
- MODEL_CONVERSION_WORKER_TOKEN=${MODEL_CONVERSION_WORKER_TOKEN}
watchtower:
image: watcher:new
environment:
- WATCHTOWER_HTTP_API_TOKEN=${WATCHTOWER_API_KEY}
easyai-updater:
image: updater:new
environment:
- UPDATE_MANIFEST_FILE=/etc/manifest.json
volumes:
- ./data/update-state:/state
- ./system-update/release-manifest.json:/etc/manifest.json:ro
networks:
easyai:
ipv4_address: 172.21.0.31
model-conversion-worker:
image: worker:new
volumes:
- accepted-data:/fixture
networks:
easyai: {}
volumes:
accepted-data: {}
''')
(seed / 'scripts').mkdir()
(seed / 'scripts/init-security-env.sh').write_text('# modern security initializer\n')
(seed / 'demo.example.conf').write_text('# upstream example\n')
for args in [['git','config','user.name','Update test'],['git','config','user.email','test@example.invalid'],['git','add','.'],['git','commit','-qm','test: 新增转换服务与独立更新地址'],['git','push','-q']]:
assert run(args,seed).returncode == 0
with (root / 'docker-compose.yml').open('a') as stream:
stream.write(''' easyai-updater:
image: updater:new
environment:
- UPDATE_MANIFEST_FILE=/state/custom.json
volumes:
- ./data/update-state:/state
networks:
- easyai
''')
(root / 'scripts').mkdir()
(root / 'scripts/init-security-env.sh').write_text('# local legacy initializer\n')
(root / 'demo.example.conf').write_text('# custom nginx source; active copy elsewhere\n')
original = (root / 'docker-compose.yml').read_bytes().replace(b' environment:\n - WATCHTOWER_HTTP_API_TOKEN=${LEGACY_TOKEN}\n', b'')
(root / 'docker-compose.yml').write_bytes(original)
bins = base / 'bin'
bins.mkdir()
docker = bins / 'docker'
docker.write_text('#!/bin/sh\nexit 0\n')
docker.chmod(0o755)
env = {**os.environ, 'PATH': str(bins)+os.pathsep+os.environ['PATH']}
legacy = run(['git','pull','--ff-only'],root)
assert legacy.returncode != 0 and 'would be overwritten' in legacy.stderr
assert original == (root / 'docker-compose.yml').read_bytes()
(root / 'business.js').write_text('local business code\n')
assert run(['git','add','business.js'],root).returncode == 0
rejected = run(['python3',str(source/'scripts/prepare-update-repository.py'),str(root)],root,env)
assert rejected.returncode != 0
assert original == (root / 'docker-compose.yml').read_bytes()
assert run(['git','reset','-q','HEAD','--','business.js'],root).returncode == 0
accepted = run(['python3',str(source/'scripts/prepare-update-repository.py'),str(root)],root,env)
assert accepted.returncode == 0, accepted.stdout+accepted.stderr
assert run(['git','pull','--ff-only'],root).returncode == 0
assert run(['git','diff','--name-only'],root).stdout == ''
override=json.loads((root/'data/deployment-config/legacy-compose.json').read_text())
assert override == {'services': {'easyai-updater': {'environment': {'UPDATE_MANIFEST_FILE': '/state/custom.json'}}}}, override
assert 'COMPOSE_FILE=docker-compose.yml:data/deployment-config/legacy-compose.json' in (root/'.env').read_text()
assert 'CONFIG_JWT_SECRET=keep-this-private-key' in (root/'.env').read_text()
backups=list((root/'data/deployment-backups').glob('legacy-update-*'))
assert len(backups)==1
assert (backups[0]/'docker-compose.yml').read_bytes()==original
assert (backups[0]/'demo.example.conf').read_text().startswith('# custom nginx')
assert (backups[0]/'.env').stat().st_mode & 0o077 == 0
assert (backups[0]).stat().st_mode & 0o077 == 0
resolved=run(['docker','compose','--env-file','.env','config','--format','json'],root)
assert resolved.returncode==0,resolved.stderr
config=json.loads(resolved.stdout)
assert config['services']['easyai-updater']['networks']['easyai']['ipv4_address']=='172.21.0.31'
assert config['services']['easyai-updater']['environment']['UPDATE_MANIFEST_FILE']=='/state/custom.json'
assert 'model-conversion-worker' in config['services']
assert 'accepted-data' in config['volumes']
assert 'WATCHTOWER_HTTP_API_TOKEN' in config['services']['watchtower']['environment']
repeat=run(['python3',str(source/'scripts/prepare-update-repository.py'),str(root)],root,env)
assert repeat.returncode==0
assert len(list((root/'data/deployment-backups').glob('legacy-update-*')))==1
print('legacy Git conflict: reproduced; code guard, private backups, local override, new Worker and updater address passed')
print(json.dumps({'passed':True,'durationSeconds':round(time.monotonic()-started,3)}))
-216
View File
@@ -1,216 +0,0 @@
#!/usr/bin/env python3
"""Run the real Bash/PowerShell update entry points with isolated command transport.
Docker transport is mocked here; actual container/queue conversion is a separate acceptance.
"""
import argparse
import json
import os
from pathlib import Path
import shutil
import subprocess
import tempfile
import time
parser = argparse.ArgumentParser()
parser.add_argument('--source', type=Path, default=Path(__file__).resolve().parent.parent)
parser.add_argument('--bash-only', action='store_true', help='Validate Linux updates only; the default still requires Bash and PowerShell.')
options = parser.parse_args()
source = options.source.resolve()
started = time.monotonic()
selected = ['easyai-server', 'easyai-web', 'model-conversion-worker']
def run(args, cwd, env=None):
return subprocess.run(args, cwd=cwd, env=env, text=True, encoding='utf-8', errors='backslashreplace', capture_output=True, timeout=35)
def fixture(base):
root = base / 'easyai'
root.mkdir()
for name in ['scripts', 'update.sh', 'update.ps1', '.env.sample', 'docker-compose.yml']:
path = source / name
if path.is_dir():
shutil.copytree(path, root / name)
else:
shutil.copy2(path, root / name)
(root / 'scripts/install-infrastructure-update-task.sh').write_text('#!/bin/sh\nexit 0\n')
(root / 'scripts/Install-InfrastructureUpdateTask.ps1').write_text('# Scheduler transport is verified separately.\n')
(root / 'data').mkdir()
(root / '.env').write_text('SYSTEM_UPDATE_MODE=legacy_watchtower\nNUXT_PUBLIC_BASE_APIURL=http://127.0.0.1:3001\nCONFIG_JWT_SECRET=existing-jwt-secret-that-is-long-enough\nWS_AUTH_WS_TICKET_SECRET=existing-ticket-secret-that-is-long-enough\n')
(root / '.gitignore').write_text('.env\n*.log\n')
commands = base / 'commands.jsonl'
commands.touch()
binaries = base / 'bin'
binaries.mkdir()
docker = binaries / 'docker'
docker.write_text('''#!/usr/bin/env python3
import json, os, sys
args = sys.argv[1:]
with open(os.environ['UPDATE_TEST_COMMANDS'], 'a') as output: output.write(json.dumps(args)+'\\n')
if args[:2] == ['container', 'inspect']: sys.exit(0 if os.environ.get('UPDATE_TEST_LEGACY_REDIS') == '1' else 1)
if args[:1] == ['inspect']:
if '--format' in args:
if args[-1] == 'redis': print('volume|legacy-redis-data'); sys.exit(0)
unhealthy = os.environ.get('UPDATE_TEST_UNHEALTHY') == '1' and args[-1].endswith('model-conversion-worker')
print('true|unhealthy' if unhealthy else 'true|healthy')
sys.exit(0)
sys.exit(1)
if args[:3] == ['compose', 'config', '--services']: print('easyai-server\\neasyai-web\\nws-gateway\\neasyai-updater\\nmodel-conversion-worker\\nmongo\\nredis')
elif args[:3] == ['compose', 'ps', '-q']: print('container-' + args[-1])
sys.exit(0)
''')
docker.chmod(0o755)
for name, code in [('sudo', '#!/bin/sh\nexec "$@"\n'), ('systemctl', '#!/bin/sh\nexit 0\n')]:
p = binaries / name
p.write_text(code)
p.chmod(0o755)
env = {**os.environ, 'PATH': str(binaries) + os.pathsep + os.environ['PATH'], 'CI': 'true', 'UPDATE_NO_WAIT': '1', 'UPDATE_MODE': 'image', 'UPDATE_SERVICES': ' '.join(selected), 'UPDATE_TEST_COMMANDS': str(commands), 'UPDATE_TEST_LEGACY_REDIS': '1'}
return root, commands, env
def calls(path):
return [json.loads(line) for line in path.read_text().splitlines()]
def assert_selected(root, command_file, output):
recorded = calls(command_file)
assert ['compose', 'pull', *selected] in recorded, recorded
assert ['compose', 'up', '-d', '--no-deps', *selected] in recorded, recorded
assert ['compose', 'pull'] not in recorded
assert ['compose', 'up', '-d'] not in recorded
assert ['compose', 'ps', '-q', 'model-conversion-worker'] in recorded
values = dict(line.split('=', 1) for line in (root / '.env').read_text().splitlines() if '=' in line and not line.startswith('#'))
token = values['MODEL_CONVERSION_WORKER_TOKEN']
assert len(token) >= 32
assert token not in output
assert values['MODEL_CONVERSION_HUNYUAN_ENABLED'] == 'false'
assert values['CONFIG_JWT_SECRET'] == 'existing-jwt-secret-that-is-long-enough'
with tempfile.TemporaryDirectory(prefix='easyai-conversion-update-') as temporary:
base = Path(temporary)
for name, command in [('bash', ['bash', 'update.sh']), ('powershell', ['pwsh', '-NoProfile', '-File', 'update.ps1'])]:
if options.bash_only and name != 'bash':
continue
case = base / name
case.mkdir()
root, log, env = fixture(case)
result = run(command, root, env)
assert result.returncode == 0, result.stdout + result.stderr
assert_selected(root, log, result.stdout)
original = (root / '.env').read_bytes()
result = run(command, root, env)
assert result.returncode == 0, result.stdout + result.stderr
assert original == (root / '.env').read_bytes(), 'repeat changes deployment settings'
log.write_text('')
original = (root / '.env').read_bytes()
result = run(command, root, {**env, 'UPDATE_RELEASE_VERSION': 'latest;unsafe'})
assert result.returncode != 0
assert original == (root / '.env').read_bytes()
assert not any(c[:2] in [['compose', 'pull'], ['compose', 'up']] for c in calls(log))
result = run(command, root, {**env, 'UPDATE_RELEASE_VERSION': '3.23.0.20261003'})
assert result.returncode == 0, result.stdout + result.stderr
values = dict(line.split('=', 1) for line in (root / '.env').read_text().splitlines() if '=' in line and not line.startswith('#'))
assert values['VERSION'] == values['MODEL_CONVERSION_WORKER_VERSION'] == '3.23.0.20261003'
log.write_text('')
result = run(command, root, {**env, 'UPDATE_SERVICES': 'easyai-server missing-service'})
assert result.returncode != 0
assert not any(c[:2] in [['compose', 'pull'], ['compose', 'up']] for c in calls(log))
log.write_text('')
result = run(command, root, {**env, 'UPDATE_TEST_UNHEALTHY': '1', 'UPDATE_HEALTH_TIMEOUT_SECONDS': '1'})
assert result.returncode != 0
assert '应用更新成功' not in result.stdout
if name == 'bash':
log.write_text('')
result = run(command, root, {**env, 'UPDATE_SERVICES': 'easyai-server easyai-web', 'UPDATE_RELEASE_VERSION': '3.25.0.20261009.3'})
assert result.returncode == 0, result.stdout + result.stderr
core_values = dict(line.split('=', 1) for line in (root / '.env').read_text().splitlines() if '=' in line and not line.startswith('#'))
assert core_values['VERSION'] == '3.25.0.20261009.3'
assert core_values['MODEL_CONVERSION_WORKER_VERSION'] == values['MODEL_CONVERSION_WORKER_VERSION']
assert ['compose', 'pull', 'easyai-server', 'easyai-web'] in calls(log)
assert ['compose', 'up', '-d', '--no-deps', 'easyai-server', 'easyai-web'] in calls(log)
assert not any('model-conversion-worker' in call for call in calls(log) if call[:2] in [['compose', 'pull'], ['compose', 'up']])
values = core_values
print('bash: core-only release preserves non-target Worker pin passed')
companion_env = {**env, 'UPDATE_SERVICES': 'ws-gateway easyai-updater',
'UPDATE_GATEWAY_VERSION': '3.25.0.20261009',
'UPDATE_UPDATER_VERSION': '3.25.0.20261009'}
original = (root / '.env').read_bytes()
for overrides in ({'UPDATE_GATEWAY_VERSION': 'latest;unsafe'},
{'UPDATE_SERVICES': 'ws-gateway'}):
log.write_text('')
result = run(command, root, {**companion_env, **overrides})
assert result.returncode != 0
assert original == (root / '.env').read_bytes()
assert not any(c[:2] in [['compose', 'pull'], ['compose', 'up']] for c in calls(log))
log.write_text('')
result = run(command, root, companion_env)
assert result.returncode == 0, result.stdout + result.stderr
assert ['compose', 'pull', 'ws-gateway', 'easyai-updater'] in calls(log)
assert ['compose', 'up', '-d', '--no-deps', 'ws-gateway', 'easyai-updater'] in calls(log)
new_values = dict(line.split('=', 1) for line in (root / '.env').read_text().splitlines() if '=' in line and not line.startswith('#'))
assert new_values['WS_GATEWAY_VERSION'] == new_values['UPDATER_VERSION'] == '3.25.0.20261009'
assert new_values['VERSION'] == values['VERSION']
assert new_values['MODEL_CONVERSION_WORKER_VERSION'] == values['MODEL_CONVERSION_WORKER_VERSION']
assert new_values['CONFIG_JWT_SECRET'] == values['CONFIG_JWT_SECRET']
print('bash: independent companion pins preserve non-target versions and reject invalid scope passed')
print(f'{name}: selected services, stable secret, invalid service and unhealthy Worker passed')
case = base / 'self-update'
case.mkdir()
root, log, env = fixture(case)
for args in [['git', 'init', '-q'], ['git', 'config', 'user.name', 'Update test'], ['git', 'config', 'user.email', 'test@example.invalid'], ['git', 'add', '.'], ['git', 'commit', '-qm', 'test: 初始更新脚本']]:
result = run(args, root)
assert result.returncode == 0, result.stderr
remote = case / 'remote.git'
seed = case / 'seed'
assert run(['git', 'clone', '-q', '--bare', str(root), str(remote)], case).returncode == 0
assert run(['git', 'remote', 'add', 'origin', str(remote)], root).returncode == 0
branch = run(['git', 'branch', '--show-current'], root).stdout.strip()
assert run(['git', 'branch', '--set-upstream-to', f'origin/{branch}'], root).returncode != 0
assert run(['git', 'fetch', '-q', 'origin'], root).returncode == 0
assert run(['git', 'branch', '--set-upstream-to', f'origin/{branch}'], root).returncode == 0
assert run(['git', 'clone', '-q', str(remote), str(seed)], case).returncode == 0
for args in [['git', 'config', 'user.name', 'Update test'], ['git', 'config', 'user.email', 'test@example.invalid']]:
assert run(args, seed).returncode == 0
with (seed / 'update.sh').open('a') as stream: stream.write('\n# Updated deployment fixture\n')
for args in [['git', 'add', 'update.sh'], ['git', 'commit', '-qm', 'test: 验证更新脚本自更新'], ['git', 'push', '-q', 'origin', 'HEAD']]:
assert run(args, seed).returncode == 0
compose_path = root / 'docker-compose.yml'
compose_path.write_text(compose_path.read_text().replace('comfy-ai/one-ai:${VERSION}', 'comfy-ai/one-ai@sha256:' + 'a' * 64))
result = run(['bash', 'update.sh'], root, {**env, 'UPDATE_MODE': 'full', 'UPDATE_ADOPT_LEGACY_CONFIG': '1', 'UPDATE_RESTORE_REPOSITORY_IMAGES': 'easyai-web'})
assert result.returncode == 0, result.stdout + result.stderr
assert '切换到新版更新脚本' in result.stdout
assert_selected(root, log, result.stdout)
assert run(['git', 'rev-parse', 'HEAD'], root).stdout == run(['git', 'rev-parse', 'HEAD'], seed).stdout
assert compose_path.read_text() == (seed / 'docker-compose.yml').read_text()
backups = list((root / 'data/deployment-backups').glob('legacy-update-*'))
assert len(backups) == 1
assert json.loads((backups[0] / 'migration.json').read_text())['restoredRepositoryImages'] == ['easyai-web']
print('bash: real Git pull, reviewed image restore and self re-exec preserve targeted update passed')
# Resolve the real deployment Compose separately from the entry-point transport fixtures.
with tempfile.TemporaryDirectory(prefix='easyai-compose-resolution-') as directory:
compose_root = Path(directory)
shutil.copy2(source / 'docker-compose.yml', compose_root / 'docker-compose.yml')
for sample in source.glob('.env*.sample'):
shutil.copy2(sample, compose_root / sample.name.removesuffix('.sample'))
resolved = run(['docker', 'compose', '--env-file', '.env', 'config', '--format', 'json'], compose_root)
assert resolved.returncode == 0, resolved.stderr
config = json.loads(resolved.stdout)
worker = config['services']['model-conversion-worker']
api = config['services']['easyai-server']
assert not worker.get('ports')
assert not worker.get('env_file')
assert worker['read_only'] is True
assert set(worker['networks']) == {'easyai'}
assert float(worker['cpus']) == 2
assert int(worker['mem_limit']) == 4 * 1024**3
assert worker['pids_limit'] == 64
assert worker['environment']['MODEL_CONVERSION_WORKER_TOKEN'] == api['environment']['MODEL_CONVERSION_WORKER_TOKEN']
assert worker['healthcheck']['test'] == ['CMD', 'node', 'src/healthcheck.mjs']
assert api['environment']['MODEL_CONVERSION_HUNYUAN_ENABLED'] == 'false'
print('real Compose: limits, network isolation, shared token and health check passed')
print(json.dumps({'passed': True, 'durationSeconds': round(time.monotonic() - started, 3)}))
-93
View File
@@ -1,93 +0,0 @@
#!/bin/bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=scripts/init-public-api-base-url.sh
. "${SCRIPT_DIR}/init-public-api-base-url.sh"
TMP_DIR="$(mktemp -d)"
trap 'rm -rf "$TMP_DIR"' EXIT
fail() {
echo "FAIL: $*" >&2
exit 1
}
assert_env_value() {
local file="$1"
local expected="$2"
local actual
actual="$(public_url_read_env_value "$file" "CONFIG_PUBLIC_API_BASE_URL")"
[ "$actual" = "$expected" ] || fail "expected $expected, got $actual"
}
write_env() {
local file="$1"
shift
printf '%s\n' "$@" > "$file"
}
existing="$TMP_DIR/existing.env"
write_env "$existing" \
"CONFIG_PUBLIC_API_BASE_URL=https://existing.example.com:8443/api" \
"NUXT_PUBLIC_BASE_APIURL=/api"
init_public_api_base_url "$existing" upgrade >/dev/null
assert_env_value "$existing" "https://existing.example.com:8443/api"
absolute="$TMP_DIR/absolute.env"
write_env "$absolute" \
"CONFIG_PUBLIC_API_BASE_URL=" \
"NUXT_PUBLIC_BASE_APIURL=http://10.0.0.8:4100"
init_public_api_base_url "$absolute" upgrade >/dev/null
assert_env_value "$absolute" "http://10.0.0.8:4100"
websocket="$TMP_DIR/websocket.env"
write_env "$websocket" \
"CONFIG_PUBLIC_API_BASE_URL=" \
"NUXT_PUBLIC_BASE_APIURL=/api" \
"NUXT_PUBLIC_BASE_SOCKETURL=wss://demo.example.com/socket.io"
init_public_api_base_url "$websocket" upgrade >/dev/null
assert_env_value "$websocket" "https://demo.example.com/api"
security_origin="$TMP_DIR/security-origin.env"
write_env "$security_origin" \
"CONFIG_PUBLIC_API_BASE_URL=" \
"NUXT_PUBLIC_BASE_APIURL=/api" \
"NUXT_PUBLIC_BASE_SOCKETURL=" \
"CONFIG_SECURITY_ORIGIN=http://demo.example.com,https://demo.example.com"
init_public_api_base_url "$security_origin" upgrade >/dev/null
assert_env_value "$security_origin" "http://demo.example.com/api"
file_override="$TMP_DIR/file-override.env"
write_env "$file_override" \
"CONFIG_PUBLIC_API_BASE_URL=" \
"DEPLOY_PUBLIC_API_BASE_URL=https://proxy.example.com:9443/custom-api" \
"NUXT_PUBLIC_BASE_APIURL=/api"
init_public_api_base_url "$file_override" upgrade >/dev/null
assert_env_value "$file_override" "https://proxy.example.com:9443/custom-api"
explicit_override="$TMP_DIR/explicit-override.env"
write_env "$explicit_override" \
"CONFIG_PUBLIC_API_BASE_URL=" \
"NUXT_PUBLIC_BASE_APIURL=/api"
init_public_api_base_url "$explicit_override" configure "http://192.168.1.20:4567" >/dev/null
assert_env_value "$explicit_override" "http://192.168.1.20:4567"
invalid="$TMP_DIR/invalid.env"
write_env "$invalid" \
"CONFIG_PUBLIC_API_BASE_URL=ftp://bad.example.com/api" \
"NUXT_PUBLIC_BASE_APIURL=https://good.example.com/api"
if init_public_api_base_url "$invalid" upgrade >/dev/null 2>&1; then
fail "invalid existing CONFIG_PUBLIC_API_BASE_URL must block startup"
fi
unresolved="$TMP_DIR/unresolved.env"
write_env "$unresolved" \
"CONFIG_PUBLIC_API_BASE_URL=" \
"NUXT_PUBLIC_BASE_APIURL=/api"
if init_public_api_base_url "$unresolved" upgrade >/dev/null 2>&1; then
fail "unresolved public API URL must block startup"
fi
echo "Public API base URL shell tests passed"
-35
View File
@@ -1,35 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' EXIT
cat > "$tmp_dir/docker" <<'SH'
#!/usr/bin/env bash
case "$1" in
info) [ "${DOCKER_STUB_STATE:-}" != unavailable ] ;;
container) [ "${DOCKER_STUB_STATE:-}" != absent ] ;;
inspect) printf '%s\n' "${DOCKER_STUB_MOUNT:-}" ;;
*) exit 2 ;;
esac
SH
chmod +x "$tmp_dir/docker"
export PATH="$tmp_dir:$PATH"
DOCKER_STUB_STATE=absent "$script_dir/check-redis-persistence.sh"
DOCKER_STUB_MOUNT="bind|$(cd "$script_dir/../data" && pwd -P)/redis" "$script_dir/check-redis-persistence.sh"
for state in unavailable no_mount wrong_mount; do
case "$state" in
unavailable) export DOCKER_STUB_STATE=unavailable DOCKER_STUB_MOUNT='' ;;
no_mount) export DOCKER_STUB_STATE=present DOCKER_STUB_MOUNT='' ;;
wrong_mount) export DOCKER_STUB_STATE=present DOCKER_STUB_MOUNT='bind|/other/redis' ;;
esac
if "$script_dir/check-redis-persistence.sh" > /dev/null 2>&1; then
echo "unsafe Redis state accepted: $state" >&2
exit 1
fi
done
echo 'Redis persistence guard tests passed'
-123
View File
@@ -1,123 +0,0 @@
#!/usr/bin/env python3
"""Exercise reviewed image-pin adoption with real Git and Compose parsing only."""
import json
import os
from pathlib import Path
import subprocess
import tempfile
import time
source = Path(__file__).resolve().parent.parent
prepare = Path(os.environ.get('EASYAI_PREPARE_UPDATE_SCRIPT', source / 'scripts/prepare-update-repository.py'))
started = time.monotonic()
pin = 'repository-web@sha256:' + 'a' * 64
def run(args, root, env=None):
return subprocess.run(args, cwd=root, env=env, capture_output=True, text=True, timeout=30)
def checked(args, root, env=None):
result = run(args, root, env)
assert result.returncode == 0, result.stdout + result.stderr
return result.stdout
for restore in ('', 'easyai-web', 'redis', 'ws-gateway'):
with tempfile.TemporaryDirectory(prefix='easyai-image-restore-') as directory:
base = Path(directory)
root = base / 'easyai'
root.mkdir()
compose = '''services:
easyai-web:
image: repository-web:${VERSION}
environment:
- EXISTING=keep
easyai-server:
image: repository-server:${VERSION}
environment:
- LOCAL_SETTING=original
redis:
image: redis:7
'''
(root / 'docker-compose.yml').write_text(compose)
(root / '.gitignore').write_text('.env\ndata/\n')
env_original = b'VERSION=latest\nCONFIG_JWT_SECRET=test-private-value\n'
(root / '.env').write_bytes(env_original)
for args in (['git', 'init', '-q', '-b', 'main'], ['git', 'config', 'user.name', 'Update test'], ['git', 'config', 'user.email', 'test@example.invalid'], ['git', 'add', '.'], ['git', 'commit', '-qm', 'test: 旧部署基准']):
checked(args, root)
remote = base / 'remote.git'
seed = base / 'seed'
checked(['git', 'clone', '-q', '--bare', str(root), str(remote)], base)
checked(['git', 'remote', 'add', 'origin', str(remote)], root)
checked(['git', 'fetch', '-q'], root)
checked(['git', 'branch', '--set-upstream-to', 'origin/main'], root)
checked(['git', 'clone', '-q', str(remote), str(seed)], base)
(seed / 'docker-compose.yml').write_text(compose.replace(' - EXISTING=keep', ' - EXISTING=keep\n - NEW_SETTING=from-repository'))
for args in (['git', 'config', 'user.name', 'Update test'], ['git', 'config', 'user.email', 'test@example.invalid'], ['git', 'add', '.'], ['git', 'commit', '-qm', 'test: 新部署配置'], ['git', 'push', '-q']):
checked(args, seed)
original = compose.replace('repository-web:${VERSION}', pin).replace('LOCAL_SETTING=original', 'LOCAL_SETTING=custom').replace('redis:7', 'redis:6.2')
(root / 'docker-compose.yml').write_text(original)
env = {**os.environ, 'UPDATE_RESTORE_REPOSITORY_IMAGES': restore, 'UPDATE_SERVICES': 'easyai-server easyai-web'}
result = run(['python3', str(prepare), str(root)], root, env)
if restore in ('redis', 'ws-gateway'):
assert result.returncode != 0, result.stdout
assert (root / 'docker-compose.yml').read_text() == original
assert (root / '.env').read_bytes() == env_original
assert not (root / 'data').exists()
continue
assert result.returncode == 0, result.stdout + result.stderr
checked(['git', 'pull', '--ff-only'], root)
config = json.loads(checked(['docker', 'compose', '--env-file', '.env', 'config', '--format', 'json'], root))
services = config['services']
assert services['easyai-web']['image'] == ('repository-web:latest' if restore else pin), services['easyai-web']['image']
assert services['easyai-web']['environment']['NEW_SETTING'] == 'from-repository'
assert services['easyai-server']['environment']['LOCAL_SETTING'] == 'custom'
assert services['redis']['image'] == 'redis:6.2'
assert 'CONFIG_JWT_SECRET=test-private-value' in (root / '.env').read_text()
backups = list((root / 'data/deployment-backups').glob('legacy-update-*'))
assert len(backups) == 1
assert (backups[0] / 'docker-compose.yml').read_text() == original
assert (backups[0] / '.env').read_bytes() == env_original
assert (backups[0] / '.env').stat().st_mode & 0o077 == 0
metadata = json.loads((backups[0] / 'migration.json').read_text())
if restore:
assert metadata['restoredRepositoryImages'] == ['easyai-web']
# A second adoption preserves the result and creates no extra backup.
checked(['python3', str(prepare), str(root)], root, env)
assert len(list((root / 'data/deployment-backups').glob('legacy-update-*'))) == 1
# A later main-file customization is adopted without dropping the
# existing override or changing an unrelated dependency setting.
main_path = root / 'docker-compose.yml'
main_path.write_text(main_path.read_text().replace('LOCAL_SETTING=original', 'LOCAL_SETTING=second-custom'))
override_file = root / 'data/deployment-config/legacy-compose.json'
previous_override = override_file.read_bytes()
checked(['python3', str(prepare), str(root)], root, env)
config = json.loads(checked(['docker', 'compose', '--env-file', '.env', 'config', '--format', 'json'], root))
assert config['services']['easyai-server']['environment']['LOCAL_SETTING'] == 'second-custom'
assert config['services']['redis']['image'] == 'redis:6.2'
assert config['services']['easyai-web']['image'] == ('repository-web:latest' if restore else pin)
backups = list((root / 'data/deployment-backups').glob('legacy-update-*'))
assert len(backups) == 2
newest = max(backups, key=lambda p: p.stat().st_mtime)
assert (newest / 'data/deployment-config/legacy-compose.json').read_bytes() == previous_override
assert (newest / 'data/deployment-config/legacy-compose.json').stat().st_mode & 0o077 == 0
if not restore:
# Main is clean, but an existing override still pins the web image.
# Explicit restoration must act on that override and be idempotent.
restoration_env = {**env, 'UPDATE_RESTORE_REPOSITORY_IMAGES': 'easyai-web'}
checked(['python3', str(prepare), str(root)], root, restoration_env)
config = json.loads(checked(['docker', 'compose', '--env-file', '.env', 'config', '--format', 'json'], root))
assert config['services']['easyai-web']['image'] == 'repository-web:latest'
assert config['services']['redis']['image'] == 'redis:6.2'
assert config['services']['easyai-server']['environment']['LOCAL_SETTING'] == 'second-custom'
assert len(list((root / 'data/deployment-backups').glob('legacy-update-*'))) == 3
checked(['python3', str(prepare), str(root)], root, restoration_env)
assert len(list((root / 'data/deployment-backups').glob('legacy-update-*'))) == 3
for mode, adopt in (('image', '1'), ('full', '0')):
env = {**os.environ, 'UPDATE_MODE': mode, 'UPDATE_ADOPT_LEGACY_CONFIG': adopt, 'UPDATE_RESTORE_REPOSITORY_IMAGES': 'easyai-web'}
result = run(['bash', str(source / 'update.sh')], source, env)
assert result.returncode != 0 and '尚未修改配置或容器' in result.stderr
print(json.dumps({'passed': True, 'cases': 6, 'durationSeconds': round(time.monotonic() - started, 3)}))
-144
View File
@@ -1,144 +0,0 @@
#!/bin/bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=scripts/init-security-env.sh
. "${SCRIPT_DIR}/init-security-env.sh"
TMP_DIR="$(mktemp -d)"
trap 'rm -rf "$TMP_DIR"' EXIT
fail() {
echo "FAIL: $*" >&2
exit 1
}
write_env() {
local file="$1"
shift
printf '%s\n' "$@" > "$file"
}
assert_min_length() {
local file="$1"
local key="$2"
local minimum="$3"
local value
value="$(read_env_value "$file" "$key")"
[ "${#value}" -ge "$minimum" ] || fail "$key must contain at least $minimum characters"
}
assert_value() {
local file="$1"
local key="$2"
local expected="$3"
local actual
actual="$(read_env_value "$file" "$key")"
[ "$actual" = "$expected" ] || fail "$key expected '$expected', got '$actual'"
}
assert_single_key() {
local file="$1"
local key="$2"
local count
count="$(grep -c "^${key}=" "$file" || true)"
[ "$count" -eq 1 ] || fail "$key must appear exactly once"
}
file_sha256() {
local file="$1"
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$file" | awk '{print $1}'
else
shasum -a 256 "$file" | awk '{print $1}'
fi
}
upgrade_env="$TMP_DIR/upgrade.env"
upgrade_log="$TMP_DIR/upgrade.log"
write_env "$upgrade_env" \
"CONFIG_JWT_SECRET='this is a very secret secret'" \
"CONFIG_SECURITY_CONFIG_ENCRYPTION_KEY=" \
"WS_AUTH_WS_TICKET_SECRET=" \
"WS_AUTH_METHODS=none,bearer"
init_security_env "$upgrade_env" upgrade > "$upgrade_log"
assert_min_length "$upgrade_env" "CONFIG_JWT_SECRET" 32
assert_min_length "$upgrade_env" "WS_AUTH_WS_TICKET_SECRET" 32
assert_min_length "$upgrade_env" "MODEL_CONVERSION_WORKER_TOKEN" 32
assert_value "$upgrade_env" "CONFIG_SECURITY_CONFIG_ENCRYPTION_KEY" ""
assert_value "$upgrade_env" "WS_AUTH_METHODS" "none,bearer,ws_ticket"
assert_single_key "$upgrade_env" "WS_AUTH_WS_TICKET_SECRET"
assert_single_key "$upgrade_env" "WS_AUTH_METHODS"
upgrade_jwt="$(read_env_value "$upgrade_env" "CONFIG_JWT_SECRET")"
upgrade_ws="$(read_env_value "$upgrade_env" "WS_AUTH_WS_TICKET_SECRET")"
upgrade_worker="$(read_env_value "$upgrade_env" "MODEL_CONVERSION_WORKER_TOKEN")"
if grep -Fq "$upgrade_jwt" "$upgrade_log" || grep -Fq "$upgrade_ws" "$upgrade_log" || grep -Fq "$upgrade_worker" "$upgrade_log"; then
fail "security initialization output must not reveal generated secrets"
fi
before_repeat="$(file_sha256 "$upgrade_env")"
init_security_env "$upgrade_env" upgrade > "$TMP_DIR/repeat.log"
after_repeat="$(file_sha256 "$upgrade_env")"
[ "$before_repeat" = "$after_repeat" ] || fail "repeated upgrade must preserve generated values byte-for-byte"
preserved_secret="existing-ws-ticket-secret-that-is-long-enough"
preserved_env="$TMP_DIR/preserved.env"
write_env "$preserved_env" \
"CONFIG_JWT_SECRET=existing-jwt-secret-that-is-long-enough" \
"WS_AUTH_WS_TICKET_SECRET=$preserved_secret" \
"MODEL_CONVERSION_WORKER_TOKEN=$preserved_secret" \
"WS_AUTH_METHODS=bearer,ws_ticket"
init_security_env "$preserved_env" upgrade > "$TMP_DIR/preserved.log"
assert_value "$preserved_env" "WS_AUTH_WS_TICKET_SECRET" "$preserved_secret"
assert_value "$preserved_env" "MODEL_CONVERSION_WORKER_TOKEN" "$preserved_secret"
assert_value "$preserved_env" "WS_AUTH_METHODS" "bearer,ws_ticket"
new_env="$TMP_DIR/new.env"
write_env "$new_env" \
"CONFIG_JWT_SECRET=" \
"CONFIG_SECURITY_CONFIG_ENCRYPTION_KEY=" \
"CONFIG_OTP_HASH_SECRET=" \
"CONFIG_AUDIT_HASH_PEPPER=" \
"CONFIG_AUDIT_INTEGRITY_KEY=" \
"CONFIG_INITIAL_ADMIN_PASSWORD=" \
"WS_AUTH_WS_TICKET_SECRET=" \
"WS_AUTH_METHODS="
init_security_env "$new_env" new > "$TMP_DIR/new.log"
for key in \
CONFIG_JWT_SECRET \
CONFIG_SECURITY_CONFIG_ENCRYPTION_KEY \
CONFIG_OTP_HASH_SECRET \
CONFIG_AUDIT_HASH_PEPPER \
CONFIG_AUDIT_INTEGRITY_KEY \
WS_AUTH_WS_TICKET_SECRET; do
assert_min_length "$new_env" "$key" 32
done
assert_min_length "$new_env" "CONFIG_INITIAL_ADMIN_PASSWORD" 12
assert_value "$new_env" "WS_AUTH_METHODS" "none,bearer,ws_ticket"
mode="$(stat -c '%a' "$new_env" 2>/dev/null || stat -f '%Lp' "$new_env")"
[ "$mode" = "600" ] || fail "generated environment file mode must be 600, got $mode"
assert_value "$upgrade_env" CONFIG_CREDENTIAL_ENCRYPTION_KEYRING ''
assert_value "$new_env" CONFIG_CREDENTIAL_ALLOW_LEGACY_PLAINTEXT false
new_ring="$(read_env_value "$new_env" CONFIG_CREDENTIAL_ENCRYPTION_KEYRING)"
new_key="${new_ring#*:\"}"; new_key="${new_key%\"\}}"
[ "$(printf '%s' "$new_key" | openssl base64 -d -A | wc -c | tr -d ' ')" = 32 ] || fail 'credential key length must be 32 bytes'
if grep -Fq "$new_key" "$TMP_DIR/new.log"; then fail 'credential key must not appear in logs'; fi
DEPLOY_ENABLE_CREDENTIAL_ENCRYPTION=true init_security_env "$upgrade_env" upgrade > "$TMP_DIR/opt-in.log"
assert_value "$upgrade_env" CONFIG_CREDENTIAL_ALLOW_LEGACY_PLAINTEXT true
opted_ring="$(read_env_value "$upgrade_env" CONFIG_CREDENTIAL_ENCRYPTION_KEYRING)"
init_security_env "$upgrade_env" upgrade > "$TMP_DIR/opt-repeat.log"
assert_value "$upgrade_env" CONFIG_CREDENTIAL_ENCRYPTION_KEYRING "$opted_ring"
write_env "$TMP_DIR/partial.env" CONFIG_CREDENTIAL_ENCRYPTION_ACTIVE_KEY_ID=v1 CONFIG_CREDENTIAL_ENCRYPTION_KEYRING=
if init_security_env "$TMP_DIR/partial.env" upgrade > "$TMP_DIR/partial.log" 2>&1; then fail 'partial keyring must fail'; fi
rotate_key="$(openssl rand -base64 32 | tr -d '\r\n')"
rotated="${new_ring%\}},\"next\":\"${rotate_key}\"}"
write_env_value "$new_env" CONFIG_CREDENTIAL_ENCRYPTION_KEYRING "'$rotated'"
write_env_value "$new_env" CONFIG_CREDENTIAL_ENCRYPTION_ACTIVE_KEY_ID next
init_security_env "$new_env" upgrade > "$TMP_DIR/rotation.log"
assert_value "$new_env" CONFIG_CREDENTIAL_ENCRYPTION_KEYRING "$rotated"
echo "Security environment shell tests passed"
-50
View File
@@ -1,50 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=scripts/init-security-origin.sh
. "${script_dir}/init-security-origin.sh"
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' EXIT
assert_value() {
local actual
actual="$(public_url_read_env_value "$1" CONFIG_SECURITY_ORIGIN)"
[ "$actual" = "$2" ] || { echo "unexpected origin: $actual" >&2; exit 1; }
}
cat > "$tmp_dir/missing.env" <<'ENV'
CONFIG_PUBLIC_API_BASE_URL=https://zaowua.com/api
ENV
before="$(shasum -a 256 "$tmp_dir/missing.env" | cut -d ' ' -f 1)"
init_security_origin "$tmp_dir/missing.env" > /dev/null
assert_value "$tmp_dir/missing.env" ''
[ "$before" = "$(shasum -a 256 "$tmp_dir/missing.env" | cut -d ' ' -f 1)" ]
first_hash="$(shasum -a 256 "$tmp_dir/missing.env" | cut -d ' ' -f 1)"
init_security_origin "$tmp_dir/missing.env" > /dev/null
[ "$first_hash" = "$(shasum -a 256 "$tmp_dir/missing.env" | cut -d ' ' -f 1)" ]
cat > "$tmp_dir/sample.env" <<'ENV'
CONFIG_PUBLIC_API_BASE_URL=https://zaowua.com/api
CONFIG_SECURITY_ORIGIN=http://127.0.0.1,http://localhost
ENV
init_security_origin "$tmp_dir/sample.env" > /dev/null
assert_value "$tmp_dir/sample.env" ''
cat > "$tmp_dir/preserved.env" <<'ENV'
CONFIG_PUBLIC_API_BASE_URL=https://zaowua.com/api
CONFIG_SECURITY_ORIGIN=https://zaowua.com,https://www.zaowua.com
ENV
before="$(shasum -a 256 "$tmp_dir/preserved.env" | cut -d ' ' -f 1)"
init_security_origin "$tmp_dir/preserved.env" > /dev/null
[ "$before" = "$(shasum -a 256 "$tmp_dir/preserved.env" | cut -d ' ' -f 1)" ]
for invalid in '*' 'https://zaowua.com/api' 'https://zaowua.com,' 'https://user:pass@zaowua.com'; do
printf 'CONFIG_PUBLIC_API_BASE_URL=https://zaowua.com/api\nCONFIG_SECURITY_ORIGIN=%s\n' "$invalid" > "$tmp_dir/invalid.env"
if init_security_origin "$tmp_dir/invalid.env" > /dev/null 2>&1; then
echo "accepted invalid origin: $invalid" >&2
exit 1
fi
done
echo 'Security origin migration tests passed'
-37
View File
@@ -1,37 +0,0 @@
#!/bin/bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TMP_DIR="$(mktemp -d)"
trap 'rm -rf "$TMP_DIR"' EXIT
# shellcheck source=init-server-http-bind-ip.sh
. "$REPO_ROOT/scripts/init-server-http-bind-ip.sh"
write_env() {
printf '%s\n' "$1" > "$TMP_DIR/.env"
}
write_env 'NUXT_PUBLIC_BASE_APIURL=/api'
init_server_http_bind_ip "$TMP_DIR/.env" >/dev/null
grep -qx 'SERVER_HTTP_BIND_IP=127.0.0.1' "$TMP_DIR/.env"
write_env 'NUXT_PUBLIC_BASE_APIURL=http://10.0.0.8:3001'
init_server_http_bind_ip "$TMP_DIR/.env" >/dev/null
grep -qx 'SERVER_HTTP_BIND_IP=0.0.0.0' "$TMP_DIR/.env"
write_env 'NUXT_PUBLIC_BASE_APIURL=http://127.0.0.1:3001'
init_server_http_bind_ip "$TMP_DIR/.env" >/dev/null
grep -qx 'SERVER_HTTP_BIND_IP=127.0.0.1' "$TMP_DIR/.env"
write_env $'NUXT_PUBLIC_BASE_APIURL=/api\nSERVER_HTTP_BIND_IP=0.0.0.0'
init_server_http_bind_ip "$TMP_DIR/.env" >/dev/null
grep -qx 'SERVER_HTTP_BIND_IP=0.0.0.0' "$TMP_DIR/.env"
if init_server_http_bind_ip "$TMP_DIR/.env" '192.168.1.8' >/dev/null 2>&1; then
echo 'Invalid SERVER_HTTP_BIND_IP unexpectedly accepted' >&2
exit 1
fi
echo 'Server HTTP bind IP shell tests passed'
-27
View File
@@ -1,27 +0,0 @@
#!/usr/bin/env python3
# 验证 start.ps1 的 .env 替换逻辑(与 PowerShell 等效)
import re
DEPLOY_IP = "192.168.1.100"
content = """NUXT_PUBLIC_BASE_APIURL=http://127.0.0.1:3001
#NUXT_PUBLIC_BASE_APIURL=/api
NUXT_PUBLIC_BASE_SOCKETURL=ws://127.0.0.1:3002
NUXT_PUBLIC_SG_APIURL=http://127.0.0.1:3003
"""
content = re.sub(r'^NUXT_PUBLIC_BASE_APIURL=.*', f'NUXT_PUBLIC_BASE_APIURL=http://{DEPLOY_IP}:3001', content, flags=re.MULTILINE)
content = re.sub(r'^NUXT_PUBLIC_BASE_SOCKETURL=.*', f'NUXT_PUBLIC_BASE_SOCKETURL=ws://{DEPLOY_IP}:3002', content, flags=re.MULTILINE)
content = re.sub(r'^NUXT_PUBLIC_SG_APIURL=.*', f'NUXT_PUBLIC_SG_APIURL=http://{DEPLOY_IP}:3003', content, flags=re.MULTILINE)
expected = f"""NUXT_PUBLIC_BASE_APIURL=http://{DEPLOY_IP}:3001
#NUXT_PUBLIC_BASE_APIURL=/api
NUXT_PUBLIC_BASE_SOCKETURL=ws://{DEPLOY_IP}:3002
NUXT_PUBLIC_SG_APIURL=http://{DEPLOY_IP}:3003
"""
# 注释行不应被替换,检查第一行和第三行
lines = content.split('\n')
assert lines[0] == f'NUXT_PUBLIC_BASE_APIURL=http://{DEPLOY_IP}:3001', f"Got {lines[0]}"
assert lines[2] == f'NUXT_PUBLIC_BASE_SOCKETURL=ws://{DEPLOY_IP}:3002', f"Got {lines[2]}"
assert lines[3] == f'NUXT_PUBLIC_SG_APIURL=http://{DEPLOY_IP}:3003', f"Got {lines[3]}"
assert lines[1] == '#NUXT_PUBLIC_BASE_APIURL=/api', f"Comment should not change: {lines[1]}"
print("OK: .env replacement logic validated")
-67
View File
@@ -1,67 +0,0 @@
#!/bin/bash
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TMP_DIR="$(mktemp -d)"
cleanup() {
find "$TMP_DIR" -type f -delete
rmdir "$TMP_DIR"
}
trap cleanup EXIT
LEGACY_ENV_FILE="$TMP_DIR/legacy.env"
cat > "$LEGACY_ENV_FILE" <<'EOF'
EXISTING_VALUE=keep-me
SYSTEM_UPDATE_MODE=legacy_watchtower
COMPOSE_PROFILES=customer-profile
WATCHTOWER_CLEANUP=
EOF
bash "$PROJECT_ROOT/scripts/apply-update-env-migrations.sh" "$LEGACY_ENV_FILE" "$PROJECT_ROOT" >/dev/null
grep -qx 'EXISTING_VALUE=keep-me' "$LEGACY_ENV_FILE"
grep -qx 'SYSTEM_UPDATE_MODE=legacy_watchtower' "$LEGACY_ENV_FILE"
grep -qx 'COMPOSE_PROFILES=customer-profile' "$LEGACY_ENV_FILE"
grep -qx 'WATCHTOWER_CLEANUP=' "$LEGACY_ENV_FILE"
grep -qx 'WATCHTOWER_HTTP_API_PERIODIC_POLLS=false' "$LEGACY_ENV_FILE"
grep -qx 'WATCHTOWER_UPDATE_TARGET=easyai-server easyai-wsgateway easyai-web' "$LEGACY_ENV_FILE"
grep -qx 'WATCHTOWER_IMAGE=registry.cn-shanghai.aliyuncs.com/comfy-ai/watchtower-aliyun:latest' "$LEGACY_ENV_FILE"
grep -qx 'MODEL_CONVERSION_WORKER_VERSION=latest' "$LEGACY_ENV_FILE"
grep -qx 'MODEL_CONVERSION_HUNYUAN_ENABLED=false' "$LEGACY_ENV_FILE"
cp "$LEGACY_ENV_FILE" "$TMP_DIR/legacy-first-run.env"
bash "$PROJECT_ROOT/scripts/apply-update-env-migrations.sh" "$LEGACY_ENV_FILE" "$PROJECT_ROOT" >/dev/null
cmp "$LEGACY_ENV_FILE" "$TMP_DIR/legacy-first-run.env"
ENV_FILE="$TMP_DIR/.env"
cat > "$ENV_FILE" <<'EOF'
EXISTING_VALUE=keep-me
COMPOSE_PROFILES=customer-profile
WATCHTOWER_CLEANUP=
EOF
bash "$PROJECT_ROOT/scripts/apply-update-env-migrations.sh" "$ENV_FILE" "$PROJECT_ROOT" >/dev/null
read_value() {
awk -v key="$1" 'index($0, key "=") == 1 { print substr($0, length(key) + 2); exit }' "$ENV_FILE"
}
[ "$(read_value EXISTING_VALUE)" = "keep-me" ]
[ "$(read_value SYSTEM_UPDATE_MODE)" = "managed" ]
[ "$(read_value COMPOSE_PROFILES)" = "customer-profile,managed-update" ]
[ "$(read_value WATCHTOWER_CLEANUP)" = "true" ]
[ "$(read_value WATCHTOWER_HTTP_API_PERIODIC_POLLS)" = "true" ]
[ "$(read_value WATCHTOWER_CORE_UPDATE_ENABLED)" = "false" ]
[ "$(read_value WATCHTOWER_UPDATE_TARGET)" = "easyai-updater" ]
[ "$(read_value SYSTEM_UPDATER_UPDATE_INTERVAL_SECONDS)" = "86400" ]
[ "$(read_value UPDATE_HEALTH_TIMEOUT_MS)" = "300000" ]
[ "$(read_value DOCKER_CONFIG_FILE)" = "/root/.docker/config.json" ]
[ "$(read_value EASYAI_DEPLOYMENT_SCHEMA_VERSION)" = "2" ]
[ "$(read_value MODEL_CONVERSION_WORKER_VERSION)" = "latest" ]
[ "$(read_value MODEL_CONVERSION_HUNYUAN_ENABLED)" = "false" ]
cp "$ENV_FILE" "$TMP_DIR/first-run.env"
bash "$PROJECT_ROOT/scripts/apply-update-env-migrations.sh" "$ENV_FILE" "$PROJECT_ROOT" >/dev/null
cmp "$TMP_DIR/first-run.env" "$ENV_FILE"
echo "Update environment migration tests passed"
@@ -1,11 +0,0 @@
# 可查询进度的在线更新模式。已有非空配置保持不变。
@skip-if SYSTEM_UPDATE_MODE=legacy_watchtower
SYSTEM_UPDATE_MODE=managed
COMPOSE_PROFILES+=managed-update
WATCHTOWER_CORE_UPDATE_ENABLED=false
WATCHTOWER_UPDATE_TARGET=easyai-updater
SYSTEM_UPDATER_UPDATE_INTERVAL_SECONDS=86400
WATCHTOWER_CLEANUP=true
UPDATE_HEALTH_TIMEOUT_MS=300000
DOCKER_CONFIG_FILE=/root/.docker/config.json
EASYAI_DEPLOYMENT_SCHEMA_VERSION=2
@@ -1,2 +0,0 @@
# 两种更新模式均启用内网接口;安全令牌由安全初始化脚本生成。
WATCHTOWER_IMAGE=registry.cn-shanghai.aliyuncs.com/comfy-ai/watchtower-aliyun:latest
@@ -1,2 +0,0 @@
@skip-if SYSTEM_UPDATE_MODE=legacy_watchtower
WATCHTOWER_HTTP_API_PERIODIC_POLLS=true
@@ -1,3 +0,0 @@
@skip-if SYSTEM_UPDATE_MODE=managed
WATCHTOWER_HTTP_API_PERIODIC_POLLS=false
WATCHTOWER_UPDATE_TARGET=easyai-server easyai-wsgateway easyai-web
@@ -1,2 +0,0 @@
# 宿主机定时维护更新组件,保留客户明确关闭的选择。
INFRASTRUCTURE_AUTO_UPDATE_ENABLED=true
@@ -1,3 +0,0 @@
# 独立模型转换;不改变现有在线更新模式与付费选择。
MODEL_CONVERSION_WORKER_VERSION=latest
MODEL_CONVERSION_HUNYUAN_ENABLED=false
-81
View File
@@ -1,81 +0,0 @@
#!/bin/bash
set -euo pipefail
PROJECT_ROOT="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
cd "$PROJECT_ROOT"
. ./scripts/init-security-env.sh
[ "$(read_env_value .env INFRASTRUCTURE_AUTO_UPDATE_ENABLED)" != false ] || exit 0
state="$PROJECT_ROOT/data/infrastructure-update"
mkdir -p "$state"
lock="$state/lock"
if ! mkdir "$lock" 2>/dev/null; then
pid="$(cat "$lock/pid" 2>/dev/null || true)"
if [[ "$pid" =~ ^[0-9]+$ ]] && ! kill -0 "$pid" 2>/dev/null; then
rm -f "$lock/pid"
rmdir "$lock" 2>/dev/null || exit 0
mkdir "$lock" 2>/dev/null || exit 0
else
exit 0
fi
fi
printf '%s\n' "$$" > "$lock/pid"
trap 'rm -f "$lock/pid"; rmdir "$lock" 2>/dev/null || true' EXIT
exec > "$state/last-run.log" 2>&1
printf '%s 检查 Watchtower 和 updater 更新\n' "$(date '+%Y-%m-%d %H:%M:%S')"
registry_config="$(read_env_value .env DOCKER_CONFIG_FILE)"
docker_options=()
if [ -n "$registry_config" ] && [ -f "$registry_config" ]; then
docker_options=(--config "$(dirname "$registry_config")")
fi
if docker "${docker_options[@]}" compose version >/dev/null 2>&1; then
compose() { docker "${docker_options[@]}" compose "$@"; }
else
compose() {
if [ -n "$registry_config" ] && [ -f "$registry_config" ]; then
DOCKER_CONFIG="$(dirname "$registry_config")" docker-compose "$@"
else
docker-compose "$@"
fi
}
fi
available="$(compose config --services)"
printf '%s\n' "$available" | grep -qx watchtower || { echo '未配置 Watchtower'; exit 1; }
maintain_service() {
local service="$1" container_id status attempt
echo "检查 $service"
# A failed service must not block the other update channel.
compose pull "$service" || return 1
compose up -d --no-deps "$service" || return 1
for ((attempt=0; attempt<12; attempt++)); do
container_id="$(compose ps -q "$service")" || return 1
if [ -n "$container_id" ]; then
status="$(docker inspect -f '{{.State.Running}} {{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$container_id")" || return 1
if [ "$status" = 'true healthy' ]; then
echo "$service 已恢复可用"
return 0
fi
if [ "$status" = 'true none' ]; then
if [ "$service" = watchtower ]; then
if docker exec "$container_id" /watchtower --health-check; then return 0; fi
else
if docker exec "$container_id" node -e "require('http').get('http://127.0.0.1:3015/health',r=>{r.resume();process.exit(r.statusCode===200?0:1)}).on('error',()=>process.exit(1))"; then return 0; fi
fi
fi
fi
sleep 5
done
echo "$service 暂未恢复可用" >&2
return 1
}
failed=0
for service in watchtower easyai-updater; do
# config --services respects active profiles: legacy deployments stay legacy.
if ! printf '%s\n' "$available" | grep -qx "$service"; then
echo "$service 未启用,跳过维护"
continue
fi
if ! maintain_service "$service"; then
echo "$service 本次维护未成功,下次定时任务将继续检查" >&2
failed=1
fi
done
exit "$failed"
-478
View File
@@ -1,478 +0,0 @@
#Requires -Version 5.1
#
# 从 Git 克隆后首次部署(推荐分行复制执行,避免参数被误传给 git;浅克隆加 --depth 1):
# git clone --depth 1 "https://git.51easyai.com/wangbo/easyai.git" "easyai"
# Set-Location ".\easyai"
# powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".\start.ps1"
#
# 若必须一行,请用分号分隔整条链,且 -File 后必须是带引号的脚本路径(不要用 Istart.ps1,应为 .\start.ps1):
# git clone --depth 1 "https://git.51easyai.com/wangbo/easyai.git" "easyai"; Set-Location "easyai"; powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".\start.ps1"
#
# 若出现 error: unknown switch 'E':说明 -ExecutionPolicy 被交给了 git(可复现:git clone URL -ExecutionPolicy)。
# 常见原因:整段命令未用分号分隔、或把 powershell 与 git 写在同一行且参数被 git 解析。
# "Istart.ps1" 多为把 ".\start.ps1" 复制错(点反斜杠被看成字母 I)。
#
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
$OutputEncoding = [System.Text.Encoding]::UTF8
$ErrorActionPreference = "Stop"
$script:Root = if ($PSScriptRoot) { $PSScriptRoot } else { (Get-Location).Path }
$script:LogFile = Join-Path $script:Root "start.ps1.log"
$script:DeployDryRun = ($env:DEPLOY_DRY_RUN -eq "1")
$script:DeployIP = ""
$script:SkipDeployQuestions = $false
$script:SecurityEnvMode = "upgrade"
$script:PublicApiBaseUrlCandidate = ""
$script:DockerDesktopUrl = "https://desktop.docker.com/win/main/amd64/Docker%20Desktop%20Installer.exe"
function Write-Log {
param([string]$Message)
try {
Add-Content -Path $script:LogFile -Encoding UTF8 -Value ("[{0}] {1}" -f (Get-Date -Format "yyyy-MM-dd HH:mm:ss"), $Message)
} catch { }
}
function Wait-ForExit {
if ($env:CI -eq "true") { return }
if ($env:DEPLOY_NO_WAIT -eq "1") { return }
Write-Host ""
Read-Host "Press Enter to exit"
}
trap {
$msg = $_.Exception.Message
Write-Log "FATAL: $msg"
Write-Host ""
Write-Host "========================================" -ForegroundColor Red
Write-Host "Script failed. Log file: $script:LogFile" -ForegroundColor Red
Write-Host "========================================" -ForegroundColor Red
Write-Host $msg -ForegroundColor Red
Wait-ForExit
exit 1
}
function Step { param([string]$Message) Write-Host $Message }
function Ok { param([string]$Message) Write-Host "[OK] $Message" -ForegroundColor Green }
function Warn { param([string]$Message) Write-Host "[WARN] $Message" -ForegroundColor Yellow }
function Fail { param([string]$Message) Write-Host "[ERR] $Message" -ForegroundColor Red; throw $Message }
function Init-ProjectDir {
$composePath = Join-Path $script:Root "docker-compose.yml"
if (-not (Test-Path $composePath)) {
Fail "docker-compose.yml not found. Please run this script in easyai directory."
}
Set-Location $script:Root
Step "Project directory: $script:Root"
}
function Ensure-FileFromSample {
param([string]$Target,[string]$Sample)
if (Test-Path $Target) { return }
if (-not (Test-Path $Sample)) { Fail "Missing sample file: $Sample" }
Copy-Item $Sample $Target
Ok "$Target created"
}
function Get-DetectedLanIp {
try {
$ip = Get-NetIPAddress -AddressFamily IPv4 -ErrorAction SilentlyContinue | Where-Object {
$_.IPAddress -notmatch "^127\." -and
$_.IPAddress -notmatch "^169\.254\." -and
$_.InterfaceAlias -notmatch "Loopback"
} | Sort-Object InterfaceIndex | Select-Object -First 1 -ExpandProperty IPAddress
return $ip
} catch {
return $null
}
}
function Run-DeployQuestions {
Write-Host ""
Write-Host "================================"
Write-Host " EasyAI Deployment Config"
Write-Host "================================"
Write-Host ""
if ($env:DEPLOY_IP) {
$script:DeployIP = $env:DEPLOY_IP.Trim()
Step "Using DEPLOY_IP=$($script:DeployIP)"
return
}
if ($env:DEPLOY_ACCESS) {
$mode = $env:DEPLOY_ACCESS.Trim().ToLowerInvariant()
switch ($mode) {
{ $_ -in @("local", "localhost", "127.0.0.1") } {
$script:DeployIP = "127.0.0.1"
Step "Using DEPLOY_ACCESS=$($env:DEPLOY_ACCESS), IP=$($script:DeployIP)"
return
}
{ $_ -in @("lan", "ip") } {
Fail "DEPLOY_ACCESS=$($env:DEPLOY_ACCESS) requires DEPLOY_IP, for example: `$env:DEPLOY_IP='192.168.1.10'"
}
default {
Fail "DEPLOY_ACCESS only supports local or lan on Windows, current value: $($env:DEPLOY_ACCESS)"
}
}
}
if ($env:DEPLOY_NON_INTERACTIVE -eq "1" -or $env:CI -eq "true") {
Fail "Non-interactive deployment requires DEPLOY_IP or DEPLOY_ACCESS=local."
}
Write-Host "Choose mode:"
Write-Host " [1] Local only (127.0.0.1)"
Write-Host " [2] LAN access"
$choice = Read-Host "Select [1/2]"
switch ($choice) {
"1" { $script:DeployIP = "127.0.0.1"; Step "Selected local mode" }
"2" {
$detected = Get-DetectedLanIp
if ($detected) {
Write-Host "Detected LAN IP: $detected"
$inputIp = (Read-Host "Input LAN IP (Enter to use detected)").Trim()
$script:DeployIP = if ([string]::IsNullOrWhiteSpace($inputIp)) { $detected } else { $inputIp }
} else {
$inputIp = (Read-Host "Input LAN IP").Trim()
if ([string]::IsNullOrWhiteSpace($inputIp)) { Fail "IP cannot be empty." }
$script:DeployIP = $inputIp
}
Warn "Allow firewall ports: 3001, 3002, 3003"
}
default { Fail "Invalid selection." }
}
}
function Upsert-Env {
param([string]$Content,[string]$Key,[string]$Value)
$line = "$Key=$Value"
$pattern = "(?m)^$Key=.*$"
if ($Content -match $pattern) { return ($Content -replace $pattern, $line) }
if ($Content -and -not $Content.EndsWith("`n")) { $Content += "`n" }
return ($Content + $line + "`n")
}
function Setup-EnvFiles {
Write-Host ""
Step "Configuring env files..."
Ensure-FileFromSample ".env.tools" ".env.tools.sample"
Ensure-FileFromSample ".env.ASG" ".env.ASG.sample"
Ensure-FileFromSample ".env.AMS" ".env.AMS.sample"
if (-not (Test-Path ".env")) { $script:SecurityEnvMode = "new" }
Ensure-FileFromSample ".env" ".env.sample"
$content = Get-Content ".env" -Raw -Encoding UTF8
if (-not $content) { $content = "" }
$serverPortMatch = [regex]::Match($content, '(?m)^SERVER_HTTP_PORT=(\d+)$')
$serverPort = if ($serverPortMatch.Success) { $serverPortMatch.Groups[1].Value } else { "3001" }
$publicApiBaseUrl = "http://$($script:DeployIP):$serverPort"
$content = Upsert-Env $content "NUXT_PUBLIC_BASE_APIURL" $publicApiBaseUrl
$content = Upsert-Env $content "NUXT_PUBLIC_BASE_SOCKETURL" "ws://$($script:DeployIP):3002"
$content = Upsert-Env $content "NUXT_PUBLIC_SG_APIURL" "http://$($script:DeployIP):3003"
$serverBindIp = if ($script:DeployIP -eq "127.0.0.1" -or $script:DeployIP -eq "localhost") { "127.0.0.1" } else { "0.0.0.0" }
$content = Upsert-Env $content "SERVER_HTTP_BIND_IP" $serverBindIp
# Windows 当前仍使用 Docker Desktop 直连端口;显式覆盖 Linux Nginx 模式的回环默认值。
$content = Upsert-Env $content "EASYAI_PROXY_BIND_IP" $serverBindIp
$script:PublicApiBaseUrlCandidate = if ($env:DEPLOY_PUBLIC_API_BASE_URL) {
$env:DEPLOY_PUBLIC_API_BASE_URL
} else {
$publicApiBaseUrl
}
[System.IO.File]::WriteAllText((Join-Path $script:Root ".env"), $content, [System.Text.UTF8Encoding]::new($false))
Ok ".env configured for IP=$($script:DeployIP)"
}
function Test-DockerInstalled {
$docker = Get-Command docker -ErrorAction SilentlyContinue
if (-not $docker) { return $false }
try { & docker --version *> $null; return $true } catch { return $false }
}
function Test-DockerRunning {
param([int]$TimeoutSeconds = 10)
try {
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = "docker"
$psi.Arguments = "info"
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
$psi.UseShellExecute = $false
$psi.CreateNoWindow = $true
$p = [System.Diagnostics.Process]::Start($psi)
$ok = $p.WaitForExit($TimeoutSeconds * 1000)
if (-not $ok) { try { $p.Kill() } catch { }; return $false }
return ($p.ExitCode -eq 0)
} catch {
return $false
}
}
function Start-DockerDesktopAndWait {
param([int]$MaxWaitSeconds = 120, [int]$CheckIntervalSeconds = 5)
Step "Docker not running, trying to start Docker Desktop..."
$path = Join-Path $env:ProgramFiles "Docker\Docker\Docker Desktop.exe"
if (-not (Test-Path $path)) { $path = Join-Path ${env:ProgramFiles(x86)} "Docker\Docker\Docker Desktop.exe" }
if (-not (Test-Path $path)) { Warn "Docker Desktop executable not found."; return $false }
$startedByCli = $false
try {
& docker desktop start *> $null
if ($LASTEXITCODE -eq 0) { $startedByCli = $true; Step "Sent docker desktop start command" }
} catch { }
if (-not $startedByCli) { Start-Process -FilePath $path -WindowStyle Hidden }
$elapsed = 0
while ($elapsed -lt $MaxWaitSeconds) {
Start-Sleep -Seconds $CheckIntervalSeconds
$elapsed += $CheckIntervalSeconds
if (Test-DockerRunning) { Ok "Docker engine ready"; return $true }
}
Warn "Timeout waiting for Docker."
return $false
}
function Ensure-DockerRunning {
if (-not (Test-DockerInstalled)) { return $false }
if (Test-DockerRunning) { Ok "Docker engine running"; return $true }
return (Start-DockerDesktopAndWait)
}
function Install-DockerDesktop {
function Test-Winget {
return [bool](Get-Command winget -ErrorAction SilentlyContinue)
}
function Test-Choco {
return [bool](Get-Command choco -ErrorAction SilentlyContinue)
}
if (Test-Winget) {
Step "Installing Docker Desktop via winget..."
Write-Log "winget install Docker.DockerDesktop"
$wingetOut = & winget install --id Docker.DockerDesktop -e --accept-source-agreements --accept-package-agreements 2>&1
$wingetOut | ForEach-Object { Write-Host $_ }
$wingetCode = $LASTEXITCODE
Write-Log "winget exit code: $wingetCode"
if ($wingetCode -eq 0) {
Ok "Install started. Reopen terminal and rerun script."
Write-Log "winget reported success"
exit 0
}
if (Test-DockerInstalled) {
Ok "Docker CLI detected after winget. Reopen terminal if docker commands fail, then rerun script."
Write-Log "winget exit $wingetCode but docker is available"
exit 0
}
Warn "winget did not report success (exit code: $wingetCode). Trying Chocolatey if available..."
} else {
Warn "winget not found (install 'App Installer' from Microsoft Store or use Windows 11 / recent Windows 10). Trying Chocolatey if available..."
}
if (Test-Choco) {
Step "Installing Docker Desktop via choco..."
Write-Log "choco install docker-desktop"
$chocoOut = & choco install docker-desktop -y 2>&1
$chocoOut | ForEach-Object { Write-Host $_ }
$chocoCode = $LASTEXITCODE
Write-Log "choco exit code: $chocoCode"
if ($chocoCode -eq 0) {
Ok "Install started. Reopen terminal and rerun script."
exit 0
}
if (Test-DockerInstalled) {
Ok "Docker CLI detected after choco. Reopen terminal if docker commands fail, then rerun script."
exit 0
}
Warn "choco did not report success (exit code: $chocoCode)."
} else {
Warn "Chocolatey (choco) not found."
}
Warn "Automatic install was not completed. Please install Docker Desktop manually:"
Write-Host " $script:DockerDesktopUrl"
Write-Log "Docker auto-install failed (winget/choco unavailable or non-zero exit)"
Wait-ForExit
exit 1
}
function Test-Docker {
Write-Host ""
Write-Host "================================"
Write-Host " Docker Check"
Write-Host "================================"
Write-Host ""
if (Test-DockerInstalled) {
Ok "Docker installed"
if (-not (Ensure-DockerRunning)) { Warn "Docker failed to start automatically."; Wait-ForExit; exit 1 }
return
}
Warn "Docker Desktop not detected."
$installMode = if ($env:DEPLOY_DOCKER_INSTALL) { $env:DEPLOY_DOCKER_INSTALL.Trim().ToLowerInvariant() } else { "" }
if (-not [string]::IsNullOrWhiteSpace($installMode)) {
switch ($installMode) {
{ $_ -in @("manual", "1") } { Start-Process $script:DockerDesktopUrl; Wait-ForExit; exit 1 }
{ $_ -in @("auto", "2") } { Install-DockerDesktop }
default { Fail "DEPLOY_DOCKER_INSTALL only supports manual or auto, current value: $($env:DEPLOY_DOCKER_INSTALL)" }
}
}
if ($env:DEPLOY_NON_INTERACTIVE -eq "1" -or $env:CI -eq "true") {
Fail "Docker Desktop not detected. Set DEPLOY_DOCKER_INSTALL=manual or DEPLOY_DOCKER_INSTALL=auto, or install Docker Desktop first."
}
Write-Host "Choose:"
Write-Host " [1] Manual install (open URL and exit)"
Write-Host " [2] Auto install (winget/choco)"
$choice = (Read-Host "Select [1/2]").Trim()
switch ($choice) {
"1" { Start-Process $script:DockerDesktopUrl; Wait-ForExit; exit 1 }
"2" { Install-DockerDesktop }
default { Fail "Invalid selection." }
}
}
function Start-Services {
Write-Host ""
Step "Starting EasyAI services..."
. (Join-Path $script:Root "scripts\Assert-RedisPersistence.ps1")
Assert-RedisPersistence -ProjectRoot $script:Root
$useComposeV2 = $false
try { & docker compose version *> $null; if ($LASTEXITCODE -eq 0) { $useComposeV2 = $true } } catch { }
if ($useComposeV2) {
docker compose pull
if ($LASTEXITCODE -ne 0) { Fail "docker compose pull failed." }
docker compose up -d
if ($LASTEXITCODE -ne 0) { Fail "docker compose up failed." }
} else {
docker-compose pull
if ($LASTEXITCODE -ne 0) { Fail "docker-compose pull failed." }
docker-compose up -d
if ($LASTEXITCODE -ne 0) { Fail "docker-compose up failed." }
}
Ok "EasyAI started"
}
function Show-AdminLoginInfo {
Write-Host ""
if ($script:SecurityEnvMode -ne "new") {
Write-Host " 管理员账号: admin(本次部署不会生成或重置已有密码)" -ForegroundColor Yellow
Write-Host " 登录提示: 若数据库尚未创建 admin,请查看 .env -> CONFIG_INITIAL_ADMIN_PASSWORD;若 admin 已存在,请使用当前密码。" -ForegroundColor DarkGray
return
}
$content = Get-Content (Join-Path $script:Root ".env") -Raw -Encoding UTF8
$initialAdminPassword = Get-EnvValue $content "CONFIG_INITIAL_ADMIN_PASSWORD"
if ([string]::IsNullOrWhiteSpace($initialAdminPassword)) {
Fail "Cannot read initial admin password from .env -> CONFIG_INITIAL_ADMIN_PASSWORD"
}
$heading = if ($script:DeployDryRun) {
" -------- 初始管理员(Docker 首次启动后生效)--------"
} else {
" -------- 初始管理员(首次登录后请立即修改密码)--------"
}
Write-Host $heading -ForegroundColor Yellow
Write-Host " 登录账号: " -NoNewline; Write-Host "admin" -ForegroundColor White
Write-Host " 登录密码: " -NoNewline; Write-Host $initialAdminPassword -ForegroundColor White
Write-Host " 生命周期: 仅在数据库首次创建 admin 时使用;创建后修改 .env 不会重置密码。" -ForegroundColor DarkGray
Write-Host " 有效期: 初始密码没有独立过期时间,在管理员修改密码前持续有效。" -ForegroundColor DarkGray
}
function Main {
Init-ProjectDir
if ((Test-Path ".env") -and -not $env:DEPLOY_FORCE_RECONFIG -and -not $env:DEPLOY_IP -and -not $env:DEPLOY_ACCESS) {
if ($env:DEPLOY_NON_INTERACTIVE -eq "1" -or $env:CI -eq "true") {
$line = Get-Content ".env" -Encoding UTF8 | Where-Object { $_ -like "NUXT_PUBLIC_BASE_APIURL=*" } | Select-Object -First 1
if ($line -match '^NUXT_PUBLIC_BASE_APIURL=http://([^/:]+)(?::\d+)?(?:/.*)?$') { $script:DeployIP = $Matches[1] }
$script:SkipDeployQuestions = $true
Step "Using existing env config"
} else {
$answer = (Read-Host "Existing .env found. Reconfigure? [y/N]").Trim().ToLower()
$yes = ($answer.Length -gt 0) -and ($answer[0] -eq [char]121)
if (-not $yes) {
$line = Get-Content ".env" -Encoding UTF8 | Where-Object { $_ -like "NUXT_PUBLIC_BASE_APIURL=*" } | Select-Object -First 1
if ($line -match '^NUXT_PUBLIC_BASE_APIURL=http://([^/:]+)(?::\d+)?(?:/.*)?$') { $script:DeployIP = $Matches[1] }
$script:SkipDeployQuestions = $true
Step "Using existing env config"
}
}
}
if (-not $script:SkipDeployQuestions) {
Run-DeployQuestions
Setup-EnvFiles
} else {
Ensure-FileFromSample ".env.tools" ".env.tools.sample"
Ensure-FileFromSample ".env.ASG" ".env.ASG.sample"
Ensure-FileFromSample ".env.AMS" ".env.AMS.sample"
}
. (Join-Path $script:Root "scripts\Initialize-ServerHttpBindIp.ps1")
$serverBindOverride = if ($script:SkipDeployQuestions) {
""
} elseif ($script:DeployIP -eq "127.0.0.1" -or $script:DeployIP -eq "localhost") {
"127.0.0.1"
} else {
"0.0.0.0"
}
Initialize-ServerHttpBindIp `
-Path (Join-Path $script:Root ".env") `
-Override $serverBindOverride
. (Join-Path $script:Root "scripts\Initialize-SecurityEnv.ps1")
Initialize-SecurityEnv -Path (Join-Path $script:Root ".env") -Mode $script:SecurityEnvMode
& (Join-Path $script:Root "scripts/Initialize-InfrastructureUpdateEnv.ps1") -ProjectRoot $script:Root
. (Join-Path $script:Root "scripts\Initialize-PublicApiBaseUrl.ps1")
$publicUrlMode = if ($script:SkipDeployQuestions) { "upgrade" } else { "configure" }
Initialize-PublicApiBaseUrl `
-Path (Join-Path $script:Root ".env") `
-Mode $publicUrlMode `
-Override $script:PublicApiBaseUrlCandidate
# CONFIG_SECURITY_ORIGIN is optional. Blank means allow all; explicit values are validated.
. (Join-Path $script:Root "scripts\Initialize-SecurityOrigin.ps1")
Initialize-SecurityOrigin -Path (Join-Path $script:Root ".env")
if ($script:DeployDryRun) {
Warn "dry-run mode: skip docker and services"
& (Join-Path $script:Root "scripts/Install-InfrastructureUpdateTask.ps1") -ProjectRoot $script:Root -DryRun
} else {
Test-Docker
Start-Services
& (Join-Path $script:Root "scripts/Install-InfrastructureUpdateTask.ps1") -ProjectRoot $script:Root
}
$ip = if ($script:DeployIP) { $script:DeployIP } else { "127.0.0.1" }
Write-Host ""
if ($script:DeployDryRun) {
Write-Host "================================" -ForegroundColor Yellow
Write-Host " 配置已生成(未启动 Docker 服务)" -ForegroundColor Yellow
Write-Host "================================" -ForegroundColor Yellow
Write-Host ""
Write-Host " 预期访问: " -NoNewline; Write-Host "http://${ip}:3010" -ForegroundColor Cyan
} else {
Write-Host "================================" -ForegroundColor Green
Write-Host " 部署成功" -ForegroundColor Green
Write-Host "================================" -ForegroundColor Green
Write-Host ""
Write-Host " 访问地址: " -NoNewline; Write-Host "http://${ip}:3010" -ForegroundColor Cyan
}
Show-AdminLoginInfo
Write-Host ""
Wait-ForExit
}
Write-Log "=== script start ==="
Write-Host "EasyAI deploy script starting..." -ForegroundColor Cyan
Write-Host "Log file: $script:LogFile"
Write-Host ""
Main
Write-Log "=== script end ==="
+159 -673
View File
@@ -1,700 +1,186 @@
#!/bin/bash
# EasyAI 一键部署脚本
# 支持交互式问答配置,兼容 IP 与域名两种访问方式
# 一行命令: git clone --depth 1 https://git.51easyai.com/wangbo/easyai.git && cd easyai && chmod +x ./start.sh && ./start.sh
set -e
set -e # 发生错误时终止脚本执行
# 仅配置模式(验证用):DEPLOY_DRY_RUN=1 只生成配置文件,不执行 Docker 安装和启动
DEPLOY_DRY_RUN="${DEPLOY_DRY_RUN:-0}"
echo "==========================="
echo "🚀 开始自动安装 Docker 和 Docker Compose"
echo "==========================="
# ==================== 项目初始化 ====================
init_project_dir() {
local script_source
script_source="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
if [ -f "${script_source}/docker-compose.yml" ]; then
echo "📁 项目目录: ${script_source}"
cd "$script_source"
# 获取操作系统类型和版本
OS_FAMILY=$(hostnamectl | grep "Operating System" | awk '{print $3}')
OS_VERSION_ID=$(grep -oP '(?<=^VERSION_ID=")[0-9.]+' /etc/os-release | cut -d'.' -f1)
OS_CODENAME=""
if [[ "$OS_FAMILY" == "Ubuntu" ]]; then
OS_CODENAME=$(lsb_release -cs)
fi
# 定义国内镜像源
# Ubuntu 镜像源
UBUNTU_DOCKER_MIRROR_URL="https://mirrors.ustc.edu.cn/docker-ce" # 中科大
UBUNTU_DOCKER_GPG_URL="https://mirrors.ustc.edu.cn/docker-ce/linux/ubuntu/gpg"
# CentOS 镜像源 (选择一个即可,这里提供清华和阿里云)
CENTOS_DOCKER_MIRROR_URL="https://mirrors.tuna.tsinghua.edu.cn/docker-ce" # 清华大学
# CENTOS_DOCKER_MIRROR_URL="https://mirrors.aliyun.com/docker-ce" # 阿里云 - 如果清华源不稳定可以尝试这个
# 函数:检查并等待网络连接
check_network() {
echo "🌐 检查网络连接到 Docker 官方仓库 (备用)..."
if curl -sSf https://download.docker.com/ &> /dev/null; then
echo "✅ 网络连接到 Docker 官方仓库正常。"
return 0
else
echo "⚠️ 无法连接到 Docker 官方仓库。将尝试使用国内镜像源。"
# 即使无法连接官方源,也继续尝试国内源
return 0
fi
echo "❌ 未找到 docker-compose.yml,请在 easyai 项目目录下运行 start.sh"
echo " 启动命令: git clone --depth 1 https://git.51easyai.com/wangbo/easyai.git && cd easyai && chmod +x ./start.sh && ./start.sh"
exit 1
}
# ==================== 配置变量(支持环境变量非交互模式) ====================
DEPLOY_ACCESS="${DEPLOY_ACCESS:-}" # ip | domain
DEPLOY_MODE="" # ip | domain | skip
DEPLOY_IP="${DEPLOY_IP:-}"
DEPLOY_DOMAIN="${DEPLOY_DOMAIN:-}"
DEPLOY_HTTPS_ENV="${DEPLOY_HTTPS_INPUT:-${DEPLOY_HTTPS:-}}"
DEPLOY_HTTPS=false
SECURITY_ENV_MODE="upgrade"
PUBLIC_API_BASE_URL_CANDIDATE=""
PROXY_CONF=""
# 预检网络连接(非阻塞,仅作为提示)
check_network
is_non_interactive() {
[ "${DEPLOY_NON_INTERACTIVE:-0}" = "1" ] || [ "${CI:-}" = "true" ] || [ ! -t 0 ]
}
# Docker 安装
if command -v docker &> /dev/null; then
echo "✅ Docker 已安装,跳过安装步骤"
else
if [[ "$OS_FAMILY" == "Ubuntu" ]]; then
echo "📦 安装依赖 (Ubuntu)..."
sudo apt update -y
sudo apt install -y apt-transport-https ca-certificates curl software-properties-common gnupg lsb-release
require_interactive() {
local missing_hint=$1
if is_non_interactive; then
echo "❌ 当前为非交互环境,无法继续问答配置"
echo " 请通过环境变量传入: ${missing_hint}"
echo "🔑 添加 Docker GPG 密钥 (Ubuntu) - 优先使用国内镜像源..."
# 现代 Ubuntu 推荐使用 gpg --dearmor
sudo install -m 0755 -d /etc/apt/keyrings
if ! curl -fsSL "$UBUNTU_DOCKER_GPG_URL" | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg; then
echo "❌ 无法从国内镜像源获取 GPG 密钥,尝试从官方源获取..."
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg || {
echo "❌ 无法获取 Docker GPG 密钥,请检查网络或 GPG 密钥 URL。"
exit 1
}
fi
sudo chmod a+r /etc/apt/keyrings/docker.gpg # 确保可读权限
echo "🌍 添加 Docker 源 (Ubuntu) - 使用国内镜像源..."
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] $UBUNTU_DOCKER_MIRROR_URL/linux/ubuntu \
$OS_CODENAME stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt-get update -y
echo "🐳 安装 Docker (Ubuntu)..."
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin || {
echo "❌ Docker 或其组件安装失败。请检查错误日志或尝试手动安装。"
exit 1
}
elif [[ "$OS_FAMILY" == "CentOS" ]]; then
echo "📦 安装依赖 (CentOS)..."
sudo yum install -y yum-utils device-mapper-persistent-data lvm2
echo "🌍 添加 Docker 源 (CentOS) - 使用国内镜像源..."
# 添加 Docker CE 官方 repo
sudo yum-config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo
# 替换为国内镜像源
echo "🔄 替换 Docker 源为国内镜像 ($CENTOS_DOCKER_MIRROR_URL)..."
sudo sed -i "s+https://download.docker.com+$CENTOS_DOCKER_MIRROR_URL+" /etc/yum.repos.d/docker-ce.repo || {
echo "❌ 替换 Docker 源失败,可能 repo 文件路径不正确或无权限。"
exit 1
}
# CentOS 8+ 可能会遇到 module 冲突,禁用默认的 container-tools 模块
if [[ "$OS_VERSION_ID" -ge "8" ]]; then
echo "⚙️ 禁用 CentOS 8+ 默认的 container-tools 模块以避免冲突..."
sudo yum module disable -y container-tools
sudo yum module enable -y container-tools:docker
fi
sudo yum makecache
echo "🐳 安装 Docker (CentOS)..."
sudo yum install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin || {
echo "❌ Docker 或其组件安装失败。请检查错误日志或尝试手动安装。"
echo "提示:如果仍遇到下载问题,请检查网络、防火墙或尝试切换另一个国内镜像源。"
exit 1
}
else
echo "❌ 未知操作系统 ($OS_FAMILY),无法安装 Docker。"
exit 1
fi
}
normalize_bool() {
local value
value="$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')"
case "$value" in
1|true|yes|y|on) echo "true" ;;
0|false|no|n|off|"") echo "false" ;;
*)
echo "❌ 布尔变量值不合法: $1(支持 true/false/1/0/yes/no/y/n/on/off)" >&2
return 1
;;
esac
}
echo "✅ 启动并设置 Docker 开机自启..."
sudo systemctl enable docker
sudo systemctl start docker
upsert_env_value() {
local file=$1
local key=$2
local value=$3
if grep -q "^${key}=" "$file"; then
sed -i.bak "s|^${key}=.*|${key}=${value}|" "$file"
# 将当前用户添加到 docker 组,以便无需 sudo 运行 Docker 命令
if ! getent group docker | grep -qw "$USER"; then
echo "👥 将当前用户 ($USER) 添加到 docker 组..."
sudo usermod -aG docker "$USER"
echo "🔔 请注销并重新登录,以便更改生效。"
fi
fi
# Docker Compose 检查和安装 (优先使用 docker-compose-plugin)
if command -v docker &> /dev/null && docker compose version &> /dev/null; then
echo "✅ Docker Compose (插件版) 已安装,跳过安装步骤"
elif command -v docker-compose &> /dev/null && ! command -v docker &> /dev/null; then
echo "⚠️ 检测到旧版 Docker Compose 独立安装,但未检测到 Docker 插件版。"
echo "建议在安装 Docker CE 时一起安装 docker-compose-plugin。"
else
echo "⚙️ 安装 Docker Compose (插件版)..."
PLUGIN_INSTALL_SUCCESS=1
# 如果 Docker CE 安装成功,docker-compose-plugin 应该已经安装了。
# 这里是额外的检查,以防万一。
if [[ "$OS_FAMILY" == "Ubuntu" ]]; then
sudo apt-get install -y docker-compose-plugin || PLUGIN_INSTALL_SUCCESS=0
elif [[ "$OS_FAMILY" == "CentOS" ]]; then
sudo yum install -y docker-compose-plugin || PLUGIN_INSTALL_SUCCESS=0
else
printf '\n%s=%s\n' "$key" "$value" >> "$file"
fi
}
set_deploy_https_from_env_or_default() {
local normalized
if ! normalized="$(normalize_bool "${DEPLOY_HTTPS_ENV:-false}")"; then
exit 1
fi
DEPLOY_HTTPS="$normalized"
}
infer_deploy_access_from_env() {
if [ -z "$DEPLOY_ACCESS" ]; then
if [ -n "$DEPLOY_DOMAIN" ]; then
DEPLOY_ACCESS="domain"
elif [ -n "$DEPLOY_IP" ]; then
DEPLOY_ACCESS="ip"
fi
fi
}
sanitize_domain() {
local domain="$1"
domain="${domain#http://}"
domain="${domain#https://}"
domain="${domain%%/*}"
domain="${domain%;}"
echo "$domain"
}
validate_domain() {
local domain="$1"
# 基础域名校验:仅允许字母、数字、连字符和点;必须包含至少一个点;不能以点或连字符开头结尾
if [[ ! "$domain" =~ ^[A-Za-z0-9.-]+$ ]]; then
return 1
fi
if [[ "$domain" != *.* ]]; then
return 1
fi
if [[ "$domain" =~ ^[.-] || "$domain" =~ [.-]$ ]]; then
return 1
fi
if [[ "$domain" == *".."* ]]; then
return 1
fi
return 0
}
prompt_or_env() {
local var_name=$1
local prompt_text=$2
local env_name=$3
local default=$4
if [ -n "${!env_name}" ]; then
eval "$var_name=\"${!env_name}\""
return
echo "❌ 未知操作系统,无法安装 Docker Compose 插件版。"
PLUGIN_INSTALL_SUCCESS=0
fi
require_interactive "$env_name"
if [ -n "$default" ]; then
read -r -p "${prompt_text} [$default]: " input
eval "$var_name=\"${input:-$default}\""
# 检查插件版安装是否成功
if [[ $PLUGIN_INSTALL_SUCCESS -eq 1 ]]; then
echo "✅ Docker Compose 插件版安装成功"
else
read -r -p "${prompt_text}: " input
eval "$var_name=\"$input\""
fi
}
echo "⚠️ Docker Compose 插件版安装失败,尝试使用本地二进制文件安装..."
#将文件移动至/usr/bin目录下,并重命名
sudo cp ./docker-compose-linux-x86_64 /usr/bin/docker-compose
# 添加执行权限
sudo chmod +x /usr/bin/docker-compose
run_deploy_questions() {
echo ""
echo "================================"
echo " EasyAI 部署配置(问答模式)"
echo "================================"
echo ""
infer_deploy_access_from_env
# 非交互模式:环境变量已完整设置则直接使用(CI/自动化部署)
if [ -n "$DEPLOY_ACCESS" ]; then
if [ "$DEPLOY_ACCESS" = "ip" ] && [ -n "$DEPLOY_IP" ]; then
DEPLOY_MODE="ip"
echo "使用环境变量: IP 模式, IP=$DEPLOY_IP"
return
fi
if [ "$DEPLOY_ACCESS" = "domain" ] && [ -n "$DEPLOY_DOMAIN" ]; then
DEPLOY_DOMAIN="$(sanitize_domain "$DEPLOY_DOMAIN")"
if ! validate_domain "$DEPLOY_DOMAIN"; then
echo "❌ 环境变量 DEPLOY_DOMAIN 格式不合法: ${DEPLOY_DOMAIN}"
exit 1
fi
DEPLOY_MODE="domain"
set_deploy_https_from_env_or_default
echo "使用环境变量: 域名模式, 域名=$DEPLOY_DOMAIN"
return
fi
if [ "$DEPLOY_ACCESS" != "ip" ] && [ "$DEPLOY_ACCESS" != "domain" ]; then
echo "❌ DEPLOY_ACCESS 只能是 ip 或 domain,当前为: $DEPLOY_ACCESS"
# 验证安装
if command -v docker-compose &> /dev/null; then
echo "✅ Docker Compose 二进制文件安装成功"
else
echo "❌ Docker Compose 二进制文件安装失败,请手动检查。"
exit 1
fi
fi
fi
# 1. IP 或域名访问
if [ -z "$DEPLOY_ACCESS" ]; then
require_interactive "DEPLOY_ACCESS=ip DEPLOY_IP=<服务器IP> 或 DEPLOY_ACCESS=domain DEPLOY_DOMAIN=<域名>"
echo "1. 通过 IP 地址还是域名访问?"
echo " [1] IP 地址(仅需开放 80 端口,由 Nginx 统一反向代理)"
echo " [2] 域名"
read -r -p "请选择 [1/2]: " choice
case "$choice" in
1) DEPLOY_MODE="ip" ;;
2) DEPLOY_MODE="domain" ;;
*) echo "❌ 无效选择"; exit 1 ;;
esac
else
DEPLOY_MODE="$DEPLOY_ACCESS"
fi
if [ "$DEPLOY_MODE" = "ip" ]; then
# 2. 输入服务器 IP
prompt_or_env DEPLOY_IP "2. 请输入服务器 IP 地址" "DEPLOY_IP" ""
if [ -z "$DEPLOY_IP" ]; then
echo "❌ IP 不能为空"
exit 1
fi
echo " 请确保防火墙已放行 80 端口"
else
# 3. 输入域名
prompt_or_env DEPLOY_DOMAIN "3. 请输入域名(不含 https:// 前缀,如 51easyai.com)" "DEPLOY_DOMAIN" ""
DEPLOY_DOMAIN="$(sanitize_domain "$DEPLOY_DOMAIN")"
if [ -z "$DEPLOY_DOMAIN" ]; then
echo "❌ 域名不能为空"
exit 1
fi
if ! validate_domain "$DEPLOY_DOMAIN"; then
echo "❌ 域名格式不合法: ${DEPLOY_DOMAIN}"
echo " 请输入类似 51easyai.com 的域名,不要包含协议、路径或分号"
exit 1
fi
echo "📌 Docker 运行状态:"
sudo systemctl status docker --no-pager || true
# 3.1 是否启用 HTTPS
if [ -n "$DEPLOY_HTTPS_ENV" ]; then
set_deploy_https_from_env_or_default
else
require_interactive "DEPLOY_HTTPS=true 或 DEPLOY_HTTPS=false"
read -r -p "3.1 是否签名 HTTPS 证书?原来已经有 HTTPS 证书这里选择 N,第一次部署或者原来没有证书选 Y [y/N]: " https_choice
DEPLOY_HTTPS=false
if [[ "$https_choice" =~ ^[yY] ]]; then
DEPLOY_HTTPS=true
fi
fi
if [ "$DEPLOY_HTTPS" = true ]; then
echo " 启用 HTTPS 需确保防火墙已放行 80、443 端口"
fi
fi
}
echo "📌 Docker Compose 版本:"
# 优先使用 docker compose 命令(新版),如果不行再尝试 docker-compose(旧版或别名)
if command -v docker &> /dev/null && docker compose version &> /dev/null; then
docker compose version
elif command -v docker-compose &> /dev/null; then
docker-compose -v
else
echo "❌ 无法检测 Docker Compose 版本。"
fi
# ==================== 生成配置文件 ====================
setup_env_files() {
echo ""
echo "📝 配置环境文件..."
echo "🎉 Docker 和 Docker Compose 已就绪!"
# 6. 复制 .env.tools、.env.ASG、.env.AMS(无 example 后缀的从 .sample 生成)
if [ ! -f .env.tools ]; then
cp .env.tools.sample .env.tools
echo " ✓ .env.tools"
fi
if [ ! -f .env.ASG ]; then
cp .env.ASG.sample .env.ASG
echo " ✓ .env.ASG"
fi
if [ ! -f .env.AMS ]; then
cp .env.AMS.sample .env.AMS
echo " ✓ .env.AMS"
fi
# 4/5. 配置 .env
if [ ! -f .env ]; then
cp .env.sample .env
SECURITY_ENV_MODE="new"
fi
local web_port server_port ws_port asg_port public_scheme socket_scheme public_host
web_port="$(awk -F= '$1 == "WEB_PORT" { print $2; exit }' .env | tr -d '[:space:]')"
web_port="${web_port:-3010}"
server_port="$(awk -F= '$1 == "SERVER_HTTP_PORT" { print $2; exit }' .env | tr -d '[:space:]')"
server_port="${server_port:-3001}"
ws_port="$(awk -F= '$1 == "CONFIG_WS_PORT" { print $2; exit }' .env | tr -d '[:space:]')"
ws_port="${ws_port:-3002}"
asg_port="$(awk -F= '$1 == "ASG_PORT" { print $2; exit }' .env | tr -d '[:space:]')"
asg_port="${asg_port:-3003}"
public_scheme="http"
socket_scheme="ws"
if [ "$DEPLOY_MODE" = "ip" ]; then
public_host="$DEPLOY_IP"
else
public_host="$DEPLOY_DOMAIN"
if [ "$DEPLOY_HTTPS" = true ]; then
public_scheme="https"
socket_scheme="wss"
fi
fi
# IP 与域名模式共用同一组 Nginx 路径,只由公开 origin 决定 Host 与协议。
upsert_env_value .env NUXT_PUBLIC_BASE_APIURL "/api"
upsert_env_value .env NUXT_PUBLIC_BASE_SOCKETURL "${socket_scheme}://${public_host}/socket.io"
upsert_env_value .env NUXT_PUBLIC_SG_APIURL "/asg-api"
upsert_env_value .env EASYAI_PROXY_BIND_IP "127.0.0.1"
upsert_env_value .env EASYAI_INFRA_BIND_IP "127.0.0.1"
upsert_env_value .env GATEWAY_INBOUND_TCP_LISTEN_HOST "172.21.0.6"
PUBLIC_API_BASE_URL_CANDIDATE="${DEPLOY_PUBLIC_API_BASE_URL:-${public_scheme}://${public_host}/api}"
if [ "$DEPLOY_MODE" = "ip" ]; then
echo " ✓ .env 已配置为 IP + Nginx 模式 (${DEPLOY_IP})"
# 生成包含前端、API、WebSocket 与 ASG 的完整 IP 代理配置。
PROXY_CONF="${DEPLOY_IP}.conf"
if [ ! -f "$PROXY_CONF" ]; then
local proxy_template="easyai-proxy.conf.sample"
if [ ! -f "$proxy_template" ]; then
echo "❌ 未找到 Nginx 配置模板: easyai-proxy.conf.sample"
echo " 请确认仓库文件完整后重试"
exit 1
fi
sed \
-e '/^# EASYAI_WWW_REDIRECT_BEGIN$/,/^# EASYAI_WWW_REDIRECT_END$/d' \
-e "s/51easyai\.com/${DEPLOY_IP}/g" \
-e "s|127\.0\.0\.1:3010|127.0.0.1:${web_port}|g" \
-e "s|127\.0\.0\.1:3001|127.0.0.1:${server_port}|g" \
-e "s|127\.0\.0\.1:3002|127.0.0.1:${ws_port}|g" \
-e "s|127\.0\.0\.1:3003|127.0.0.1:${asg_port}|g" \
"$proxy_template" > "$PROXY_CONF"
echo " ✓ Nginx IP 配置已生成: $PROXY_CONF"
fi
else
echo " ✓ .env 已配置为域名模式 (${DEPLOY_DOMAIN})"
# 7. Nginx 配置(域名模式)
PROXY_CONF="${DEPLOY_DOMAIN}.conf"
if [ ! -f "$PROXY_CONF" ]; then
local proxy_template="easyai-proxy.conf.sample"
if [ ! -f "$proxy_template" ]; then
echo "❌ 未找到 Nginx 配置模板: easyai-proxy.conf.sample"
echo " 请确认仓库文件完整后重试"
exit 1
fi
sed \
-e "s/51easyai\.com/${DEPLOY_DOMAIN}/g" \
-e "s|127\.0\.0\.1:3010|127.0.0.1:${web_port}|g" \
-e "s|127\.0\.0\.1:3001|127.0.0.1:${server_port}|g" \
-e "s|127\.0\.0\.1:3002|127.0.0.1:${ws_port}|g" \
-e "s|127\.0\.0\.1:3003|127.0.0.1:${asg_port}|g" \
"$proxy_template" > "$PROXY_CONF"
echo " ✓ Nginx 配置已生成: $PROXY_CONF"
fi
fi
rm -f .env.bak
}
# ==================== Docker 安装(复用原 start.sh 逻辑) ====================
install_docker() {
echo ""
echo "================================"
echo " Docker 安装与检查"
echo "================================"
local host_os
host_os="$(uname -s 2>/dev/null || echo Unknown)"
if [[ "$host_os" == "Darwin" ]]; then
OS_FAMILY="macOS"
OS_VERSION_ID="0"
OS_CODENAME=""
elif [ -f /etc/os-release ]; then
. /etc/os-release
case "${ID:-}" in
ubuntu|debian) OS_FAMILY="Ubuntu" ;;
centos|rhel|fedora) OS_FAMILY="CentOS" ;;
*) OS_FAMILY="${ID:-Unknown}" ;;
esac
OS_VERSION_ID=$(grep -oP '(?<=^VERSION_ID=")[0-9.]+' /etc/os-release 2>/dev/null | cut -d'.' -f1 || echo "0")
OS_CODENAME=""
if [[ "$OS_FAMILY" == "Ubuntu" ]]; then
OS_CODENAME=$(lsb_release -cs 2>/dev/null || (grep VERSION_CODENAME /etc/os-release 2>/dev/null | cut -d= -f2 | tr -d '"'))
fi
else
OS_FAMILY=$(hostnamectl 2>/dev/null | grep "Operating System" | awk '{print $3}' || echo "Unknown")
OS_VERSION_ID="0"
OS_CODENAME=""
fi
UBUNTU_DOCKER_MIRROR_URL="https://mirrors.ustc.edu.cn/docker-ce"
UBUNTU_DOCKER_GPG_URL="https://mirrors.ustc.edu.cn/docker-ce/linux/ubuntu/gpg"
CENTOS_DOCKER_MIRROR_URL="https://mirrors.tuna.tsinghua.edu.cn/docker-ce"
check_network() {
if curl -sSf --connect-timeout 5 https://download.docker.com/ &>/dev/null; then
echo "✅ 网络连接正常"
return 0
fi
echo "⚠️ 将使用国内镜像源"
return 0
}
check_network
install_docker_macos() {
if ! command -v brew &>/dev/null; then
echo "❌ 未检测到 Homebrew,无法自动安装 Docker Desktop。"
echo " 请先安装 Homebrew: https://brew.sh/"
return 1
fi
if brew list --cask docker &>/dev/null; then
echo "✅ Docker Desktop 已安装(Homebrew)"
else
echo "📦 正在通过 Homebrew 安装 Docker Desktop..."
brew install --cask docker
fi
echo "🚀 正在尝试启动 Docker Desktop..."
open -a Docker || true
echo "⏳ 等待 Docker 引擎就绪(最多 120 秒)..."
local i
for i in $(seq 1 60); do
if docker info &>/dev/null; then
echo "✅ Docker Desktop 已启动并可用"
return 0
fi
sleep 2
done
echo "⚠️ Docker Desktop 已安装,但尚未就绪。"
echo " 请手动打开 Docker Desktop,待状态正常后重新运行 ./start.sh"
return 1
}
if command -v docker &>/dev/null; then
echo "✅ Docker 已安装"
else
if [[ "$OS_FAMILY" == "Ubuntu" ]]; then
sudo apt update -y
sudo apt install -y apt-transport-https ca-certificates curl gnupg lsb-release
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL "$UBUNTU_DOCKER_GPG_URL" | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg 2>/dev/null || \
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] $UBUNTU_DOCKER_MIRROR_URL/linux/ubuntu $OS_CODENAME stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
sudo apt-get update -y
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
elif [[ "$OS_FAMILY" == "CentOS" ]]; then
sudo yum install -y yum-utils
sudo yum-config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo
sudo sed -i "s+https://download.docker.com+$CENTOS_DOCKER_MIRROR_URL+g" /etc/yum.repos.d/docker-ce.repo
[[ "$OS_VERSION_ID" -ge "8" ]] && sudo yum module disable -y container-tools 2>/dev/null || true
sudo yum install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
elif [[ "$OS_FAMILY" == "macOS" ]]; then
echo "⚠️ 检测到 macOS,未找到 Docker。"
mac_install_choice="${DEPLOY_MAC_DOCKER_INSTALL:-${DEPLOY_DOCKER_INSTALL:-}}"
if [ -z "$mac_install_choice" ]; then
require_interactive "DEPLOY_MAC_DOCKER_INSTALL=auto 或 DEPLOY_MAC_DOCKER_INSTALL=manual"
echo "请选择安装方式:"
echo " [1] 自动安装 Docker Desktop(需要 Homebrew)"
echo " [2] 手动安装(默认)"
read -r -p "请选择 [1/2]: " mac_install_choice
fi
mac_install_choice="$(printf '%s' "$mac_install_choice" | tr '[:upper:]' '[:lower:]')"
case "${mac_install_choice:-2}" in
1|auto)
if ! install_docker_macos; then
exit 1
fi
;;
2|manual)
echo " 请手动安装 Docker Desktop: https://www.docker.com/products/docker-desktop/"
echo " 安装并启动 Docker Desktop 后,重新运行 ./start.sh"
exit 1
;;
*)
echo "❌ 无效选择"
exit 1
;;
esac
else
echo "❌ 不支持的操作系统: $OS_FAMILY"
exit 1
fi
if [[ "$OS_FAMILY" != "macOS" ]]; then
sudo systemctl enable docker
sudo systemctl start docker
getent group docker | grep -qw "$USER" || sudo usermod -aG docker "$USER"
fi
fi
if docker compose version &>/dev/null; then
echo "✅ Docker Compose 已就绪"
elif command -v docker-compose &>/dev/null; then
echo "✅ Docker Compose (兼容模式) 已就绪"
else
echo "❌ 请安装 docker-compose-plugin"
exit 1
fi
}
# ==================== 配置 HTTP Nginx 入口 ====================
configure_http_nginx_proxy() {
case "$DEPLOY_MODE" in
ip|domain) ;;
*) return 0 ;;
esac
# HTTPS 域名由 https.sh 安装配置并签发证书;IP 与纯 HTTP 域名走这里。
if [ "$DEPLOY_MODE" = "domain" ] && [ "$DEPLOY_HTTPS" = true ]; then
return 0
fi
if [ -z "$PROXY_CONF" ] || [ ! -f "$PROXY_CONF" ]; then
echo "❌ 缺少 Nginx 配置文件: ${PROXY_CONF:-未生成}"
return 1
fi
echo ""
echo "🌐 配置 Nginx 统一入口(HTTP 80)..."
if ! command -v nginx &>/dev/null; then
if command -v apt-get &>/dev/null; then
sudo apt-get update -y
sudo apt-get install -y nginx
elif command -v dnf &>/dev/null; then
sudo dnf install -y nginx
elif command -v yum &>/dev/null; then
sudo yum install -y nginx
else
echo "❌ 未找到受支持的 Nginx 包管理器(apt-get/dnf/yum)"
return 1
fi
fi
local nginx_conf_dir="/etc/nginx/conf.d"
local nginx_target="${nginx_conf_dir}/easyai-proxy.conf"
local nginx_backup=""
sudo install -d -m 0755 "$nginx_conf_dir"
if sudo test -f "$nginx_target"; then
nginx_backup="${nginx_target}.pre-ip-proxy-$(date +%Y%m%dT%H%M%S)"
sudo cp -a "$nginx_target" "$nginx_backup"
echo " ✓ 已备份现有配置: $nginx_backup"
fi
sudo install -m 0644 "$PROXY_CONF" "$nginx_target"
if ! sudo nginx -t; then
echo "❌ Nginx 配置校验失败,正在恢复原配置"
if [ -n "$nginx_backup" ]; then
sudo cp -a "$nginx_backup" "$nginx_target"
else
sudo rm -f "$nginx_target"
fi
sudo nginx -t || true
return 1
fi
if command -v systemctl &>/dev/null; then
sudo systemctl enable nginx >/dev/null
if sudo systemctl is-active --quiet nginx; then
sudo systemctl reload nginx
else
sudo systemctl start nginx
fi
else
sudo nginx -s reload 2>/dev/null || sudo nginx
fi
echo " ✓ Nginx 已监听 80,统一代理前端、API、WebSocket 与 ASG"
}
# ==================== 启动服务 ====================
start_services() {
echo ""
echo "🚀 启动 EasyAI 服务..."
sudo ./scripts/check-redis-persistence.sh
if docker compose version &>/dev/null; then
sudo docker compose pull && sudo docker compose up -d
else
sudo docker-compose pull && sudo docker-compose up -d
fi
echo "🎉 EasyAI 应用启动成功"
}
# ==================== 执行 HTTPS 配置 ====================
run_https_setup() {
if [ "$DEPLOY_HTTPS" = true ] && [ -n "$DEPLOY_DOMAIN" ]; then
echo ""
echo "🔒 执行 HTTPS 配置(请确保服务器已放行 80、443 端口)..."
if [ -f "./https.sh" ]; then
# https.sh 依赖 easyai-proxy.conf,需使用生成的域名配置文件
export EASYAI_PROXY_CONF="${DEPLOY_DOMAIN}.conf"
bash ./https.sh
else
echo "⚠️ 未找到 https.sh,请手动配置 HTTPS"
fi
fi
}
show_admin_login_info() {
echo ""
if [ "$SECURITY_ENV_MODE" != "new" ]; then
echo "管理员账号: admin(本次部署不会生成或重置已有密码)"
echo "登录提示: 若数据库尚未创建 admin,请查看 .env -> CONFIG_INITIAL_ADMIN_PASSWORD;若 admin 已存在,请使用当前密码。"
return 0
fi
local initial_admin_password
initial_admin_password="$(read_env_value .env "CONFIG_INITIAL_ADMIN_PASSWORD")"
if [ -z "$initial_admin_password" ]; then
echo "❌ 未能读取初始管理员密码: .env -> CONFIG_INITIAL_ADMIN_PASSWORD" >&2
return 1
fi
if [ "$DEPLOY_DRY_RUN" = "1" ]; then
echo "-------- 初始管理员(Docker 首次启动后生效)--------"
else
echo "-------- 初始管理员(首次登录后请立即修改密码)--------"
fi
echo "登录账号: admin"
echo "登录密码: ${initial_admin_password}"
echo "生命周期: 仅在数据库首次创建 admin 时使用;创建后修改 .env 不会重置密码。"
echo "有效期: 初始密码没有独立过期时间,在管理员修改密码前持续有效。"
}
# ==================== 主流程 ====================
main() {
init_project_dir
infer_deploy_access_from_env
# 检查是否已有 .env 且非强制重新配置
if [ -f .env ] && [ -z "$DEPLOY_FORCE_RECONFIG" ] && [ -z "$DEPLOY_ACCESS" ]; then
echo "📁 检测到已有 .env 配置"
if is_non_interactive; then
echo "⏭️ 使用现有配置继续..."
DEPLOY_MODE="skip"
else
read -r -p "是否重新配置部署方式?[y/N]: " reconfigure
if [[ ! "$reconfigure" =~ ^[yY] ]]; then
echo "⏭️ 使用现有配置继续..."
DEPLOY_MODE="skip"
fi
fi
fi
if [ "$DEPLOY_MODE" != "skip" ]; then
run_deploy_questions
fi
if [ "$DEPLOY_MODE" != "skip" ]; then
setup_env_files
else
if [ ! -f .env.tools ]; then
cp .env.tools.sample .env.tools
fi
if [ ! -f .env.ASG ]; then
cp .env.ASG.sample .env.ASG
fi
if [ ! -f .env.AMS ]; then
cp .env.AMS.sample .env.AMS
fi
fi
# Linux 的 IP/域名模式都经由同机 Nginx 代理,后端宿主机端口保持回环绑定。
# shellcheck source=scripts/init-server-http-bind-ip.sh
. ./scripts/init-server-http-bind-ip.sh
local server_bind_override=""
if [ "$DEPLOY_MODE" = "domain" ]; then
server_bind_override="127.0.0.1"
elif [ "$DEPLOY_MODE" = "ip" ]; then
server_bind_override="127.0.0.1"
fi
init_server_http_bind_ip .env "$server_bind_override"
# 空值或历史默认值只在首次发现时生成并写回 .env,后续启动保持稳定。
# 数据库中的后台 JWT 轮转配置由后端优先使用,不受此处影响。
# shellcheck source=scripts/init-security-env.sh
. ./scripts/init-security-env.sh
init_security_env .env "$SECURITY_ENV_MODE"
bash ./scripts/init-infrastructure-update-env.sh "$PWD"
# 在拉取镜像或重建服务前完成公开地址迁移;非法或无法推导时立即终止。
# shellcheck source=scripts/init-public-api-base-url.sh
. ./scripts/init-public-api-base-url.sh
if [ "$DEPLOY_MODE" = "skip" ]; then
init_public_api_base_url .env upgrade "${DEPLOY_PUBLIC_API_BASE_URL:-}"
else
init_public_api_base_url .env configure "$PUBLIC_API_BASE_URL_CANDIDATE"
fi
# CONFIG_SECURITY_ORIGIN 为可选项:留空时后端默认允许所有来源,显式配置时才校验。
# shellcheck source=scripts/init-security-origin.sh
. ./scripts/init-security-origin.sh
init_security_origin .env
if [ "$DEPLOY_DRY_RUN" = "1" ]; then
echo ""
echo "⚠️ dry-run 模式:跳过 Docker 安装和服务启动"
echo " 配置文件已生成,可直接运行 ./start.sh 完成部署"
bash ./scripts/install-infrastructure-update-task.sh "$PWD" --dry-run
else
install_docker
start_services
bash ./scripts/install-infrastructure-update-task.sh "$PWD"
configure_http_nginx_proxy
export DEPLOY_DOMAIN
run_https_setup
fi
echo ""
echo "================================"
echo " 部署完成"
echo "================================"
if [ "$DEPLOY_MODE" = "ip" ] && [ -n "$DEPLOY_IP" ]; then
echo "访问地址: http://${DEPLOY_IP}"
elif [ "$DEPLOY_MODE" = "domain" ] && [ -n "$DEPLOY_DOMAIN" ]; then
if [ "$DEPLOY_HTTPS" = true ]; then
echo "访问地址: https://${DEPLOY_DOMAIN}"
else
echo "访问地址: http://${DEPLOY_DOMAIN}"
fi
if [ "$DEPLOY_HTTPS" = true ]; then
echo "HTTPS 已启用"
fi
fi
show_admin_login_info
echo ""
}
main "$@"
echo "🚀 启动EasyAI"
# 对于新版 docker-compose-plugin,命令是 'docker compose'
if command -v docker &> /dev/null && docker compose version &> /dev/null; then
sudo docker compose pull && sudo docker compose up -d
else # 兼容旧版独立安装的 docker-compose
sudo docker-compose pull && sudo docker-compose up -d
fi
echo "🎉EasyAI应用启动成功"
-30
View File
@@ -1,30 +0,0 @@
# 在线更新发布来源
`release-manifest.json` 是唯一的正式产品发布记录。页面检测、任务目标、updater 执行以及没有 updater 的旧 Watchtower 部署均读取它。`APP_VERSION` 与 `/app/version` 只表示当前运行版本,用于展示和更新结果确认;`APP_VERSION_CHECK_BASE_URL` 不再参与新版检测。
默认从部署仓库 main 的发布清单读取,无需先升级某台公开 API 服务。自建发布渠道使用 `UPDATE_MANIFEST_URL`;离线部署明确设置 `UPDATE_MANIFEST_SOURCE=local`,后端和 updater 读取同一个挂载文件。读取失败时显示无法检查更新,不使用过期文件代替正式版本。
发布时先完成后端和网页的同一版本镜像推送,然后生成整套记录:
```sh
python3 scripts/prepare-product-release.py --version 3.24.0.20261008.1 \
--policy system-update/release-manifest.json \
--output system-update/release-manifest.json
```
版本只从镜像实际发布信息读取,不另行人工同步网页版本或 API 运行版本。工具验证服务器和网页镜像的版本标签、linux/amd64 平台、三组件完整性及远程 digest,全部通过后才原子写入。网关未变化时保留上一正式 digest;有变化时显式传入 `--gateway-ref`。发布策略由 `--policy` 指定,必须在发布审查中确认,工具不推断新版本的回滚安全性。生成文件不等于已经发布,提交并发布该记录后才向部署开放新版。
旧 Watchtower 仍执行其原有可变镜像标签,不能按清单锁定 digest。因此发布前须确保其使用的服务器/网页标签与清单版本一致。任务严格检查核心服务恢复和目标运行版本,不会把请求收到或版本不一致当作升级成功。
## 更新状态
- 提交前未能连接更新服务:尚未提交,提示管理员检查容器、地址和授权。单凭连接错误不能断言容器已经退出。
- 旧服务在线、没有历史/进度接口:可执行更新;请求发出后明确提示可能仍在下载或安装。
- 已发送但没有收到回复:保留原任务和请求标识,只查询,不自动再次提交。
- 执行结束但核心服务未恢复、或版本不一致:说明具体未确认项,不显示成功。
进度窗口始终可以关闭,关闭不取消服务端任务。仍在前台查询时可以通过“查看更新进度”重新打开;刷新按原任务恢复。旧 Watchtower 前台等待最多五分钟,之后保留“结果待确认”,服务端继续检查原任务。受管更新有独立的较长执行窗口,但窗口同样随时可关闭。
历史旧任务没有发布标识、目标来自旧运行版本接口时,只在请求已发送、所有核心服务可用且实际安装版本与正式发布记录完全相等时修正结果,保留原目标供核对。带发布标识的新任务和受管任务仍严格匹配原目标,不能用任意更高版本冒充成功。
旧浏览器保存的兼容记录没有任务接口时,同样重新核对当前运行版本和正式发布信息;只有两者完全一致且高于旧目标,才确认新版已安装。无法核对时保留待确认,不接受任意更高版本。
-37
View File
@@ -1,37 +0,0 @@
{
"schemaVersion": 1,
"channel": "stable",
"releaseId": "3.28.2.20261011.4-86e94f19e538",
"productVersion": "3.28.2.20261011.4",
"platform": "linux/amd64",
"publishedAt": "2026-10-11T09:46:39.697960+00:00",
"managedUpdateAllowed": true,
"scheduledUpdateAllowed": true,
"rollbackSafe": true,
"minimumUpdaterVersion": "1.1.0",
"minimumDeploymentSchemaVersion": 2,
"requiredFreeBytes": 8589934592,
"components": [
{
"key": "server-main",
"image": "registry.cn-shanghai.aliyuncs.com/comfy-ai/comfy-server",
"digest": "sha256:2077104a3d9f1cba68eb31911bb00a26d2c154eda2c897fb031c162559379e11",
"buildId": "607c5ee243936bba394579c7bc94ca0b2f62cda6",
"imageSizeBytes": 989020204
},
{
"key": "ws-gateway",
"image": "registry.cn-shanghai.aliyuncs.com/easyaigc/wsgateway",
"digest": "sha256:1dd4247cf6a8a21d336876b5d97ecdc3e4ce43199b2f31aff18e0fab47bb4dca",
"buildId": "retained-formal-image",
"imageSizeBytes": 751971874
},
{
"key": "web",
"image": "registry.cn-shanghai.aliyuncs.com/comfy-ai/one-ai",
"digest": "sha256:279da715ec67c2017c06ca31c8f7e172b703b99c5378404d24198af919c460dc",
"buildId": "f1925755cb481b9c3a609996333d26bf47da5302",
"imageSizeBytes": 347958996
}
]
}
@@ -1,126 +0,0 @@
{
"schemaVersion": 1,
"productVersion": "3.28.1.20261011.3",
"operationId": "epochlyra-save-release-dfb5ba06-a197-4504-8bfa-4ae66f52ed32",
"createdAt": "2026-10-11T08:59:44.124706+00:00",
"source": {
"backend": {
"branch": "main",
"sha": "03d8b0ffe7a42918a780b1a8591ea94cb818d47b",
"requiredFix": "2d05f64abca0a401cfab7d86d976ee5d248c8049"
},
"web": {
"branch": "dev",
"sha": "360ae7a565981606a878ae9fd42008bc2f88b592",
"requiredFix": "fbf176371cf49f397489876ae49c18528570d1f5"
}
},
"protocol": "immutable saveAttemptId with atomic same-account/content/version/collaboration acknowledgement; backend remains compatible with clients without saveAttemptId",
"manifest": {
"path": "system-update/release-manifest.json",
"releaseId": "3.28.1.20261011.3-2aa4d1f16159",
"sha256": "2e2d42b749800f9d68f38a3333fe9c89dd556f2dd49fde28ab67302a0701255f"
},
"images": [
{
"key": "server-main",
"image": "registry.cn-shanghai.aliyuncs.com/comfy-ai/comfy-server",
"versionTag": "3.28.1.20261011.3",
"floatingTag": "latest",
"digest": "sha256:51070a417dbc76cc32fe4376e55fe964ee370c2d05730626d43635c3f2f5ada6",
"configDigest": "sha256:1995d93482a1cb2b1145e17110ca649172dadafab7245fb643e9601caa157ab3",
"buildId": "03d8b0ffe7a42918a780b1a8591ea94cb818d47b",
"imageSizeBytes": 989020197,
"platform": "linux/amd64",
"tagsMatch": true,
"actualVersionLabelMatched": true,
"actualRevisionLabelMatched": true
},
{
"key": "web",
"image": "registry.cn-shanghai.aliyuncs.com/comfy-ai/one-ai",
"versionTag": "3.28.1.20261011.3",
"floatingTag": "latest",
"digest": "sha256:12991654387d41e390859a0e98541b7be135f41af29c4f40b0ecb2fa3ba4a689",
"configDigest": "sha256:76f310701c43e05fee30480f65e084f52926572b46b5ede6a496190537fe30d2",
"buildId": "360ae7a565981606a878ae9fd42008bc2f88b592",
"imageSizeBytes": 347945972,
"platform": "linux/amd64",
"tagsMatch": true,
"actualVersionLabelMatched": true,
"actualRevisionLabelMatched": true
},
{
"key": "web-no-ssr",
"image": "registry.cn-shanghai.aliyuncs.com/comfy-ai/one-ai",
"versionTag": "3.28.1.20261011.3-no-ssr",
"floatingTag": "latest-no-ssr",
"digest": "sha256:9d38c14983e8beca7f0d80fbd3f1a388e304318aa0077dd96ac8ccafc6a16884",
"configDigest": "sha256:382ca77df59bce486052c72eaea66804148f86255dab4205ba438268ecbb6498",
"buildId": "360ae7a565981606a878ae9fd42008bc2f88b592",
"imageSizeBytes": 312703180,
"platform": "linux/amd64",
"tagsMatch": true,
"actualVersionLabelMatched": true,
"actualRevisionLabelMatched": true
}
],
"fixEvidence": {
"backendCompiledArtifact": {
"imageDigest": "sha256:51070a417dbc76cc32fe4376e55fe964ee370c2d05730626d43635c3f2f5ada6",
"sourceSha": "03d8b0ffe7a42918a780b1a8591ea94cb818d47b",
"compiledArtifactSha256": "02ab798d101a7def4ec8b6f6af988a9b1e731a49fa4591a9bdc34a7b7a82a549",
"compiledArtifactMatchesBuild": true,
"saveAttemptIdPresent": true,
"atomicSaveReceiptPresent": true,
"modelOrServerStarted": false
},
"webCompiledArtifacts": {
"nuxtBuildId": "764f28d5-c640-4cc6-80ab-2644b6076fb7",
"saveAttemptArtifacts": [
{
"path": "_nuxt/C9y4r00i.js",
"sha256": "f5153b1d29deac547491a371be56074f2f2da81d62735319f647547a58d5dceb",
"bytes": 174196
}
],
"sourceSha": "360ae7a565981606a878ae9fd42008bc2f88b592"
},
"webNoSsrCompiledArtifacts": {
"metadata": {
"version": "3.0.4",
"buildTime": "2026-10-11T08:55:36.156Z",
"commitHash": "360ae7a5"
},
"nuxtBuild": {
"id": "a5aed96e-4ce3-4c3d-8b5c-2c8241913cf6",
"timestamp": 1791708782012
},
"saveAttemptArtifacts": [
{
"path": "_nuxt/BtAfqqEq.js",
"sha256": "4ed0b531f482103b2bc2de288dd851e6b19dcee64d6b68307f5040c71dc84a34",
"bytes": 174196
}
],
"compiledSaveAttemptPresent": true,
"image": "registry.cn-shanghai.aliyuncs.com/comfy-ai/one-ai@sha256:9d38c14983e8beca7f0d80fbd3f1a388e304318aa0077dd96ac8ccafc6a16884",
"sourceSha": "360ae7a565981606a878ae9fd42008bc2f88b592",
"verifiedAt": "2026-10-11T08:56:26.816398+00:00"
}
},
"publisherChecks": {
"backendTargetedTests": 164,
"frontendTargetedTests": 67,
"backendPublishGuards": 5,
"frontendPublishGuards": 3,
"frontendFixLintPassed": true,
"ssrStaticObjectsUploadedAndVerified": 2890,
"imageUploadAndRegistryVerificationPassed": true
},
"unchangedComponents": [
"ws-gateway",
"runtime-worker"
],
"customerAcceptance": "Customer deployment and real browser acceptance are owned by the designated customer operator; this record is artifact/source evidence."
}
@@ -1,182 +0,0 @@
{
"schemaVersion": 1,
"productVersion": "3.28.2.20261011.4",
"operationId": "epochlyra-refresh-confirm-release-m5-20261011-v1",
"taskId": "epochlyra-canvas-save-release-acceptance-20261011",
"createdAt": "2026-10-11T09:46:39.726277+00:00",
"source": {
"backend": {
"branch": "main",
"sha": "607c5ee243936bba394579c7bc94ca0b2f62cda6",
"requiredFix": "2d05f64abca0a401cfab7d86d976ee5d248c8049",
"businessCodeChanged": false
},
"web": {
"branch": "dev",
"sha": "f1925755cb481b9c3a609996333d26bf47da5302",
"requiredFix": "f1925755cb481b9c3a609996333d26bf47da5302",
"requiredFixes": [
"f1925755cb481b9c3a609996333d26bf47da5302",
"fbf176371cf49f397489876ae49c18528570d1f5"
]
}
},
"change": "Refresh of an unconfirmed pending save restores paused editing and asks for retry; acknowledgement of the immutable original request precedes later queued edits. Explicit 409 and legacy draft conflict protection remain.",
"manifest": {
"path": "system-update/release-manifest.json",
"releaseId": "3.28.2.20261011.4-86e94f19e538",
"sha256": "8a79489034213c5998429f7ed41d080b126b3f4653f9cd961918bee5e1dacf6c"
},
"images": [
{
"key": "server-main",
"image": "registry.cn-shanghai.aliyuncs.com/comfy-ai/comfy-server",
"versionTag": "3.28.2.20261011.4",
"floatingTag": "latest",
"digest": "sha256:2077104a3d9f1cba68eb31911bb00a26d2c154eda2c897fb031c162559379e11",
"configDigest": "sha256:fa754ec7fba17dfb75797ba80073c90fc2b89f72450b0a15af7ed879d310cd65",
"buildId": "607c5ee243936bba394579c7bc94ca0b2f62cda6",
"imageSizeBytes": 989020204,
"platform": "linux/amd64",
"tagsMatch": true,
"actualVersionLabelMatched": true,
"actualRevisionLabelMatched": true
},
{
"key": "web",
"image": "registry.cn-shanghai.aliyuncs.com/comfy-ai/one-ai",
"versionTag": "3.28.2.20261011.4",
"floatingTag": "latest",
"digest": "sha256:279da715ec67c2017c06ca31c8f7e172b703b99c5378404d24198af919c460dc",
"configDigest": "sha256:b65ecb96d83180835b094588d03f71983f10c83abb23e94bf00e55df2c659bb4",
"buildId": "f1925755cb481b9c3a609996333d26bf47da5302",
"imageSizeBytes": 347958996,
"platform": "linux/amd64",
"tagsMatch": true,
"actualVersionLabelMatched": true,
"actualRevisionLabelMatched": true
},
{
"key": "web-no-ssr",
"image": "registry.cn-shanghai.aliyuncs.com/comfy-ai/one-ai",
"versionTag": "3.28.2.20261011.4-no-ssr",
"floatingTag": "latest-no-ssr",
"digest": "sha256:3f2401b3b9b85434d1831de35c5d88aa3eed25a87b885298d556f762ac500123",
"configDigest": "sha256:e0c19f4260130b8e8bc52a0b2a53b8eb00676f9cf021ec43222866df5a765d7c",
"buildId": "f1925755cb481b9c3a609996333d26bf47da5302",
"imageSizeBytes": 312702485,
"platform": "linux/amd64",
"tagsMatch": true,
"actualVersionLabelMatched": true,
"actualRevisionLabelMatched": true
}
],
"fixEvidence": {
"backend": {
"sourceSha": "607c5ee243936bba394579c7bc94ca0b2f62cda6",
"compiledArtifactSha256": "8ec7ce09386cf5cfe654644b72b948f05c12c839c71c13003364b200cb4da925",
"compiledArtifactMatchesBuild": true,
"saveAttemptIdPresent": true,
"atomicSaveReceiptPresent": true,
"modelOrServerStarted": false,
"verifiedAt": "2026-10-11T09:32:18.825311+00:00",
"imageDigest": "sha256:2077104a3d9f1cba68eb31911bb00a26d2c154eda2c897fb031c162559379e11",
"image": "registry.cn-shanghai.aliyuncs.com/comfy-ai/comfy-server@sha256:2077104a3d9f1cba68eb31911bb00a26d2c154eda2c897fb031c162559379e11"
},
"webSsr": {
"metadata": {
"version": "3.0.4",
"buildTime": "2026-10-11T09:38:26.796Z",
"commitHash": "f1925755"
},
"nuxtBuild": {
"id": "d7fcee8d-a6ac-4cb6-a494-2fd07a1ffc5a",
"timestamp": 1791711088163
},
"saveAttemptArtifacts": [
{
"path": "_nuxt/RrADm_1v.js",
"sha256": "2de9b3141067664186de4cecf517e6dcefdb197056f4b04dfbcef04ee53e9c6b",
"bytes": 174475
}
],
"compiledSaveAttemptPresent": true,
"compiledRefreshConfirmationMessagePresent": true,
"sourceSha": "f1925755cb481b9c3a609996333d26bf47da5302",
"imageArtifactsMatchBuild": true,
"verifiedAt": "2026-10-11T09:40:27.646971+00:00",
"imageDigest": "sha256:279da715ec67c2017c06ca31c8f7e172b703b99c5378404d24198af919c460dc",
"image": "registry.cn-shanghai.aliyuncs.com/comfy-ai/one-ai@sha256:279da715ec67c2017c06ca31c8f7e172b703b99c5378404d24198af919c460dc"
},
"webNoSsr": {
"metadata": {
"version": "3.0.4",
"buildTime": "2026-10-11T09:43:52.060Z",
"commitHash": "f1925755"
},
"nuxtBuild": {
"id": "1e7bb7c2-9040-452f-b478-69e059df86db",
"timestamp": 1791711702559
},
"saveAttemptArtifacts": [
{
"path": "_nuxt/De3ohASY.js",
"sha256": "2ab5046d392da52250484dee347997dfa187f965ba343c4868c98eef30830078",
"bytes": 174475
}
],
"compiledSaveAttemptPresent": true,
"compiledRefreshConfirmationMessagePresent": true,
"sourceSha": "f1925755cb481b9c3a609996333d26bf47da5302",
"imageArtifactsMatchBuild": true,
"verifiedAt": "2026-10-11T09:43:57.453562+00:00",
"imageDigest": "sha256:3f2401b3b9b85434d1831de35c5d88aa3eed25a87b885298d556f762ac500123",
"image": "registry.cn-shanghai.aliyuncs.com/comfy-ai/one-ai@sha256:3f2401b3b9b85434d1831de35c5d88aa3eed25a87b885298d556f762ac500123"
}
},
"publisherChecks": {
"backendTestsPassed": 18,
"frontendTestsPassed": 45,
"frontendTestsSkipped": 0,
"backendPublishGuardsPassed": 5,
"frontendPublishGuardsPassed": 3,
"frontendFixLintPassed": true,
"ssrStaticObjectsUploadedAndVerified": 2890,
"imageUploadAndRegistryVerificationPassed": true,
"commands": {
"backend-targeted-tests": {
"passed": true,
"durationSeconds": 3.35
},
"web-targeted-tests": {
"passed": true,
"durationSeconds": 0.69
},
"web-fix-lint": {
"passed": true,
"durationSeconds": 1.55
},
"backend-release-guards": {
"passed": true,
"durationSeconds": 0.22
},
"web-release-guards": {
"passed": true,
"durationSeconds": 0.25
},
"backend-deploy": {
"passed": true,
"durationSeconds": 35.03
},
"web-deploy-resume": {
"passed": true,
"durationSeconds": 241.63
}
}
},
"unchangedComponents": [
"ws-gateway",
"runtime-worker"
],
"customerAcceptance": "Customer second update and refresh confirmation browser acceptance are owned by the MacBook parent conversation; this record is publisher source/artifact evidence, not customer acceptance."
}
@@ -1,65 +0,0 @@
# updater 双保险维护验收
日期:2026-10-08。代码位于 `/Users/wangbo/projects/easyai`。本记录补充前序 Watchtower 宿主机维护验收,描述本轮新增的 updater 独立定时更新通道。
## 结果
宿主机定时任务每六小时分别维护 Watchtower 和已启用的 updater;原 Watchtower 每 24 小时维护 updater 的机制保留。无需新增容器或再注册另一条宿主机任务。
每项维护分别拉取、按需重建和验证健康状态。某项失败仍继续另一项,同时记录失败并返回非零结果。不会因 Watchtower 不可用而跳过 updater。旧部署未启用 updater 时保持原更新模式。
验收时尚未提交或推送源码;本轮真实运行结果不代表已部署至线上。本轮没有实际注册本机 cron 或 Windows 计划任务。旧机器需由运维执行一次新版部署脚本接入;发布源码本身不能给机器安装宿主机任务。
## 修改前真实复现
独立 Compose 项目 `easyai-updater-insurance-20261008`,Watchtower 1.22.3、updater 1.1.0,本机 registry 发布 updater 1.2.0。主动停止本次自建的 Watchtower,并把该项目 Watchtower 镜像设置为不存在的标签,模拟 Watchtower 恢复失败。
分别运行原 Bash 和 PowerShell 定时入口:均在 Watchtower 拉取失败时退出;updater 继续运行旧版本 1.1.0,容器及镜像均未变化。
证据:`before.json`、`before-bash.log`、`before-powershell.log`,原入口快照保存在 `before-scripts/`。这说明原定时维护只负责 Watchtower,不能在其失效时直接维护 updater。
## 本轮变更及影响范围
- 修改 `scripts/update-infrastructure.sh` 和 `scripts/Update-Infrastructure.ps1`:独立处理两个组件,失败分别记录,最后汇总结果;保持部署目录、Docker 登录配置、并发锁及只重建指定服务的行为。
- 修改 Bash/PowerShell 任务安装器的提示文案,保留原任务标识、每六小时周期、去重及关闭方式,不重复注册任务。
- 更新 `.env.sample` 和 README,说明 updater 两条维护路径。
- 扩展 `scripts/test-infrastructure-auto-update.py`,覆盖两种入口、两种 Compose 命令路径、独立失败、legacy 跳过及健康检查。
主要调用方仍为 `start.sh/update.sh/start.ps1/update.ps1` 安装的定时任务。没有修改业务自动更新开关,也没有改动 updater、后端、前端业务代码及已有任务存储格式。此前工作区中其他未提交改动均保留。
## 实际运行结果
| 场景 | 实际结果 |
| --- | --- |
| Watchtower 已停止且拉取失败,Bash 定时入口 | updater 实际升级 1.1.0 → 1.2.0;Watchtower 仍停止,证明该升级不依赖它;返回失败以保留 Watchtower 故障结果 |
| 相同条件,PowerShell 定时入口 | updater 实际升级 1.1.0 → 1.2.0;同样独立执行并报告部分失败 |
| updater 历史任务 | Bash 独立更新后,实际任务 API 仍能读取原有成功任务及其消息 |
| updater 拉取失败 | updater 容器未停止或重建;Watchtower 仍恢复健康;整体报告失败 |
| 正常维护及镜像无变化 | 两组件均健康;镜像和配置无变化时容器 ID 不变 |
| legacy profile 未启用 updater | Bash、PowerShell 均不创建或启动 updater |
| 原 Watchtower 更新通道 | 重新开启轮询并发布 updater 新标签,Watchtower 仍实际更新 updater 1.1.0 → 1.2.0 |
| 业务服务对照 | 全程容器 ID 不变,没有被维护入口直接重建 |
实际运行验收耗时 27.58 秒,不含镜像准备及修改前复现。证据:`after.json`、各场景 `.log`、`watchtower-final.log`。这是自建 registry、真实 Compose 和真实更新组件的运行结果。业务服务为独立 Node 对照容器,并未部署整套业务及数据库。
## 定向检查
- `python3 scripts/test-infrastructure-auto-update.py`:通过,19.496 秒。覆盖 Bash/PowerShell、Compose v2/v1 命令路径、单项失败不影响另一项、全部拉取失败不重建、legacy 跳过、并发排除、健康探测,以及原有迁移和 cron 去重保护。
- `pwsh -NoProfile -File scripts/Test-InfrastructureUpdateTask.ps1`:通过,0.18 秒。验证注册参数及去重、关闭行为;计划任务 API 为测试替身。
- Bash/PowerShell 语法、ShellCheck、diff 空白检查:通过,合计 0.72 秒。PowerShell UTF-8 BOM 保持不变,供 Windows PowerShell 5.1 使用。
证据:`validation.json`、`quality-checks.json` 及本轮工具输出。没有运行全量前端质量门禁,本轮未涉及前端业务代码。
## 验证边界
Windows 宿主机真实计划任务注册、生产六小时和 24 小时长周期、客户私有仓库网络及权限尚未验证。Compose v1 是命令适配行为验收,实际容器验收使用 Docker Compose v5.3.1。PowerShell 实际容器入口在 macOS 上运行。
历史终态任务持久化已实际验证;本轮没有在业务更新执行到一半时替换 updater 的故障演练,也不据此宣称所有活动任务恢复已验收。
Compose 根据启用的 profile 选择可维护服务,运行 `config --services` 已实测关闭 profile 时不包含 updater。避免直接显式启动未启用的 updater,因显式指定服务会自动启用其 profile。[Docker 官方说明](https://docs.docker.com/compose/how-tos/profiles/)
本方案仍需 Docker 和镜像仓库可用;镜像启动失败不会自动回滚。每项维护有健康等待并报告失败,后续定时任务继续检查。
本次已清理四个自建验收容器及独立网络,原本机 `test-watchtower-1` 保持健康。证据:`cleanup.json`。
原始运行日志与入口快照保存在本机证据目录:`/Users/wangbo/.codex/visualizations/2026/10/07/01a11505-cae4-76e0-9ba9-235f6962b2b4/updater-double-insurance-20261008`。本目录附带修改前、修改后及定向检查的结构化结果。
@@ -1,53 +0,0 @@
{
"passed": true,
"bashUpdatedUpdaterWithWatchtowerStoppedAndPullFailed": true,
"powershellUpdatedUpdaterWithWatchtowerStoppedAndPullFailed": true,
"failedServiceReportedWhileOtherServiceMaintained": true,
"persistedJobRetained": true,
"updaterPullFailurePreservedContainerAndWatchtowerRecovered": true,
"unchangedImagesNoRecreate": true,
"legacyProfileDoesNotStartUpdater": true,
"originalWatchtowerChannelStillUpdatesUpdater": true,
"businessContainerUnchanged": true,
"seconds": 27.58,
"before": [
{
"name": "/easyai-updater-insurance-20261008-watchtower",
"id": "f177e0655e789190f9f06533e1035d166b5924db7d2afc1d1e242abb43b1bd0c",
"image": "sha256:6ba1e78a0c9ce1ae5389ecf8a78426b82beaf3357c71d52548f7d05fd92eebb7",
"running": false
},
{
"name": "/easyai-updater-insurance-20261008-updater",
"id": "9837e6b0328798dbbad17ff310ef8d6d27761c1d152d374a91e56fdb3009c31e",
"image": "sha256:275e9ff7fb453d16feabfb9b92e1cd603f49c434bf2daa80a9f5ad6f505bffcb",
"running": true
},
{
"name": "/easyai-updater-insurance-20261008-business",
"id": "6b733cf4ee75e2136430bb1b53ec49c1685d3958ad7cebe862b4a52b139e847f",
"image": "sha256:5bbd38fd80132923f2ea205d02836fefdf7eb3555fd93f0bb58e26a899c0e493",
"running": true
}
],
"afterIndependentTimer": [
{
"name": "/easyai-updater-insurance-20261008-watchtower",
"id": "f177e0655e789190f9f06533e1035d166b5924db7d2afc1d1e242abb43b1bd0c",
"image": "sha256:6ba1e78a0c9ce1ae5389ecf8a78426b82beaf3357c71d52548f7d05fd92eebb7",
"running": false
},
{
"name": "/easyai-updater-insurance-20261008-updater",
"id": "f4b5029ae1bd5b9d33ef472039b3e938242ac68c5d3a36019035a745532a0734",
"image": "sha256:d7e4d04d4e614d537a08ee24f58317a3d6734e3514ca61eafc92bd1bb7a8d6ef",
"running": true
},
{
"name": "/easyai-updater-insurance-20261008-business",
"id": "6b733cf4ee75e2136430bb1b53ec49c1685d3958ad7cebe862b4a52b139e847f",
"image": "sha256:5bbd38fd80132923f2ea205d02836fefdf7eb3555fd93f0bb58e26a899c0e493",
"running": true
}
]
}
@@ -1,28 +0,0 @@
{
"reproduced": true,
"watchtowerStopped": true,
"watchtowerPullFailed": true,
"updaterRemainedOld": true,
"bashExitCode": 1,
"powershellExitCode": 1,
"containers": [
{
"name": "/easyai-updater-insurance-20261008-watchtower",
"id": "f177e0655e789190f9f06533e1035d166b5924db7d2afc1d1e242abb43b1bd0c",
"image": "sha256:6ba1e78a0c9ce1ae5389ecf8a78426b82beaf3357c71d52548f7d05fd92eebb7",
"running": false
},
{
"name": "/easyai-updater-insurance-20261008-updater",
"id": "9837e6b0328798dbbad17ff310ef8d6d27761c1d152d374a91e56fdb3009c31e",
"image": "sha256:275e9ff7fb453d16feabfb9b92e1cd603f49c434bf2daa80a9f5ad6f505bffcb",
"running": true
},
{
"name": "/easyai-updater-insurance-20261008-business",
"id": "6b733cf4ee75e2136430bb1b53ec49c1685d3958ad7cebe862b4a52b139e847f",
"image": "sha256:5bbd38fd80132923f2ea205d02836fefdf7eb3555fd93f0bb58e26a899c0e493",
"running": true
}
]
}
@@ -1,10 +0,0 @@
{
"ownContainersRemoved": [
"/easyai-updater-insurance-20261008-watchtower",
"/easyai-updater-insurance-20261008-updater",
"/easyai-updater-insurance-20261008-business",
"/easyai-updater-insurance-20261008-registry"
],
"ownNetworkRemoved": true,
"originalLocalTestWatchtowerHealthy": true
}
@@ -1,7 +0,0 @@
{
"BashSyntax": true,
"PowerShellSyntax": true,
"ShellCheck": true,
"DiffWhitespace": true,
"seconds": 0.72
}
@@ -1,14 +0,0 @@
{
"infrastructureTests": {
"passed": true,
"seconds": 19.496
},
"powershellTaskContract": {
"passed": true,
"seconds": 0.18
},
"windowsPowerShellEncoding": {
"bomPreserved": true
},
"fullQualityGateRun": false
}
@@ -1,170 +0,0 @@
{
"status": "test-verified-awaiting-production-target",
"productVersion": "3.23.0.20261003",
"source": {
"backend": "8a67a2f180e6c6af3fab760a32d6bcc78ca1cee2",
"backendImageBuild": "83cefbfdf2bc909d11ace9f020b108145844051a",
"web": "965c417288c5f09260c6e28254cde62af7683854",
"deploymentImplementation": "be3ba8838a0db0ad7d6e4a711303caa607ae95be"
},
"images": {
"server": "sha256:a21a59205ced431987dda6c72d4fe5c0ad00ced97311500b10d08a9c1237bbce",
"web": "sha256:bb1ff1dc77518ca01b770d03317fbddb0e2bf8eda6c27b70d6dbec0944d8e78e",
"webNoSsr": "sha256:d97c52ac76c462140827e8d9d9a1198f36dd2cc7d2c6defed60f9f7aee87cbaf",
"modelConversionWorker": "sha256:085fa49d99a06562ce9634bfdd8567efdfe4e38754e9688de81288d2e2578526"
},
"imagePlatform": "linux/amd64",
"publishedTags": "version-only",
"floatingTagsPromoted": false,
"production": {
"status": "not-executed",
"targetHost": null,
"reason": "Latest release record and ignored production connection config identify different hosts; clarification pending."
},
"checks": [
{
"name": "frontend directed tests",
"passed": true,
"tests": 190,
"durationSeconds": 1.237
},
{
"name": "API directed Nx tests",
"passed": true,
"tests": 88,
"durationSeconds": 2.02
},
{
"name": "Linux Worker in formal amd64 image including KTX2 and memory termination",
"passed": true,
"tests": 14,
"durationSeconds": 36.637
},
{
"name": "Bash/PowerShell update, real Git re-exec and actual Compose configuration",
"passed": true,
"durationSeconds": 21.93,
"boundary": "Docker command transport is stubbed in entrypoint tests; actual Compose is resolved separately."
},
{
"name": "frontend SSR production build",
"passed": true,
"durationSeconds": 502.95
},
{
"name": "frontend no-SSR production build",
"passed": true,
"durationSeconds": 182.66
},
{
"name": "static resource upload and object verification",
"passed": true,
"objects": 2881,
"durationSeconds": 168.05
},
{
"name": "formal API and Worker with DEV upload/task/artifact chain",
"passed": true,
"cases": 8
},
{
"name": "800k GLB formal image conversion, artifact hash and meter dimensions",
"passed": true,
"evidence": "model-conversion-20261003/conversion-local.json"
},
{
"name": "canvas i18n locale check",
"passed": false,
"preExisting": true,
"missingKey": "canvasFlow.remotePlugin.inputPortMissing",
"locales": [
"zh-CN",
"en-US",
"ja-JP",
"ko-KR"
],
"boundary": "Present at origin/dev before the director3d commits."
},
{
"name": "Worker official amd64 image build",
"passed": true,
"durationSeconds": 632.9
},
{
"name": "formal frontend image actual OBJ file input, meters, lightweight copy and WebGL UI",
"passed": true,
"standingHeightMeters": 1.72,
"dimensionsMeters": [
20,
0.2,
20
],
"errors": 0,
"sourceResourceCacheReused": true,
"evidence": "model-conversion-20261003/browser-local.json",
"screenshot": "model-conversion-20261003/browser-import.png"
}
],
"gateway": {
"updateRequiredFromLatestRecordedProductionSource": false,
"basis": "No diff under apps/easyai-wsgateway or shared libraries compared with recorded backend source e3f12240; actual target still needs inspection."
},
"limitations": [
"Test deployment and acceptance passed; production has not been deployed and registry latest was not promoted by this release.",
"Test host has 2 CPU cores, 3.576 GiB physical RAM and no swap; Worker limit is 2 CPU / 4 GiB. Saturation isolation, 20-cycle memory trends and capacity must be measured separately before production rollout.",
"Remote conversion timings are individual functional runs including network upload/download, not a controlled A/B/C performance benchmark.",
"Customer original GLB and additional real hardware remain unavailable for acceptance.",
"Pixel-level editor/preview/image/video consistency and full legacy/template interactions were not repeated on this test host; earlier directed/local evidence remains scoped to its original environment.",
"Browser end-to-end wall-clock duration was not recorded; successful browser phases, original HTTP evidence, actual transform readings, persisted resource data and screenshots are retained."
],
"testRelease": {
"productVersion": "3.23.0.20261003.1",
"verifiedAt": "2026-10-04",
"domain": "demo.51easyai.com",
"deploymentPath": "/root/easyai",
"deploymentEntry": "update.sh",
"deploymentImplementation": "2a1192eb7ec822773f55af96fa12e06bf96774a8",
"source": {
"backend": "be56a7e0ec79d1a1e7420d870709014ed4bf010a",
"web": "965c417288c5f09260c6e28254cde62af7683854",
"workerImageBuild": "83cefbfdf2bc909d11ace9f020b108145844051a"
},
"images": {
"server": {
"tag": "registry.cn-shanghai.aliyuncs.com/comfy-ai/comfy-server:3.23.0.20261003.1",
"digest": "sha256:2b2d7e0cb8270d3540f3562e5debdb6a84a8cd2ae610c7c2f6a991cce1cc681c",
"rebuilt": true
},
"web": {
"tag": "registry.cn-shanghai.aliyuncs.com/comfy-ai/one-ai:3.23.0.20261003.1",
"digest": "sha256:bb1ff1dc77518ca01b770d03317fbddb0e2bf8eda6c27b70d6dbec0944d8e78e",
"reusedImage": true,
"originalOciVersion": "3.23.0.20261003"
},
"webNoSsr": {
"tag": "registry.cn-shanghai.aliyuncs.com/comfy-ai/one-ai:3.23.0.20261003.1-no-ssr",
"digest": "sha256:d97c52ac76c462140827e8d9d9a1198f36dd2cc7d2c6defed60f9f7aee87cbaf",
"reusedImage": true,
"originalOciVersion": "3.23.0.20261003",
"deployedInTest": false
},
"worker": {
"tag": "registry.cn-shanghai.aliyuncs.com/easyaigc/model-conversion-worker:3.23.0.20261003.1",
"digest": "sha256:085fa49d99a06562ce9634bfdd8567efdfe4e38754e9688de81288d2e2578526",
"reusedImage": true,
"originalOciVersion": "3.23.0.20261003"
}
},
"floatingTagsPromoted": false,
"evidence": "model-conversion-20261003/test/README.md",
"browserEvidence": "model-conversion-20261003/test/browser/results.json",
"uploadRegressionEvidence": "model-conversion-20261003/test/browser/upload-regression.json",
"conversionEvidence": "model-conversion-20261003/test/patch-conversion/results.json",
"runtimeEvidence": "model-conversion-20261003/test/patch-deployment.json",
"nonTargetContainersPreserved": 13,
"screenshots": [
"model-conversion-20261003/test/browser/import.png",
"model-conversion-20261003/test/browser/saved-reopened.png"
]
}
}
@@ -1,11 +0,0 @@
# 模型转换服务部署候选
版本 `3.23.0.20261003` 的固定版本镜像已发布;尚未提升浮动标签或执行线上更新。目标主机待确认,本记录不能作为线上验收通过的证据。
部署代码通过 `update.sh` / `update.ps1` 完成环境迁移、共享令牌初始化、指定服务镜像拉取、容器启动和健康等待。生产只使用更新脚本应用配置;确认主机后还需检查其现有 Compose、端口、数据卷、ICE 与代理设置,再执行上线和真实转换验收。
`conversion-local.json` 是正式 API / Worker amd64 镜像、真实 DEV 存储和任务链路的合成 GLB 转换记录;`protocol-local.json` 记录 8 个实际协议场景。原文件约 19.2 MB、798,848 个三角面,转换产物 714,896 字节、100,000 个三角面,宽深均 20 米,高度误差约 0.022 毫米。当前 M4 Max 使用 amd64 模拟执行,耗时不能当作线上硬件性能。
镜像源码为后端 `83cefbfd` 与前端 `965c4172`;后端后续 `8a67a2f1` 只增加验收工具与说明,没有改镜像运行代码。主工作区的收益钱包、鉴权、上传和 PM2 改动未被此次发布提交带入。
正式前端镜像还通过真实浏览器选择 OBJ、确认米制单位、默认性能优先轻量副本和 WebGL 显示验收:素体站立身高 1.72 米,导入实体 20 × 0.2 × 20 米。此轮复用了同项目的已上传来源资源,记录在 `browser-local.json`,截图为 `browser-import.png`。
Binary file not shown.

Before

Width:  |  Height:  |  Size: 227 KiB

@@ -1,18 +0,0 @@
{
"passed": true,
"standingHeight": "1.72",
"lightweight": true,
"quality": "performance",
"dimensions": {
"width": "20.00",
"height": "0.20",
"depth": "20.00"
},
"errors": [],
"requests": [
{
"status": 201,
"path": "http://127.0.0.1:3101/model-conversion/resources/source-upload"
}
]
}
@@ -1,64 +0,0 @@
{
"passed": true,
"apiOrigin": "http://127.0.0.1:3101",
"projectId": "6abff8d24f5fe54612cf8f3b",
"nodeId": "model-conversion-deployment-acceptance",
"taskId": "6abff8dd4f5fe54612cf8f8f",
"sourceHash": "f686ed16f4f90cc9b8ad9b5fc7d133d3768b1851620b107dd811fbd85a19f834",
"sourceBytes": 19203544,
"artifactHash": "889709b74d725388f3b225f7b158a0717d0c07fa0a8532fad8358cecf616f581",
"artifactBytes": 714896,
"originalDimensions": {
"width": 20,
"height": 0.20000000298023224,
"depth": 20
},
"actualDimensions": {
"width": 20,
"height": 0.19997775554656982,
"depth": 20
},
"loadingUnit": "meters",
"converterVersion": "assimp6.0.5-gltf4.3.0-meshopt0.25.0-ktx4.4.2-recipe2",
"statistics": {
"before": {
"triangles": 798848,
"meshes": 1,
"primitives": 1,
"nodes": 2,
"skins": 0,
"animations": 0,
"morphPrimitives": 0,
"geometryBytes": 19202712,
"textures": [],
"estimatedTextureGpuBytes": 0
},
"after": {
"triangles": 100000,
"meshes": 1,
"primitives": 1,
"nodes": 2,
"skins": 0,
"animations": 0,
"morphPrimitives": 0,
"geometryBytes": 1830360,
"textures": [],
"estimatedTextureGpuBytes": 0
},
"primary": {
"triangles": 100000,
"meshes": 1,
"primitives": 1,
"nodes": 2,
"skins": 0,
"animations": 0,
"morphPrimitives": 0,
"geometryBytes": 1830360,
"textures": [],
"estimatedTextureGpuBytes": 0
}
},
"uploadMs": 11026,
"conversionAndDownloadMs": 17482,
"elapsedMs": 28776
}
@@ -1,40 +0,0 @@
{
"sourceSha": "83cefbfdf2bc909d11ace9f020b108145844051a",
"projectId": "6abff81f4f5fe54612cf7f11",
"sourceHash": "263a8cd920cf8e06abaa996cf4aeec27ab7a609d77499028020d003c6e576b4f",
"cases": [
{
"name": "actual upload/worker/persistent artifact/idempotency/no port writes",
"passed": true,
"taskId": "6abff8204f5fe54612cf7f34"
},
{
"name": "verified upload and conversion cache reuse",
"passed": true
},
{
"name": "generic task refresh recovery",
"passed": true
},
{
"name": "cross-project resource rejected",
"passed": true
},
{
"name": "paid channel default disabled",
"passed": true
},
{
"name": "changed source hash rejected",
"passed": true
},
{
"name": "queued cancellation survives worker restart; late completion not applied",
"passed": true
},
{
"name": "persisted locked node blocks import",
"passed": true
}
]
}
@@ -1,49 +0,0 @@
# 模型转换服务测试环境发布验收
2026-10-04,`https://demo.51easyai.com`,主机 `8.218.158.189`,部署目录 `/root/easyai`。
测试环境已发布并完成以下验收;生产未部署,本次仅推送固定版本镜像,没有推送 `latest`。
## 发布和兼容升级
- 部署实现:`2a1192eb7ec822773f55af96fa12e06bf96774a8`。后端补丁:`be56a7e0ec79d1a1e7420d870709014ed4bf010a`。
- 初次从旧部署 `f04bb5e` 更新,实际复现了定制 Compose 和未跟踪配置阻断旧脚本拉取,以及 updater 占用 API 固定地址的问题。首次使用新版 `update.sh` 的旧配置迁移入口完成备份、语义合并和更新,再由同一脚本部署服务;没有手动修改数据库或替代脚本启动业务容器。
- 最终更新命令:`UPDATE_MODE=image UPDATE_NON_INTERACTIVE=1 UPDATE_RELEASE_VERSION=3.23.0.20261003.1 UPDATE_SERVICES='easyai-server easyai-web model-conversion-worker' bash /root/easyai/update.sh`。
- API 镜像重新构建。前端 SSR、no-SSR 和 Worker 发布 `.1` 版本别名,镜像摘要与原版本相同,其 OCI 构建版本仍为 `3.23.0.20261003`;测试主机运行 SSR。完整摘要见上级发布记录及 `patch-deployment.json`。
- API、前端、Worker、updater 均健康;版本接口为 `3.23.0.20261003.1`,主页、健康及更新就绪接口均 HTTP 200。补丁更新中的 13 个非目标容器保留容器 ID 和启动时间,Nginx 及原鉴权、Mongo、Redis配置已核验保留。
- 旧配置备份保存在主机受限目录 `data/deployment-backups/legacy-update-20261003-075918-g3q8cib5`;本机记录不含配置密钥。后续固定版本更新直接使用部署目录的 `update.sh`。
## 浏览器上传问题的复现和修复
实际进入普通 `/ai/canvas/:projectId`,从文件输入选择 OBJ,确认米、默认轻量副本和性能优先。修复前,上传计划返回 HTTP 201,但本地存储合法地省略了 `headers`;浏览器在 `Object.entries(upload.headers)` 抛出 `TypeError: Cannot convert undefined or null to object`,未发送文件 PUT,未进入转换。
修复仅在模型转换接口把省略的请求头变为 `{}`,保留云存储提供的签名头,通用存储行为不变。真实服务回归测试修复前 1 失败、1 通过;修复后转换协议 13 项和存储既有行为 27 项全部通过。证据为 `browser/upload-regression.json`,失败截图为 `browser/import-failed.png`。
修复后真实 Chrome 通过普通画布执行了文件 PUT、资源完成、CanvasTask、独立 Worker 转换及 WebGL 加载,原始网络证据保存在 `browser/uncached-import.json`。没有隐藏界面、替换真实加载器或强制点击。
## 实际结果
| 验收 | 结果 | 原始记录 |
| --- | --- | --- |
| 默认素体 | 站立身高 1.72 米 | `browser/results.json` |
| OBJ 导入 | 20 × 0.2 × 20 米,默认轻量副本、性能优先 | `browser/uncached-import.json`、`browser/import.png` |
| 实际缩放 | 0.05 → 1 × 0.01 × 1;10 → 200 × 2 × 200;20 → 400 × 4 × 400;恢复 1 → 20 × 0.2 × 20 米 | `browser/transform-save.json`,数值来自实际控件读数 |
| 保存与全新浏览器重开 | 米制 `sceneState` 保留轻量版本、倍率 1、资源 ID;尺寸 20 × 0.2 × 20 米;场景 JSON 3239 字节,没有 Base64;页面无异常 | `browser/results.json`、`browser/saved-reopened.png` |
| 约 80 万面 GLB,补丁环境重测 | 798848 → 100000 三角面;19203544 → 714896 字节;20 × 0.200000003 × 20 → 20 × 0.199977756 × 20 米;高度误差约 0.022 毫米;产物 SHA 校验通过 | `patch-conversion/results.json` |
| 4K 贴图 | 4096² → 1024²;生成 KTX2 和 PNG 兼容产物;保留尺寸 | `texture-4096/results.json`,原版本实测,补丁复用同一 Worker 镜像 |
| 骨骼和动画 | 100001 三角面不减面;3 个节点、1 套骨骼、1 个动画保留;尺寸保留 | `skin-animation/results.json`,原版本实测,补丁复用同一 Worker 镜像 |
| Worker 专属接口与付费通道 | 匿名和普通登录 token 都不能调用产物管理接口,均 HTTP 403;混元通道关闭 | `patch-worker-access.json` |
验收分阶段执行:先完成真实导入、变换和普通保存,再以全新 Chrome 会话重开并检查。保存会自动关闭编辑器;已有图片输出的节点通过编辑按钮重开,双击图片按原契约打开图片预览。米制 fixture 使用正式 `sceneState` 字段。早期脚本对这些入口的错误假设已纠正,不计作业务失败或成功证据。
## 耗时和资源边界
- 转换协议定向 Nx:2.38 秒(13 项,Jest 0.84 秒);存储定向 Nx:1.87 秒(27 项,Jest 0.638 秒)。
- Shared 构建 0.66 秒,API 定向 Nx 构建 20.83 秒。正式 Linux amd64 Dockerfile 构建、固定版本推送及实际部署均已完成;未记录本次 Docker 构建和浏览器全链路的完整墙钟耗时。
- 最后一次 GLB 链路共 71.932 秒:上传 18.502 秒,转换、任务等待和产物下载共 52.444 秒。该数值包含网络与存储开销,是一次功能复验,不是受控 A/B/C benchmark,也不能单独解读为减面计算耗时。
- 转换及下载期间的 12 个本机 API 可用性采样全部 HTTP 200,中位数约 2.17 毫秒,最大约 6.17 毫秒。见 `patch-runtime-samples.json`;这不能证明满载 P95 退化目标。
- Worker 实际限制:2 CPU、4 GiB、并发 1、PID 64、只读根文件系统、无主机端口;共享内部鉴权配置匹配。见 `patch-deployment.json`。测试主机仅 2 CPU、3.576 GiB 实际内存、没有 swap,不能把本轮小规模通过当作生产容量结论。
- 旧配置迁移、固定版本及 Bash/PowerShell 更新定向测试通过,分别约 1.28 秒、21.045 秒;测试主机的旧脚本冲突和新版脚本更新另外经过真实执行。没有用命令 mock 替代真实发布验收。
## 尚未覆盖
客户原始 GLB、其他实际硬件、Worker 满载时的 API 基线对照、20 次循环内存趋势,仍需独立验收。测试主机没有重新执行全部旧场景/模板交互,也没有完成编辑器、机位预览、图片与视频的逐像素对应点检查;先前定向/本地记录仅代表其原环境。不得据此声称视觉无损、全部性能目标或生产验收已通过。原质量记录中的国际化历史缺失项仍未改动。
Binary file not shown.

Before

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 185 KiB

@@ -1,139 +0,0 @@
{
"passed": true,
"environment": "test",
"url": "https://demo.51easyai.com/ai/canvas/6ac046133db0eda177a9896e",
"projectId": "6ac046133db0eda177a9896e",
"nodeId": "model-conversion-deployment-acceptance",
"apiVersion": "3.23.0.20261003.1",
"standingHeight": 1.72,
"lightweight": true,
"quality": "performance",
"importedDimensions": {
"width": "20.00",
"height": "0.20",
"depth": "20.00"
},
"uncachedUploadEvidence": "uncached-import.json",
"scaleChecks": [
{
"scale": 0.05,
"width": 1.0,
"height": 0.01,
"depth": 1.0
},
{
"scale": 10.0,
"width": 200.0,
"height": 2.0,
"depth": 200.0
},
{
"scale": 20.0,
"width": 400.0,
"height": 4.0,
"depth": 400.0
},
{
"scale": 1.0,
"width": 20.0,
"height": 0.2,
"depth": 20.0
}
],
"scaleChecksEvidence": "transform-save.json",
"savedResource": {
"resourceId": "c459455a-7479-4310-8b66-3da706dfc513",
"selected": "lightweight",
"format": "obj",
"sceneBytes": 3239,
"containsBase64": false
},
"reopenedDimensions": {
"width": 20,
"height": 0.2,
"depth": 20
},
"errors": [],
"requests": [
{
"method": "GET",
"status": 200,
"path": "https://demo.51easyai.com/api/canvas-workflow/plugins/runtime-manifest"
},
{
"method": "GET",
"status": 200,
"path": "https://demo.51easyai.com/api/canvas-workflow/plugin-installations/snapshot"
},
{
"method": "GET",
"status": 200,
"path": "https://demo.51easyai.com/api/canvas-workflow/runtime-snapshots"
},
{
"method": "GET",
"status": 200,
"path": "https://demo.51easyai.com/api/upload/files/2026-10-04%2F6937758647fc2fff48b99759%2Fupload%2Fdirector-3d-model-conversion-deployme-3sfggu.png"
},
{
"method": "GET",
"status": 200,
"path": "https://demo.51easyai.com/api/canvas-workflow/plugin-installations/snapshot"
},
{
"method": "GET",
"status": 200,
"path": "https://demo.51easyai.com/api/canvas-workflow/resource-actions"
},
{
"method": "GET",
"status": 200,
"path": "https://demo.51easyai.com/api/canvas-workflow/plugin-installations/snapshot"
},
{
"method": "GET",
"status": 200,
"path": "https://demo.51easyai.com/api/canvas-workflow/plugin-installations/snapshot"
},
{
"method": "GET",
"status": 200,
"path": "https://demo.51easyai.com/api/canvas-workflow/plugin-installations/snapshot"
},
{
"method": "GET",
"status": 200,
"path": "https://demo.51easyai.com/api/canvas-workflow/plugin-installations/snapshot"
},
{
"method": "GET",
"status": 200,
"path": "https://demo.51easyai.com/api/canvas-workflow/plugin-installations/snapshot"
},
{
"method": "GET",
"status": 200,
"path": "https://demo.51easyai.com/api/canvas-workflow/plugin-installations/snapshot"
},
{
"method": "GET",
"status": 200,
"path": "https://demo.51easyai.com/api/canvas-workflow/plugin-installations/snapshot"
},
{
"method": "GET",
"status": 200,
"path": "https://demo.51easyai.com/api/canvas-workflow/plugin-installations/snapshot"
},
{
"method": "GET",
"status": 200,
"path": "https://demo.51easyai.com/api/canvas-workflow/plugin-installations/snapshot"
},
{
"method": "GET",
"status": 200,
"path": "https://demo.51easyai.com/api/canvas-workflow/plugin-installations/snapshot"
}
]
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 KiB

@@ -1,33 +0,0 @@
{
"passed": true,
"source": "captured live Playwright import, scale and UI save run",
"scaleChecks": [
{
"scale": 0.05,
"width": 1.0,
"height": 0.01,
"depth": 1.0
},
{
"scale": 10.0,
"width": 200.0,
"height": 2.0,
"depth": 200.0
},
{
"scale": 20.0,
"width": 400.0,
"height": 4.0,
"depth": 400.0
},
{
"scale": 1.0,
"width": 20.0,
"height": 0.2,
"depth": 20.0
}
],
"savedResourceId": "c459455a-7479-4310-8b66-3da706dfc513",
"saveBehavior": "Save persists sceneState and closes editor; no extra Close click is needed",
"logExcerpt": "stage: 'saved',\n scaleChecks: [\n { scale: 0.05, width: 1, height: 0.01, depth: 1 },\n { scale: 10, width: 200, height: 2, depth: 200 },\n { scale: 20, width: 400, height: 4, depth: 400 },\n { scale: 1, width: 20, height: 0.2, depth: 20 }\n ],\n resourceId: 'c459455a-7479-4310-8b66-3da706dfc513'"
}

Some files were not shown because too many files have changed in this diff Show More