Files
easyai/scripts/test-deployment-public-url.sh
T

362 lines
16 KiB
Bash
Executable File

#!/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-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
assert "WATCHTOWER_HTTP_API_TOKEN" not in (watchtower.get("environment") or {}), watchtower.get("environment")
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 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.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.0.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=http://10.0.0.8' .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"