feat: establish Hestia server with preserved deployment resources
Verify Hestia server / verify (push) Failing after 0s
Verify Hestia server / verify (push) Failing after 0s
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
# Hestia 脚本
|
||||
|
||||
| 文件 | 职责 |
|
||||
| --- | --- |
|
||||
| check.sh | 远端格式、工作区测试和 Clippy |
|
||||
| ci-ces-control.sh | 专用容器/临时 PostgreSQL,验证 SQLite、真实 PostgreSQL 和宿主行为 |
|
||||
| smoke.py | 实际 server 与管理 cli 的 HTTP、SIGTERM、JSON 持久化和无隐式数据库访问检查 |
|
||||
| deploy-ces-control.sh | 已验证镜像部署、只读旧信息导入、健康检查和失败回退 |
|
||||
| README.md | 本目录入口 |
|
||||
|
||||
CI 使用 Hestia 专用容器/缓存名称。生产脚本保留旧 endorphin-aster-vm 资源标识,不能删除数据库或卷。只在指定远端环境执行。返回 [项目入口](../README.md)。
|
||||
@@ -0,0 +1,6 @@
|
||||
#!/bin/sh
|
||||
# Run only in the designated remote validation environment.
|
||||
set -eu
|
||||
cargo fmt --all --check
|
||||
cargo test --workspace --locked
|
||||
cargo clippy --workspace --all-targets --locked -- -D warnings
|
||||
@@ -0,0 +1,58 @@
|
||||
#!/bin/sh
|
||||
# Run only on the designated remote CI host, never on the developer's machine.
|
||||
set -eu
|
||||
cd "$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
|
||||
|
||||
release_sha=${ASTER_RELEASE_SHA:?ASTER_RELEASE_SHA is required}
|
||||
case "$release_sha" in *[!0-9a-f]*|'') echo "Invalid release SHA" >&2; exit 1;; esac
|
||||
test "${#release_sha}" -eq 40
|
||||
|
||||
ci_id="${GITHUB_RUN_ID:-manual}-$$"
|
||||
network="endorphin-hestia-server-ci-$ci_id"
|
||||
postgres="endorphin-hestia-server-ci-pg-$ci_id"
|
||||
checks="endorphin-hestia-server-ci-checks-$ci_id"
|
||||
image="endorphin-hestia-server-ci:$release_sha"
|
||||
cleanup() {
|
||||
docker rm -f "$checks" "$postgres" >/dev/null 2>&1 || true
|
||||
docker network rm "$network" >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
|
||||
docker build --target ci -t "$image" .
|
||||
docker network create "$network" >/dev/null
|
||||
# Disposable test database: no production mounts and no published ports.
|
||||
docker run -d --name "$postgres" --network "$network" --network-alias postgres \
|
||||
--env POSTGRES_USER=aster --env POSTGRES_DB=aster \
|
||||
--env POSTGRES_PASSWORD=aster-ci-only \
|
||||
--tmpfs /var/lib/postgresql/data postgres:17-bookworm >/dev/null
|
||||
|
||||
ready=false
|
||||
for attempt in $(seq 1 60); do
|
||||
if docker exec "$postgres" pg_isready -h 127.0.0.1 -U aster -d aster >/dev/null 2>&1; then
|
||||
ready=true
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
if [ "$ready" != true ]; then
|
||||
echo "CI PostgreSQL did not become ready" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
check_status=0
|
||||
docker run --name "$checks" --init --network "$network" \
|
||||
--mount type=volume,src=endorphin-hestia-server-ci-cargo,dst=/usr/local/cargo/registry \
|
||||
--mount type=volume,src=endorphin-hestia-server-ci-target,dst=/build/target \
|
||||
--env ASTER_TEST_POSTGRES_URL='postgres://aster:aster-ci-only@postgres:5432/aster?sslmode=disable' \
|
||||
--env ASTER_RELEASE_SHA="$release_sha" \
|
||||
"$image" sh -eu -c '
|
||||
checks=0
|
||||
sh scripts/check.sh || checks=$?
|
||||
test "$checks" -eq 0
|
||||
cargo test -p storage --test postgres --locked -- --ignored
|
||||
python3 scripts/smoke.py
|
||||
' || check_status=$?
|
||||
|
||||
exit "$check_status"
|
||||
@@ -0,0 +1,119 @@
|
||||
#!/bin/sh
|
||||
# Deploy only the image produced after the remote database checks succeed.
|
||||
set -eu
|
||||
cd "$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
|
||||
test "$(id -u)" -eq 0 || { echo "Deployment requires root on ces-control" >&2; exit 1; }
|
||||
release_sha=${ASTER_RELEASE_SHA:?ASTER_RELEASE_SHA is required}
|
||||
case "$release_sha" in *[!0-9a-f]*|'') echo "Invalid release SHA" >&2; exit 1;; esac
|
||||
test "${#release_sha}" -eq 40
|
||||
image="endorphin-aster-vm:$release_sha"
|
||||
docker image inspect "$image" >/dev/null
|
||||
|
||||
umask 077
|
||||
root=/opt/endorphin-aster-vm
|
||||
config=/etc/endorphin-aster-vm/production.env
|
||||
release="$root/releases/$release_sha"
|
||||
mkdir -p "$release" /etc/endorphin-aster-vm /var/lib/endorphin-aster-vm/postgres /var/lib/endorphin-aster-vm/host
|
||||
chown 10001:10001 /var/lib/endorphin-aster-vm/host
|
||||
chmod 700 /var/lib/endorphin-aster-vm/host
|
||||
exec 9>/var/lock/endorphin-aster-vm-deploy.lock
|
||||
flock 9
|
||||
|
||||
if [ ! -f "$config" ]; then
|
||||
password=$(openssl rand -hex 24)
|
||||
printf 'POSTGRES_PASSWORD=%s\nASTER_PORT=18081\n' "$password" > "$config"
|
||||
unset password
|
||||
fi
|
||||
chmod 600 "$config"
|
||||
cp docker-compose.ces-control.yml "$release/compose.yml"
|
||||
printf 'ASTER_IMAGE=%s\n' "$image" > "$release/release.env"
|
||||
previous=$(readlink -f "$root/current" 2>/dev/null || true)
|
||||
legacy_export=
|
||||
cleanup_export() {
|
||||
if [ -n "$legacy_export" ]; then rm -f "$legacy_export"; fi
|
||||
}
|
||||
trap cleanup_export EXIT
|
||||
|
||||
compose_at() {
|
||||
location=$1
|
||||
shift
|
||||
docker compose -p endorphin-aster-vm --env-file "$config" \
|
||||
--env-file "$location/release.env" -f "$location/compose.yml" "$@"
|
||||
}
|
||||
rollback() {
|
||||
echo "Deployment failed; application data has been retained" >&2
|
||||
if [ -n "$previous" ] && [ "$previous" != "$release" ] && [ -f "$previous/compose.yml" ]; then
|
||||
echo "Restoring the previously healthy release" >&2
|
||||
compose_at "$previous" up -d --no-build --wait --wait-timeout 180 || true
|
||||
fi
|
||||
}
|
||||
|
||||
if ! compose_at "$release" up -d --no-build --wait --wait-timeout 180; then
|
||||
rollback
|
||||
exit 1
|
||||
fi
|
||||
if ! health=$(compose_at "$release" exec -T aster curl --fail --silent http://127.0.0.1:8080/health); then
|
||||
rollback
|
||||
exit 1
|
||||
fi
|
||||
printf '%s' "$health" | python3 -c 'import json,sys; d=json.load(sys.stdin); assert d == {"status":"ok"}' || {
|
||||
rollback
|
||||
exit 1
|
||||
}
|
||||
if ! bootstrap=$(compose_at "$release" exec -T aster cli bootstrap); then
|
||||
rollback
|
||||
exit 1
|
||||
fi
|
||||
printf '%s' "$bootstrap" | python3 -c 'import sys; s=sys.stdin.read(); assert "runtime=EndorObject type_system=disabled" in s; assert "released heap_objects=0" in s' || {
|
||||
rollback
|
||||
exit 1
|
||||
}
|
||||
# Import legacy basic information once, without changing database records.
|
||||
# Files are authoritative; cli import only creates records that are missing.
|
||||
if [ ! -f /var/lib/endorphin-aster-vm/host/.legacy-imported ]; then
|
||||
if ! has_legacy=$(compose_at "$release" exec -T postgres env PGCLIENTENCODING=UTF8 psql -X -U aster -d aster -A -t -v ON_ERROR_STOP=1 \
|
||||
-c "SELECT to_regclass('public.aster_metadata') IS NOT NULL"); then
|
||||
rollback
|
||||
exit 1
|
||||
fi
|
||||
if [ "$has_legacy" = t ]; then
|
||||
legacy_export=$(mktemp)
|
||||
if ! compose_at "$release" exec -T postgres env PGCLIENTENCODING=UTF8 psql -X -U aster -d aster -A -t -v ON_ERROR_STOP=1 \
|
||||
-c "SELECT COALESCE(json_agg(json_build_object('key', key, 'value', value)), '[]'::json) FROM public.aster_metadata" > "$legacy_export"; then
|
||||
rollback
|
||||
exit 1
|
||||
fi
|
||||
if ! compose_at "$release" exec -T aster cli metadata import < "$legacy_export"; then
|
||||
rollback
|
||||
exit 1
|
||||
fi
|
||||
rm -f "$legacy_export"
|
||||
legacy_export=
|
||||
elif [ "$has_legacy" != f ]; then
|
||||
echo "Cannot determine legacy information schema" >&2
|
||||
rollback
|
||||
exit 1
|
||||
fi
|
||||
if ! touch /var/lib/endorphin-aster-vm/host/.legacy-imported; then
|
||||
rollback
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
# File-backed host information; no SQL table or database write.
|
||||
if ! compose_at "$release" exec -T aster cli metadata put deployment.release "$release_sha"; then
|
||||
rollback
|
||||
exit 1
|
||||
fi
|
||||
if ! actual=$(compose_at "$release" exec -T aster cli metadata get deployment.release); then
|
||||
rollback
|
||||
exit 1
|
||||
fi
|
||||
if [ "$actual" != "$release_sha" ]; then
|
||||
rollback
|
||||
exit 1
|
||||
fi
|
||||
ln -sfn "$release" "$root/current.next"
|
||||
mv -Tf "$root/current.next" "$root/current"
|
||||
compose_at "$release" ps
|
||||
echo "Deployed release=$release_sha postgres=provisioned endpoint=127.0.0.1:18081"
|
||||
echo "Persistent PostgreSQL data: /var/lib/endorphin-aster-vm/postgres"
|
||||
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Remote CI end-to-end smoke test, no external database required."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def run(*args, env):
|
||||
return subprocess.run(args, cwd=ROOT, env=env, check=True, text=True, capture_output=True).stdout.strip()
|
||||
|
||||
|
||||
def main():
|
||||
env = os.environ.copy()
|
||||
run("cargo", "build", "--workspace", "--locked", env=env)
|
||||
# Cargo may use a configured CARGO_TARGET_DIR; discover the actual binaries.
|
||||
metadata = json.loads(run("cargo", "metadata", "--no-deps", "--format-version", "1", env=env))
|
||||
binary_dir = Path(metadata["target_directory"]) / "debug"
|
||||
cli = str(binary_dir / "cli")
|
||||
server = str(binary_dir / "server")
|
||||
with tempfile.TemporaryDirectory(prefix="aster-smoke-") as directory:
|
||||
env["ASTER_STATE_DIR"] = str(Path(directory) / "host-info")
|
||||
env["SQLITE_PATH"] = str(Path(directory) / "nested" / "aster.db")
|
||||
# Neither executable may use database settings for basic VM information.
|
||||
env["DATABASE_URL"] = "invalid-database-url"
|
||||
bootstrap = run(cli, "bootstrap", env=env)
|
||||
assert "runtime=EndorObject type_system=disabled" in bootstrap, bootstrap
|
||||
assert "objects_distinct=true heap_objects=2" in bootstrap, bootstrap
|
||||
assert "released heap_objects=0" in bootstrap, bootstrap
|
||||
assert "stored (file)" in run(cli, "metadata", "put", "project.note", "日本語・中文・😀", env=env)
|
||||
assert run(cli, "metadata", "get", "project.note", env=env) == "日本語・中文・😀"
|
||||
legacy = json.dumps([{"key": "project.note", "value": "old"},
|
||||
{"key": "legacy.note", "value": "preserved"}])
|
||||
subprocess.run([cli, "metadata", "import"], input=legacy, cwd=ROOT, env=env,
|
||||
check=True, text=True, capture_output=True)
|
||||
assert run(cli, "metadata", "get", "project.note", env=env) == "日本語・中文・😀"
|
||||
assert run(cli, "metadata", "get", "legacy.note", env=env) == "preserved"
|
||||
assert not Path(env["SQLITE_PATH"]).exists(), "CLI must not open a database"
|
||||
|
||||
with socket.socket() as sock:
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
port = sock.getsockname()[1]
|
||||
env["ASTER_LISTEN"] = f"127.0.0.1:{port}"
|
||||
with tempfile.TemporaryFile(mode="w+") as log:
|
||||
process = subprocess.Popen([server], cwd=ROOT, env=env, stdout=log, stderr=log)
|
||||
try:
|
||||
deadline = time.monotonic() + 15
|
||||
while True:
|
||||
try:
|
||||
with urllib.request.urlopen(f"http://127.0.0.1:{port}/health", timeout=1) as response:
|
||||
health = json.load(response)
|
||||
break
|
||||
except (OSError, ValueError):
|
||||
if process.poll() is not None or time.monotonic() >= deadline:
|
||||
log.seek(0)
|
||||
raise AssertionError(f"server did not become healthy: {log.read()}")
|
||||
time.sleep(0.1)
|
||||
assert health == {"status": "ok"}, health
|
||||
for _ in range(2):
|
||||
with urllib.request.urlopen(f"http://127.0.0.1:{port}/bootstrap", timeout=2) as response:
|
||||
info = json.load(response)
|
||||
assert info == {"object_model": "EndorObject", "type_system": False, "heap_objects": 0}, info
|
||||
finally:
|
||||
process.send_signal(signal.SIGTERM)
|
||||
try:
|
||||
process.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait()
|
||||
raise AssertionError("server did not shut down gracefully")
|
||||
assert process.returncode == 0, process.returncode
|
||||
assert not Path(env["SQLITE_PATH"]).exists(), "Server must not open a database"
|
||||
assert run(cli, "metadata", "get", "host.version", env=env) == "0.1.0"
|
||||
record = json.loads((Path(env["ASTER_STATE_DIR"]) / "host.version.json").read_text(encoding="utf-8"))
|
||||
assert record == {"format_version": 1, "key": "host.version", "value": "0.1.0"}
|
||||
assert run(cli, "metadata", "get", "project.note", env=env) == "日本語・中文・😀"
|
||||
print("PASS: no implicit database access; file metadata persistence; HTTP health/bootstrap; SIGTERM shutdown")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user