Initial commit: Vault Dashboard for options hedging
- FastAPI + NiceGUI web application - QuantLib-based Black-Scholes pricing with Greeks - Protective put, laddered, and LEAPS strategies - Real-time WebSocket updates - TradingView-style charts via Lightweight-Charts - Docker containerization - GitLab CI/CD pipeline for VPS deployment - VPN-only access configuration
This commit is contained in:
91
scripts/deploy.sh
Executable file
91
scripts/deploy.sh
Executable file
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
: "${DEPLOY_USER:?DEPLOY_USER is required}"
|
||||
: "${DEPLOY_HOST:?DEPLOY_HOST is required}"
|
||||
: "${CI_REGISTRY_IMAGE:?CI_REGISTRY_IMAGE is required}"
|
||||
: "${CI_REGISTRY_USER:?CI_REGISTRY_USER is required}"
|
||||
: "${CI_REGISTRY_PASSWORD:?CI_REGISTRY_PASSWORD is required}"
|
||||
|
||||
DEPLOY_PORT="${DEPLOY_PORT:-22}"
|
||||
DEPLOY_PATH="${DEPLOY_PATH:-/opt/vault-dash}"
|
||||
COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.deploy.yml}"
|
||||
COMPOSE_SERVICE="${COMPOSE_SERVICE:-vault-dash}"
|
||||
DEPLOY_TIMEOUT="${DEPLOY_TIMEOUT:-120}"
|
||||
HEALTHCHECK_URL="${HEALTHCHECK_URL:-http://127.0.0.1:${APP_PORT:-8000}/health}"
|
||||
IMAGE_TAG="${IMAGE_TAG:-${CI_COMMIT_SHA}}"
|
||||
APP_IMAGE="${APP_IMAGE:-${CI_REGISTRY_IMAGE}:${IMAGE_TAG}}"
|
||||
REMOTE_ENV_FILE="${REMOTE_ENV_FILE:-$DEPLOY_PATH/.env}"
|
||||
SSH_OPTS=(-p "$DEPLOY_PORT" -o StrictHostKeyChecking=no)
|
||||
REMOTE_TARGET="${DEPLOY_USER}@${DEPLOY_HOST}"
|
||||
|
||||
ssh "${SSH_OPTS[@]}" "$REMOTE_TARGET" "mkdir -p '$DEPLOY_PATH'"
|
||||
|
||||
if [[ -n "${APP_ENV_FILE:-}" ]]; then
|
||||
printf '%s\n' "$APP_ENV_FILE" | ssh "${SSH_OPTS[@]}" "$REMOTE_TARGET" "cat > '$REMOTE_ENV_FILE'"
|
||||
else
|
||||
ssh "${SSH_OPTS[@]}" "$REMOTE_TARGET" "cat > '$REMOTE_ENV_FILE'" <<EOF
|
||||
APP_IMAGE=$APP_IMAGE
|
||||
APP_ENV=${APP_ENV:-production}
|
||||
APP_NAME=${APP_NAME:-Vault Dashboard}
|
||||
APP_PORT=${APP_PORT:-8000}
|
||||
APP_BIND_ADDRESS=${APP_BIND_ADDRESS:-127.0.0.1}
|
||||
REDIS_URL=${REDIS_URL:-}
|
||||
DEFAULT_SYMBOL=${DEFAULT_SYMBOL:-GLD}
|
||||
CACHE_TTL=${CACHE_TTL:-300}
|
||||
WEBSOCKET_INTERVAL_SECONDS=${WEBSOCKET_INTERVAL_SECONDS:-5}
|
||||
NICEGUI_MOUNT_PATH=${NICEGUI_MOUNT_PATH:-/}
|
||||
NICEGUI_STORAGE_SECRET=${NICEGUI_STORAGE_SECRET:-}
|
||||
CORS_ORIGINS=${CORS_ORIGINS:-*}
|
||||
EOF
|
||||
fi
|
||||
|
||||
scp "${SSH_OPTS[@]}" docker-compose.deploy.yml "$REMOTE_TARGET:$DEPLOY_PATH/$COMPOSE_FILE"
|
||||
|
||||
ssh "${SSH_OPTS[@]}" "$REMOTE_TARGET" "export CI_REGISTRY='${CI_REGISTRY:-registry.gitlab.com}' CI_REGISTRY_USER='$CI_REGISTRY_USER' CI_REGISTRY_PASSWORD='$CI_REGISTRY_PASSWORD' DEPLOY_PATH='$DEPLOY_PATH' COMPOSE_FILE='$COMPOSE_FILE' COMPOSE_SERVICE='$COMPOSE_SERVICE' APP_IMAGE='$APP_IMAGE' HEALTHCHECK_URL='$HEALTHCHECK_URL' DEPLOY_TIMEOUT='$DEPLOY_TIMEOUT' REMOTE_ENV_FILE='$REMOTE_ENV_FILE'; bash -s" <<'EOF'
|
||||
set -Eeuo pipefail
|
||||
cd "$DEPLOY_PATH"
|
||||
|
||||
if [[ -f .last_successful_image ]]; then
|
||||
PREVIOUS_IMAGE="$(cat .last_successful_image)"
|
||||
else
|
||||
PREVIOUS_IMAGE=""
|
||||
fi
|
||||
|
||||
if docker compose -f "$COMPOSE_FILE" --env-file "$REMOTE_ENV_FILE" ps -q "$COMPOSE_SERVICE" >/dev/null 2>&1; then
|
||||
CURRENT_CONTAINER="$(docker compose -f "$COMPOSE_FILE" --env-file "$REMOTE_ENV_FILE" ps -q "$COMPOSE_SERVICE" || true)"
|
||||
if [[ -n "$CURRENT_CONTAINER" ]]; then
|
||||
CURRENT_IMAGE="$(docker inspect -f '{{ .Config.Image }}' "$CURRENT_CONTAINER")"
|
||||
if [[ -n "$CURRENT_IMAGE" ]]; then
|
||||
PREVIOUS_IMAGE="$CURRENT_IMAGE"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "$CI_REGISTRY_PASSWORD" | docker login "$CI_REGISTRY" -u "$CI_REGISTRY_USER" --password-stdin
|
||||
|
||||
docker pull "$APP_IMAGE"
|
||||
sed -i.bak "/^APP_IMAGE=/d" "$REMOTE_ENV_FILE"
|
||||
printf 'APP_IMAGE=%s\n' "$APP_IMAGE" | cat - "$REMOTE_ENV_FILE.bak" > "$REMOTE_ENV_FILE"
|
||||
rm -f "$REMOTE_ENV_FILE.bak"
|
||||
|
||||
docker compose -f "$COMPOSE_FILE" --env-file "$REMOTE_ENV_FILE" up -d --remove-orphans
|
||||
|
||||
end_time=$((SECONDS + DEPLOY_TIMEOUT))
|
||||
until curl -fsS "$HEALTHCHECK_URL" >/dev/null; do
|
||||
if (( SECONDS >= end_time )); then
|
||||
echo "Deployment health check failed, attempting rollback" >&2
|
||||
if [[ -n "$PREVIOUS_IMAGE" ]]; then
|
||||
sed -i.bak "/^APP_IMAGE=/d" "$REMOTE_ENV_FILE"
|
||||
printf 'APP_IMAGE=%s\n' "$PREVIOUS_IMAGE" | cat - "$REMOTE_ENV_FILE.bak" > "$REMOTE_ENV_FILE"
|
||||
rm -f "$REMOTE_ENV_FILE.bak"
|
||||
docker pull "$PREVIOUS_IMAGE" || true
|
||||
docker compose -f "$COMPOSE_FILE" --env-file "$REMOTE_ENV_FILE" up -d --remove-orphans
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
|
||||
echo "$APP_IMAGE" > .last_successful_image
|
||||
EOF
|
||||
70
scripts/entrypoint.sh
Executable file
70
scripts/entrypoint.sh
Executable file
@@ -0,0 +1,70 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
APP_MODULE="${APP_MODULE:-app.main:app}"
|
||||
APP_HOST="${APP_HOST:-0.0.0.0}"
|
||||
APP_PORT="${APP_PORT:-8000}"
|
||||
APP_ENV="${APP_ENV:-production}"
|
||||
WORKERS="${UVICORN_WORKERS:-1}"
|
||||
REDIS_WAIT_TIMEOUT="${REDIS_WAIT_TIMEOUT:-30}"
|
||||
|
||||
wait_for_redis() {
|
||||
if [ -z "${REDIS_URL:-}" ]; then
|
||||
echo "REDIS_URL not set, skipping Redis wait"
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "Waiting for Redis: ${REDIS_URL}"
|
||||
python - <<'PY'
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from urllib.parse import urlparse
|
||||
|
||||
redis_url = os.environ.get("REDIS_URL", "")
|
||||
timeout = int(os.environ.get("REDIS_WAIT_TIMEOUT", "30"))
|
||||
parsed = urlparse(redis_url)
|
||||
host = parsed.hostname or "redis"
|
||||
port = parsed.port or 6379
|
||||
deadline = time.time() + timeout
|
||||
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
import socket
|
||||
with socket.create_connection((host, port), timeout=2):
|
||||
print(f"Redis is reachable at {host}:{port}")
|
||||
raise SystemExit(0)
|
||||
except OSError:
|
||||
time.sleep(1)
|
||||
|
||||
print(f"Timed out waiting for Redis at {host}:{port}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
PY
|
||||
}
|
||||
|
||||
run_migrations() {
|
||||
if [ "${RUN_MIGRATIONS:-0}" != "1" ]; then
|
||||
echo "RUN_MIGRATIONS disabled, skipping"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ -f alembic.ini ] && command -v alembic >/dev/null 2>&1; then
|
||||
echo "Running Alembic migrations"
|
||||
alembic upgrade head
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ -f manage.py ]; then
|
||||
echo "Running Django migrations"
|
||||
python manage.py migrate --noinput
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "No supported migration command found, skipping"
|
||||
}
|
||||
|
||||
wait_for_redis
|
||||
run_migrations
|
||||
|
||||
echo "Starting application on ${APP_HOST}:${APP_PORT}"
|
||||
exec python -m uvicorn "${APP_MODULE}" --host "${APP_HOST}" --port "${APP_PORT}" --workers "${WORKERS}"
|
||||
53
scripts/healthcheck.py
Executable file
53
scripts/healthcheck.py
Executable file
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Simple deployment health check utility."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import time
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.request import urlopen
|
||||
import json
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Poll an HTTP health endpoint until it is ready.")
|
||||
parser.add_argument("url", help="Health endpoint URL, e.g. https://vault.example.com/health")
|
||||
parser.add_argument("--timeout", type=int, default=120, help="Maximum wait time in seconds")
|
||||
parser.add_argument("--interval", type=int, default=5, help="Poll interval in seconds")
|
||||
parser.add_argument("--expect-status", default="ok", help="Expected JSON status field")
|
||||
parser.add_argument("--expect-environment", default=None, help="Optional expected JSON environment field")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def fetch_json(url: str) -> dict:
|
||||
with urlopen(url, timeout=10) as response:
|
||||
body = response.read().decode("utf-8")
|
||||
return json.loads(body)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
deadline = time.time() + args.timeout
|
||||
last_error = "unknown error"
|
||||
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
payload = fetch_json(args.url)
|
||||
if payload.get("status") != args.expect_status:
|
||||
raise RuntimeError(f"unexpected status: {payload!r}")
|
||||
if args.expect_environment and payload.get("environment") != args.expect_environment:
|
||||
raise RuntimeError(f"unexpected environment: {payload!r}")
|
||||
print(f"healthcheck passed: {payload}")
|
||||
return 0
|
||||
except (HTTPError, URLError, TimeoutError, ValueError, RuntimeError) as exc:
|
||||
last_error = str(exc)
|
||||
time.sleep(args.interval)
|
||||
|
||||
print(f"healthcheck failed after {args.timeout}s: {last_error}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user