forked from wangbo/easyai
feat(deploy): 增加受控更新与环境配置迁移
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
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]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -57,7 +57,8 @@ function Initialize-SecurityEnv {
|
||||
if ($null -eq $content) { $content = "" }
|
||||
$definitions = @(
|
||||
@{ Key = "CONFIG_JWT_SECRET"; Legacy = "this is a very secret secret"; MinLength = 32 },
|
||||
@{ Key = "WS_AUTH_WS_TICKET_SECRET"; Legacy = ""; MinLength = 32 }
|
||||
@{ Key = "WS_AUTH_WS_TICKET_SECRET"; Legacy = ""; MinLength = 32 },
|
||||
@{ Key = "SYSTEM_UPDATE_INTERNAL_TOKEN"; Legacy = ""; MinLength = 32 }
|
||||
)
|
||||
if ($Mode -eq "new") {
|
||||
$definitions += @(
|
||||
|
||||
Executable
+151
@@ -0,0 +1,151 @@
|
||||
#!/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"
|
||||
@@ -129,6 +129,7 @@ init_security_env() {
|
||||
|
||||
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_env_list_item "$file" "WS_AUTH_METHODS" "ws_ticket" "none,bearer"
|
||||
if [ "$mode" = "new" ]; then
|
||||
ensure_security_secret "$file" "CONFIG_SECURITY_CONFIG_ENCRYPTION_KEY"
|
||||
|
||||
@@ -144,10 +144,66 @@ 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
|
||||
|
||||
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.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)
|
||||
'
|
||||
}
|
||||
|
||||
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 \
|
||||
@@ -202,6 +258,8 @@ if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1;
|
||||
assert_canvas_ws_auth_config
|
||||
assert_compose_security
|
||||
assert_redis_startup_contract
|
||||
assert_managed_update_contract
|
||||
assert_legacy_update_contract
|
||||
fi
|
||||
|
||||
reset_case
|
||||
|
||||
Executable
+57
@@ -0,0 +1,57 @@
|
||||
#!/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"
|
||||
[ "$(wc -l < "$LEGACY_ENV_FILE" | tr -d ' ')" = "4" ]
|
||||
|
||||
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_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" ]
|
||||
|
||||
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"
|
||||
@@ -0,0 +1,11 @@
|
||||
# 可查询进度的在线更新模式。已有非空配置保持不变。
|
||||
@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
|
||||
Reference in New Issue
Block a user