ops(web): bound immutable release history
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { copyFileSync } from 'node:fs';
|
||||
import { copyFileSync, readdirSync, writeFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
const source = resolve(process.cwd(), 'public/app-config.example.js');
|
||||
@@ -10,3 +10,5 @@ const destination = resolve(process.cwd(), 'dist/app-config.js');
|
||||
// runtime configuration at /app-config.js on the server.
|
||||
copyFileSync(source, destination);
|
||||
|
||||
const assets = readdirSync(resolve(process.cwd(), 'dist/assets')).sort();
|
||||
writeFileSync(resolve(process.cwd(), 'dist/.release-assets'), `${assets.join('\n')}\n`);
|
||||
|
||||
@@ -7,6 +7,7 @@ const excelAssets = assets.filter((name) => /^exceljs(?:\.min)?-[\w-]+\.js$/.tes
|
||||
const mileageWorkers = assets.filter((name) => /^mileageExport\.worker-[\w-]+\.js$/.test(name));
|
||||
const runtimeConfig = readFileSync(resolve(process.cwd(), 'dist/app-config.js'), 'utf8');
|
||||
const safeRuntimeTemplate = readFileSync(resolve(process.cwd(), 'public/app-config.example.js'), 'utf8');
|
||||
const releaseManifest = readFileSync(resolve(process.cwd(), 'dist/.release-assets'), 'utf8').split(/\r?\n/).filter(Boolean);
|
||||
|
||||
if (excelAssets.length !== 1) {
|
||||
throw new Error(`production build must contain exactly one ExcelJS asset, found ${excelAssets.length}: ${excelAssets.join(', ') || 'none'}`);
|
||||
@@ -17,6 +18,12 @@ if (mileageWorkers.length !== 1) {
|
||||
if (runtimeConfig !== safeRuntimeTemplate) {
|
||||
throw new Error('production dist/app-config.js must match the credential-free runtime template');
|
||||
}
|
||||
if (releaseManifest.length !== assets.length || releaseManifest.some((name, index) => name !== [...assets].sort()[index])) {
|
||||
throw new Error('production dist/.release-assets must list every generated asset exactly once in sorted order');
|
||||
}
|
||||
if (releaseManifest.some((name) => name.includes('/') || name === '.' || name === '..')) {
|
||||
throw new Error('production dist/.release-assets contains an unsafe asset name');
|
||||
}
|
||||
const configuredSecurityCode = runtimeConfig.match(/["']?amapSecurityJsCode["']?\s*:\s*(["'])(.*?)\1/s)?.[2].trim();
|
||||
if (configuredSecurityCode) {
|
||||
throw new Error('production dist/app-config.js must not contain an AMap security code');
|
||||
@@ -24,4 +31,4 @@ if (configuredSecurityCode) {
|
||||
|
||||
const excelBytes = statSync(resolve(assetsDirectory, excelAssets[0])).size;
|
||||
const workerBytes = statSync(resolve(assetsDirectory, mileageWorkers[0])).size;
|
||||
process.stdout.write(`web_build_gate=ok exceljs_assets=1 exceljs_bytes=${excelBytes} mileage_workers=1 worker_bytes=${workerBytes} runtime_config_sanitized=1\n`);
|
||||
process.stdout.write(`web_build_gate=ok release_assets=${releaseManifest.length} exceljs_assets=1 exceljs_bytes=${excelBytes} mileage_workers=1 worker_bytes=${workerBytes} runtime_config_sanitized=1\n`);
|
||||
|
||||
117
vehicle-data-platform/deploy/install-web-release.sh
Executable file
117
vehicle-data-platform/deploy/install-web-release.sh
Executable file
@@ -0,0 +1,117 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
RELEASE_ID=${1:?release id is required}
|
||||
ARCHIVE=${2:?web archive is required}
|
||||
ROOT=${PLATFORM_ROOT:-/opt/lingniu-vehicle-platform}
|
||||
BASE_URL=${PLATFORM_BASE_URL:-http://127.0.0.1:20300}
|
||||
SERVICE=${PLATFORM_SERVICE:-lingniu-vehicle-platform}
|
||||
COMPATIBILITY_GENERATIONS=${COMPATIBILITY_GENERATIONS:-3}
|
||||
RELEASE_HISTORY_LIMIT=${RELEASE_HISTORY_LIMIT:-20}
|
||||
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||
SYSTEMCTL_BIN=${SYSTEMCTL_BIN:-systemctl}
|
||||
CURL_BIN=${CURL_BIN:-curl}
|
||||
|
||||
case "$RELEASE_ID" in
|
||||
''|*[!A-Za-z0-9._-]*) printf 'invalid release id: %s\n' "$RELEASE_ID" >&2; exit 1 ;;
|
||||
esac
|
||||
test -f "$ARCHIVE" || { printf 'web archive is missing: %s\n' "$ARCHIVE" >&2; exit 1; }
|
||||
test -x "$SCRIPT_DIR/prepare-web-release-tree.sh" || { printf 'release tree helper is missing\n' >&2; exit 1; }
|
||||
test -f "$SCRIPT_DIR/prune-release-history.py" || { printf 'release pruning helper is missing\n' >&2; exit 1; }
|
||||
|
||||
old=$(readlink -f "$ROOT/current")
|
||||
next="$ROOT/releases/$RELEASE_ID"
|
||||
env_file="$ROOT/env/platform.env"
|
||||
old_release=$(sed -n 's/^PLATFORM_RELEASE=//p' "$env_file" | tail -1)
|
||||
switched=false
|
||||
|
||||
set_release_env() {
|
||||
python3 - "$env_file" "$1" <<'PY'
|
||||
from __future__ import print_function
|
||||
import io
|
||||
import sys
|
||||
|
||||
path, release = sys.argv[1], sys.argv[2]
|
||||
with io.open(path, 'r', encoding='utf-8') as handle:
|
||||
lines = handle.read().splitlines()
|
||||
updated = []
|
||||
found = False
|
||||
for line in lines:
|
||||
if line.startswith('PLATFORM_RELEASE='):
|
||||
updated.append('PLATFORM_RELEASE=' + release)
|
||||
found = True
|
||||
else:
|
||||
updated.append(line)
|
||||
if not found:
|
||||
updated.append('PLATFORM_RELEASE=' + release)
|
||||
with io.open(path, 'w', encoding='utf-8') as handle:
|
||||
handle.write('\n'.join(updated) + '\n')
|
||||
PY
|
||||
}
|
||||
|
||||
rollback() {
|
||||
local status=$?
|
||||
trap - ERR
|
||||
set +e
|
||||
if test "$switched" = true; then
|
||||
ln -s "$old" "$ROOT/current.rollback"
|
||||
python3 -c 'import os,sys; os.replace(sys.argv[1], sys.argv[2])' "$ROOT/current.rollback" "$ROOT/current"
|
||||
set_release_env "$old_release"
|
||||
"$SYSTEMCTL_BIN" restart "$SERVICE"
|
||||
fi
|
||||
printf 'web release install failed; current restored to %s\n' "$old" >&2
|
||||
exit "$status"
|
||||
}
|
||||
trap rollback ERR
|
||||
|
||||
test ! -e "$next" || { printf 'release already exists: %s\n' "$next" >&2; exit 1; }
|
||||
mkdir -p "$next/web"
|
||||
cp "$old/platform-api" "$next/platform-api"
|
||||
cp "$old/lingniu-vehicle-platform.service" "$next/lingniu-vehicle-platform.service"
|
||||
cp -a "$old/deploy" "$next/deploy"
|
||||
cp "$SCRIPT_DIR/prepare-web-release-tree.sh" "$next/deploy/prepare-web-release-tree.sh"
|
||||
cp "$SCRIPT_DIR/prune-release-history.py" "$next/deploy/prune-release-history.py"
|
||||
cp "$0" "$next/deploy/install-web-release.sh"
|
||||
chmod +x "$next/platform-api" "$next/deploy/"*.sh "$next/deploy/prune-release-history.py"
|
||||
|
||||
while IFS= read -r member; do
|
||||
case "$member" in
|
||||
/*|../*|*/../*|*/..) printf 'web archive contains unsafe path: %s\n' "$member" >&2; exit 1 ;;
|
||||
esac
|
||||
done < <(tar -tzf "$ARCHIVE")
|
||||
tar --no-same-owner -xzf "$ARCHIVE" -C "$next/web"
|
||||
find "$next/web" -type f -name '._*' -delete
|
||||
"$next/deploy/prepare-web-release-tree.sh" "$next/web" "$old/web" "$COMPATIBILITY_GENERATIONS"
|
||||
|
||||
ln -s "$next" "$ROOT/current.next"
|
||||
python3 -c 'import os,sys; os.replace(sys.argv[1], sys.argv[2])' "$ROOT/current.next" "$ROOT/current"
|
||||
switched=true
|
||||
set_release_env "$RELEASE_ID"
|
||||
"$SYSTEMCTL_BIN" restart "$SERVICE"
|
||||
|
||||
ready=false
|
||||
for _ in $(seq 1 15); do
|
||||
if "$SYSTEMCTL_BIN" is-active --quiet "$SERVICE" && "$CURL_BIN" -fsS "$BASE_URL/" >/dev/null; then ready=true; break; fi
|
||||
sleep 1
|
||||
done
|
||||
test "$ready" = true || { printf 'service did not become ready\n' >&2; exit 1; }
|
||||
verify_bin=${VERIFY_WEB_RELEASE_BIN:-$next/deploy/verify-web-release.sh}
|
||||
"$verify_bin" "$next/web" "$BASE_URL" "$next/web/.compatibility-assets"
|
||||
|
||||
if test -f "$ROOT/env/access-tokens.env"; then
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
. "$ROOT/env/access-tokens.env"
|
||||
set +a
|
||||
test -n "${VIEWER_TOKEN:-}" || { printf 'viewer token is unavailable for health verification\n' >&2; exit 1; }
|
||||
health=$("$CURL_BIN" -fsS -H "Authorization: Bearer $VIEWER_TOKEN" "$BASE_URL/api/ops/health")
|
||||
printf '%s' "$health" | python3 -c 'import json,sys; value=(((json.load(sys.stdin).get("data") or {}).get("runtime") or {}).get("platformRelease")); sys.exit(0 if value == sys.argv[1] else 1)' "$RELEASE_ID"
|
||||
fi
|
||||
|
||||
prune_bin=${PRUNE_RELEASE_BIN:-$next/deploy/prune-release-history.py}
|
||||
python3 "$prune_bin" "$ROOT" "$RELEASE_HISTORY_LIMIT" "$next" "$old"
|
||||
rm -f "$ARCHIVE"
|
||||
switched=false
|
||||
trap - ERR
|
||||
printf 'web_release_install=ok release=%s previous=%s\n' "$RELEASE_ID" "$(basename "$old")"
|
||||
61
vehicle-data-platform/deploy/install-web-release.test.sh
Executable file
61
vehicle-data-platform/deploy/install-web-release.test.sh
Executable file
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||
fixture=$(mktemp -d)
|
||||
trap 'rm -rf "$fixture"' EXIT
|
||||
root="$fixture/platform"
|
||||
old="$root/releases/old-release"
|
||||
mkdir -p "$old/web/assets" "$old/deploy" "$root/env" "$fixture/bin"
|
||||
printf '#!/usr/bin/env bash\nexit 0\n' > "$old/platform-api"
|
||||
chmod +x "$old/platform-api"
|
||||
printf '[Service]\n' > "$old/lingniu-vehicle-platform.service"
|
||||
cp "$SCRIPT_DIR/verify-web-release.sh" "$old/deploy/verify-web-release.sh"
|
||||
printf 'old.js\n' > "$old/web/.release-assets"
|
||||
printf 'old\n' > "$old/web/assets/old.js"
|
||||
ln -s "$old" "$root/current"
|
||||
printf 'PLATFORM_RELEASE=old-release\n' > "$root/env/platform.env"
|
||||
|
||||
for index in 1 2 3 4; do mkdir -p "$root/releases/stale-$index"; touch -t "20260716010${index}" "$root/releases/stale-$index"; done
|
||||
|
||||
new_web="$fixture/new-web"
|
||||
mkdir -p "$new_web/assets"
|
||||
printf 'new.js\n' > "$new_web/.release-assets"
|
||||
printf 'new\n' > "$new_web/assets/new.js"
|
||||
printf '<main>new</main>\n' > "$new_web/index.html"
|
||||
printf 'window.__LINGNIU_APP_CONFIG__={"amapSecurityServiceHost":"/_AMapService"};\n' > "$new_web/app-config.js"
|
||||
tar -C "$new_web" -czf "$fixture/new.tar.gz" .
|
||||
|
||||
printf '#!/usr/bin/env bash\nexit 0\n' > "$fixture/bin/systemctl"
|
||||
printf '#!/usr/bin/env bash\nexit 0\n' > "$fixture/bin/curl"
|
||||
printf '#!/usr/bin/env bash\nprintf "mock_verify=ok\\n"\n' > "$fixture/bin/verify"
|
||||
chmod +x "$fixture/bin/"*
|
||||
|
||||
PLATFORM_ROOT="$root" SYSTEMCTL_BIN="$fixture/bin/systemctl" CURL_BIN="$fixture/bin/curl" VERIFY_WEB_RELEASE_BIN="$fixture/bin/verify" RELEASE_HISTORY_LIMIT=3 "$SCRIPT_DIR/install-web-release.sh" new-release "$fixture/new.tar.gz" > "$fixture/install.out"
|
||||
test "$(readlink -f "$root/current")" = "$(cd "$root/releases/new-release" && pwd -P)"
|
||||
grep -q '^PLATFORM_RELEASE=new-release$' "$root/env/platform.env"
|
||||
test -f "$root/current/web/assets/new.js"
|
||||
test -f "$root/current/web/assets/old.js"
|
||||
test -f "$root/current/web/.compatibility-manifests/1.assets"
|
||||
test "$(find "$root/releases" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ')" = 3
|
||||
grep -q 'web_release_install=ok release=new-release previous=old-release' "$fixture/install.out"
|
||||
|
||||
rollback_web="$fixture/rollback-web"
|
||||
mkdir -p "$rollback_web/assets"
|
||||
printf 'rollback.js\n' > "$rollback_web/.release-assets"
|
||||
printf 'rollback\n' > "$rollback_web/assets/rollback.js"
|
||||
printf '<main>rollback</main>\n' > "$rollback_web/index.html"
|
||||
printf 'window.__LINGNIU_APP_CONFIG__={"amapSecurityServiceHost":"/_AMapService"};\n' > "$rollback_web/app-config.js"
|
||||
tar -C "$rollback_web" -czf "$fixture/rollback.tar.gz" .
|
||||
printf '#!/usr/bin/env bash\nexit 1\n' > "$fixture/bin/fail-verify"
|
||||
chmod +x "$fixture/bin/fail-verify"
|
||||
if PLATFORM_ROOT="$root" SYSTEMCTL_BIN="$fixture/bin/systemctl" CURL_BIN="$fixture/bin/curl" VERIFY_WEB_RELEASE_BIN="$fixture/bin/fail-verify" RELEASE_HISTORY_LIMIT=3 "$SCRIPT_DIR/install-web-release.sh" broken-release "$fixture/rollback.tar.gz" > "$fixture/rollback.out" 2>&1; then
|
||||
printf 'expected failed verification to roll back\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
test "$(readlink -f "$root/current")" = "$(cd "$root/releases/new-release" && pwd -P)"
|
||||
grep -q '^PLATFORM_RELEASE=new-release$' "$root/env/platform.env"
|
||||
grep -q 'current restored' "$fixture/rollback.out"
|
||||
|
||||
printf 'install-web-release tests passed\n'
|
||||
72
vehicle-data-platform/deploy/prepare-web-release-tree.sh
Executable file
72
vehicle-data-platform/deploy/prepare-web-release-tree.sh
Executable file
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
NEW_WEB=${1:?new web root is required}
|
||||
OLD_WEB=${2:?old web root is required}
|
||||
MAX_COMPATIBILITY_GENERATIONS=${3:-3}
|
||||
|
||||
case "$MAX_COMPATIBILITY_GENERATIONS" in
|
||||
''|*[!0-9]*) printf 'invalid compatibility generation limit: %s\n' "$MAX_COMPATIBILITY_GENERATIONS" >&2; exit 1 ;;
|
||||
esac
|
||||
test "$MAX_COMPATIBILITY_GENERATIONS" -ge 1 || { printf 'compatibility generation limit must be positive\n' >&2; exit 1; }
|
||||
test -f "$NEW_WEB/.release-assets" || { printf 'new release manifest is missing\n' >&2; exit 1; }
|
||||
test -f "$OLD_WEB/.release-assets" || { printf 'old release manifest is missing\n' >&2; exit 1; }
|
||||
mkdir -p "$NEW_WEB/assets" "$NEW_WEB/.compatibility-manifests"
|
||||
|
||||
validate_asset() {
|
||||
local asset=$1
|
||||
case "$asset" in
|
||||
''|/*|*/*|.|..) printf 'unsafe release asset: %s\n' "$asset" >&2; return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
while IFS= read -r asset || test -n "$asset"; do
|
||||
asset=${asset%$'\r'}
|
||||
test -n "$asset" || continue
|
||||
validate_asset "$asset"
|
||||
test -f "$NEW_WEB/assets/$asset" || { printf 'current asset is missing: %s\n' "$NEW_WEB/assets/$asset" >&2; exit 1; }
|
||||
done < "$NEW_WEB/.release-assets"
|
||||
|
||||
copy_manifest_assets() {
|
||||
local manifest=$1
|
||||
local asset source destination
|
||||
while IFS= read -r asset || test -n "$asset"; do
|
||||
asset=${asset%$'\r'}
|
||||
test -n "$asset" || continue
|
||||
validate_asset "$asset"
|
||||
source="$OLD_WEB/assets/$asset"
|
||||
destination="$NEW_WEB/assets/$asset"
|
||||
test -f "$source" || { printf 'compatibility asset is missing: %s\n' "$source" >&2; return 1; }
|
||||
if test -f "$destination"; then
|
||||
cmp -s "$source" "$destination" || { printf 'asset hash collision has different content: %s\n' "$asset" >&2; return 1; }
|
||||
else
|
||||
cp "$source" "$destination"
|
||||
fi
|
||||
done < "$manifest"
|
||||
}
|
||||
|
||||
cp "$OLD_WEB/.release-assets" "$NEW_WEB/.compatibility-manifests/1.assets"
|
||||
generation=2
|
||||
while test "$generation" -le "$MAX_COMPATIBILITY_GENERATIONS"; do
|
||||
previous=$((generation - 1))
|
||||
source_manifest="$OLD_WEB/.compatibility-manifests/$previous.assets"
|
||||
test -f "$source_manifest" || break
|
||||
cp "$source_manifest" "$NEW_WEB/.compatibility-manifests/$generation.assets"
|
||||
generation=$((generation + 1))
|
||||
done
|
||||
|
||||
compatibility_manifest="$NEW_WEB/.compatibility-assets"
|
||||
: > "$compatibility_manifest"
|
||||
for manifest in "$NEW_WEB"/.compatibility-manifests/*.assets; do
|
||||
test -f "$manifest" || continue
|
||||
copy_manifest_assets "$manifest"
|
||||
cat "$manifest" >> "$compatibility_manifest"
|
||||
done
|
||||
LC_ALL=C sort -u "$compatibility_manifest" -o "$compatibility_manifest"
|
||||
|
||||
current_count=$(awk 'NF { count += 1 } END { print count + 0 }' "$NEW_WEB/.release-assets")
|
||||
compatibility_count=$(awk 'NF { count += 1 } END { print count + 0 }' "$compatibility_manifest")
|
||||
generation_count=$(find "$NEW_WEB/.compatibility-manifests" -maxdepth 1 -type f -name '*.assets' | wc -l | tr -d ' ')
|
||||
served_count=$(find "$NEW_WEB/assets" -maxdepth 1 -type f | wc -l | tr -d ' ')
|
||||
printf 'web_release_tree=ok current_assets=%s compatibility_assets=%s compatibility_generations=%s served_assets=%s\n' "$current_count" "$compatibility_count" "$generation_count" "$served_count"
|
||||
38
vehicle-data-platform/deploy/prepare-web-release.test.sh
Executable file
38
vehicle-data-platform/deploy/prepare-web-release.test.sh
Executable file
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||
fixture=$(mktemp -d)
|
||||
trap 'rm -rf "$fixture"' EXIT
|
||||
|
||||
old="$fixture/old"
|
||||
new="$fixture/new"
|
||||
mkdir -p "$old/assets" "$old/.compatibility-manifests" "$new/assets"
|
||||
printf 'old-current.js\nshared.js\n' > "$old/.release-assets"
|
||||
printf 'older-one.js\n' > "$old/.compatibility-manifests/1.assets"
|
||||
printf 'too-old.js\n' > "$old/.compatibility-manifests/2.assets"
|
||||
for asset in old-current.js shared.js older-one.js too-old.js orphan.js; do printf '%s\n' "$asset" > "$old/assets/$asset"; done
|
||||
printf 'new-current.js\nshared.js\n' > "$new/.release-assets"
|
||||
printf 'new-current.js\n' > "$new/assets/new-current.js"
|
||||
cp "$old/assets/shared.js" "$new/assets/shared.js"
|
||||
|
||||
output=$("$SCRIPT_DIR/prepare-web-release-tree.sh" "$new" "$old" 2)
|
||||
test "$output" = 'web_release_tree=ok current_assets=2 compatibility_assets=3 compatibility_generations=2 served_assets=4'
|
||||
test -f "$new/assets/old-current.js"
|
||||
test -f "$new/assets/older-one.js"
|
||||
test ! -e "$new/assets/too-old.js"
|
||||
test ! -e "$new/assets/orphan.js"
|
||||
test "$(cat "$new/.compatibility-manifests/1.assets")" = "$(cat "$old/.release-assets")"
|
||||
test "$(cat "$new/.compatibility-manifests/2.assets")" = "$(cat "$old/.compatibility-manifests/1.assets")"
|
||||
|
||||
unsafe="$fixture/unsafe"
|
||||
cp -R "$new" "$unsafe"
|
||||
printf '../secret.js\n' > "$old/.release-assets"
|
||||
if "$SCRIPT_DIR/prepare-web-release-tree.sh" "$unsafe" "$old" 2 > "$fixture/unsafe.out" 2>&1; then
|
||||
printf 'expected an unsafe asset to fail\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -q 'unsafe release asset' "$fixture/unsafe.out"
|
||||
|
||||
printf 'prepare-web-release tests passed\n'
|
||||
53
vehicle-data-platform/deploy/prune-release-history.py
Executable file
53
vehicle-data-platform/deploy/prune-release-history.py
Executable file
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
from __future__ import print_function
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='Keep a bounded number of immutable platform releases.')
|
||||
parser.add_argument('root')
|
||||
parser.add_argument('limit', type=int)
|
||||
parser.add_argument('protected', nargs='*')
|
||||
args = parser.parse_args()
|
||||
|
||||
root = Path(args.root).resolve()
|
||||
releases = (root / 'releases').resolve()
|
||||
if root == Path('/') or releases.parent != root or not releases.is_dir():
|
||||
raise SystemExit('unsafe or missing release root: {}'.format(releases))
|
||||
if args.limit < 2:
|
||||
raise SystemExit('release history limit must be at least 2')
|
||||
|
||||
protected = set()
|
||||
for value in args.protected:
|
||||
path = Path(value).resolve()
|
||||
if path.parent != releases or not path.is_dir():
|
||||
raise SystemExit('protected release is outside the release root: {}'.format(path))
|
||||
protected.add(path)
|
||||
|
||||
candidates = [path.resolve() for path in releases.iterdir() if path.is_dir() and not path.is_symlink()]
|
||||
candidates.sort(key=lambda path: path.stat().st_mtime, reverse=True)
|
||||
keep = set(protected)
|
||||
for path in candidates:
|
||||
if len(keep) >= args.limit:
|
||||
break
|
||||
keep.add(path)
|
||||
|
||||
removed = 0
|
||||
for path in candidates:
|
||||
if path in keep:
|
||||
continue
|
||||
if path.parent != releases:
|
||||
raise SystemExit('refusing to remove a nested or escaped path: {}'.format(path))
|
||||
shutil.rmtree(str(path))
|
||||
removed += 1
|
||||
|
||||
print('release_prune=ok before={} kept={} removed={}'.format(len(candidates), len(keep), removed))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
29
vehicle-data-platform/deploy/prune-release-history.test.sh
Executable file
29
vehicle-data-platform/deploy/prune-release-history.test.sh
Executable file
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||
fixture=$(mktemp -d)
|
||||
trap 'rm -rf "$fixture"' EXIT
|
||||
mkdir -p "$fixture/releases"
|
||||
for index in 1 2 3 4 5 6; do
|
||||
mkdir "$fixture/releases/r$index"
|
||||
touch -t "20260716010${index}" "$fixture/releases/r$index"
|
||||
done
|
||||
|
||||
output=$(python3 "$SCRIPT_DIR/prune-release-history.py" "$fixture" 3 "$fixture/releases/r1")
|
||||
test "$output" = 'release_prune=ok before=6 kept=3 removed=3'
|
||||
test -d "$fixture/releases/r1"
|
||||
test -d "$fixture/releases/r6"
|
||||
test -d "$fixture/releases/r5"
|
||||
test ! -e "$fixture/releases/r2"
|
||||
test ! -e "$fixture/releases/r3"
|
||||
test ! -e "$fixture/releases/r4"
|
||||
|
||||
if python3 "$SCRIPT_DIR/prune-release-history.py" / 3 > "$fixture/unsafe.out" 2>&1; then
|
||||
printf 'expected an unsafe root to fail\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -q 'unsafe or missing release root' "$fixture/unsafe.out"
|
||||
|
||||
printf 'prune-release-history tests passed\n'
|
||||
@@ -44,6 +44,16 @@ Release `web-release-smoke-20260716015150` installs `deploy/verify-web-release.s
|
||||
|
||||
Behavioral tests cover a valid release, a missing compatibility file, a wrong HTTP 200 response body and an unsafe manifest path. The deployed gate passed all 20 current assets and all 20 compatibility assets from `mileage-export-worker-20260716014414`; the platform service remained active and the authenticated health endpoint reported the new release. The ECS host still runs Python 3.6, so the test fixture uses a working-directory subshell instead of the newer `http.server --directory` option and now proves the same cases on both the development host and ECS. The final release is `web-release-smoke-portable-20260716015528`.
|
||||
|
||||
## 2026-07-16: bounded immutable release history
|
||||
|
||||
The initial immutable-release workflow copied every asset from the active release into the next release. Because the active release already contained its own compatibility assets, this formed a transitive chain: every deployment retained all earlier assets. The ECS host had accumulated 747 release directories occupying 12 GB, even though only the current and recent rollback revisions could serve traffic.
|
||||
|
||||
The production build now emits `.release-assets` itself and verifies that it lists every generated file exactly once. Release preparation keeps three explicit compatibility generations, copies only assets named by those manifests and rejects unsafe names, missing files or same-name/different-content collisions. Older tabs remain protected by the route boundary's one-shot current-version reload, while recent tabs continue loading their original hashed chunks without interruption.
|
||||
|
||||
`install-web-release.sh` stages an immutable directory, validates archive paths, atomically replaces the `current` symlink, restarts the service, verifies every current and compatibility asset byte-for-byte, checks the authenticated runtime release and only then prunes history to 20 releases. Any readiness, asset or health failure restores the previous symlink, release environment and service before returning an error. Tests exercise successful installation, bounded compatibility generations, unsafe input, missing assets, release pruning and a forced post-switch rollback.
|
||||
|
||||
This applies the same lifecycle principles as blue/green deployment: stage a separate revision, validate it, switch traffic and retain a bounded rollback history. AWS documents fast rollback by switching back to the blue environment, while Kubernetes exposes `revisionHistoryLimit` because unbounded old revisions consume control-plane resources: <https://docs.aws.amazon.com/whitepapers/latest/blue-green-deployments/introduction.html>, <https://kubernetes.io/docs/concepts/workloads/controllers/deployment/#revision-history-limit>.
|
||||
|
||||
## 2026-07-16: cancellable, off-main-thread mileage export
|
||||
|
||||
The full-fleet mileage export previously performed three long-running phases as one opaque foreground action: paginating vehicle identities, paginating daily mileage rows and building the ExcelJS workbook on the browser main thread. Navigating away did not cancel the export requests, and a large `writeBuffer()` could make controls and route transitions unresponsive.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"web:build": "pnpm --dir apps/web run build",
|
||||
"web:test": "pnpm --dir apps/web run test",
|
||||
"api:test": "cd apps/api && go test ./...",
|
||||
"release:web-smoke:test": "bash deploy/verify-web-release.test.sh",
|
||||
"release:web-smoke:test": "bash deploy/verify-web-release.test.sh && bash deploy/prepare-web-release.test.sh && bash deploy/prune-release-history.test.sh && bash deploy/install-web-release.test.sh",
|
||||
"build": "pnpm run web:build && cd apps/api && go build -o ../../dist/platform-api ./cmd/platform-api && go build -o ../../dist/oneos-scope-sync ./cmd/oneos-scope-sync"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user