Deploy and Keep Active #127
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Deploy and Keep Active | |
| on: | |
| push: | |
| branches: [ main ] | |
| paths: | |
| - 'Dockerfile' | |
| - 'docker-compose.yml' | |
| - '2fa_bot.py' | |
| - 'requirements.txt' | |
| - 'scripts/**' | |
| # Runs automatically every day at 0:00 UTC (8:00 AM Beijing Time) | |
| schedule: | |
| - cron: '0 0 * * *' | |
| workflow_dispatch: | |
| inputs: | |
| deploy_mode: | |
| description: keepalive skips docker build; full rebuilds the container | |
| required: false | |
| default: keepalive | |
| type: choice | |
| options: | |
| - keepalive | |
| - full | |
| sync_github_secrets_to_secret_manager: | |
| description: write current GitHub secrets into Secret Manager before deploy | |
| required: false | |
| default: false | |
| type: boolean | |
| env: | |
| GCP_PROJECT_ID: interactivebrokersquant | |
| GCP_WORKLOAD_IDENTITY_PROVIDER: projects/303168642265/locations/global/workloadIdentityPools/github-actions/providers/github-ibkr-gateway-main | |
| GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT: ibkr-gateway-deploy@interactivebrokersquant.iam.gserviceaccount.com | |
| jobs: | |
| deploy: | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 20 | |
| permissions: | |
| contents: read | |
| id-token: write | |
| env: | |
| DEPLOY_EVENT_NAME: ${{ github.event_name }} | |
| GCE_USER: ${{ vars.IB_GATEWAY_GCE_USER }} | |
| GCE_INSTANCE_NAME: ${{ vars.IB_GATEWAY_INSTANCE_NAME }} | |
| GCE_ZONE: ${{ vars.IB_GATEWAY_ZONE }} | |
| DEPLOY_PATH: ${{ vars.IB_GATEWAY_DEPLOY_PATH }} | |
| IB_GATEWAY_MODE: ${{ vars.IB_GATEWAY_MODE }} | |
| CLOUD_RUN_EGRESS_CIDR: ${{ vars.IB_GATEWAY_CLOUD_RUN_EGRESS_CIDR }} | |
| ALLOW_CONNECTIONS_FROM_LOCALHOST_ONLY: ${{ vars.IB_GATEWAY_ALLOW_CONNECTIONS_FROM_LOCALHOST_ONLY }} | |
| TWS_ACCEPT_INCOMING: ${{ vars.IB_GATEWAY_TWS_ACCEPT_INCOMING }} | |
| READ_ONLY_API: ${{ vars.IB_GATEWAY_READ_ONLY_API }} | |
| SSH_PRIVATE_KEY_SECRET_NAME: ${{ vars.IB_GATEWAY_SSH_PRIVATE_KEY_SECRET_NAME }} | |
| TWS_USERID_SECRET_NAME: ${{ vars.IB_GATEWAY_TWS_USERID_SECRET_NAME }} | |
| TWS_PASSWORD_SECRET_NAME: ${{ vars.IB_GATEWAY_TWS_PASSWORD_SECRET_NAME }} | |
| TOTP_SECRET_SECRET_NAME: ${{ vars.IB_GATEWAY_TOTP_SECRET_SECRET_NAME }} | |
| VNC_SERVER_PASSWORD_SECRET_NAME: ${{ vars.IB_GATEWAY_VNC_SERVER_PASSWORD_SECRET_NAME }} | |
| WORKFLOW_DISPATCH_MODE: ${{ github.event.inputs.deploy_mode }} | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v6 | |
| - name: Check whether deployment config is complete | |
| env: | |
| SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} | |
| TWS_USERID: ${{ secrets.TWS_USERID }} | |
| TWS_PASSWORD: ${{ secrets.TWS_PASSWORD }} | |
| TOTP_SECRET: ${{ secrets.TOTP_SECRET }} | |
| VNC_SERVER_PASSWORD: ${{ secrets.VNC_SERVER_PASSWORD }} | |
| run: | | |
| set -euo pipefail | |
| required_vars=( | |
| GCE_USER | |
| GCE_INSTANCE_NAME | |
| GCE_ZONE | |
| DEPLOY_PATH | |
| IB_GATEWAY_MODE | |
| CLOUD_RUN_EGRESS_CIDR | |
| ALLOW_CONNECTIONS_FROM_LOCALHOST_ONLY | |
| ) | |
| for var_name in "${required_vars[@]}"; do | |
| if [ -z "${!var_name:-}" ]; then | |
| echo "${var_name} is required. Set it in GitHub Actions variables before running this workflow." >&2 | |
| exit 1 | |
| fi | |
| done | |
| require_secret_source() { | |
| local secret_name_var="$1" | |
| local fallback_var="$2" | |
| local label="$3" | |
| if [ -n "${!secret_name_var:-}" ] || [ -n "${!fallback_var:-}" ]; then | |
| return 0 | |
| fi | |
| echo "${label} is required. Set ${secret_name_var} as a GitHub variable pointing to Secret Manager, or set ${fallback_var} as a GitHub Actions secret." >&2 | |
| exit 1 | |
| } | |
| require_secret_source SSH_PRIVATE_KEY_SECRET_NAME SSH_PRIVATE_KEY SSH_PRIVATE_KEY | |
| require_secret_source TWS_USERID_SECRET_NAME TWS_USERID TWS_USERID | |
| require_secret_source TWS_PASSWORD_SECRET_NAME TWS_PASSWORD TWS_PASSWORD | |
| require_secret_source TOTP_SECRET_SECRET_NAME TOTP_SECRET TOTP_SECRET | |
| require_secret_source VNC_SERVER_PASSWORD_SECRET_NAME VNC_SERVER_PASSWORD VNC_SERVER_PASSWORD | |
| - name: Authenticate to Google Cloud | |
| id: auth | |
| uses: google-github-actions/auth@v3 | |
| with: | |
| workload_identity_provider: ${{ env.GCP_WORKLOAD_IDENTITY_PROVIDER }} | |
| service_account: ${{ env.GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT }} | |
| - name: Set up gcloud | |
| uses: google-github-actions/setup-gcloud@v3 | |
| with: | |
| project_id: ${{ env.GCP_PROJECT_ID }} | |
| version: '>= 416.0.0' | |
| - name: Sync GitHub secrets to Secret Manager | |
| if: ${{ github.event_name == 'workflow_dispatch' && inputs.sync_github_secrets_to_secret_manager }} | |
| env: | |
| SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} | |
| TWS_USERID: ${{ secrets.TWS_USERID }} | |
| TWS_PASSWORD: ${{ secrets.TWS_PASSWORD }} | |
| TOTP_SECRET: ${{ secrets.TOTP_SECRET }} | |
| VNC_SERVER_PASSWORD: ${{ secrets.VNC_SERVER_PASSWORD }} | |
| run: | | |
| set -euo pipefail | |
| sync_secret() { | |
| local secret_name="$1" | |
| local secret_value="$2" | |
| local label="$3" | |
| if [ -z "${secret_name:-}" ]; then | |
| echo "${label} secret name variable is required before syncing to Secret Manager." >&2 | |
| exit 1 | |
| fi | |
| if [ -z "${secret_value:-}" ]; then | |
| echo "${label} GitHub secret is empty; cannot sync to Secret Manager." >&2 | |
| exit 1 | |
| fi | |
| printf '%s' "${secret_value}" | gcloud secrets versions add "${secret_name}" --project "${GCP_PROJECT_ID}" --data-file=- >/dev/null | |
| } | |
| echo "::add-mask::${TWS_USERID}" | |
| echo "::add-mask::${TWS_PASSWORD}" | |
| echo "::add-mask::${TOTP_SECRET}" | |
| echo "::add-mask::${VNC_SERVER_PASSWORD}" | |
| sync_secret "${SSH_PRIVATE_KEY_SECRET_NAME:-}" "${SSH_PRIVATE_KEY:-}" "SSH_PRIVATE_KEY" | |
| sync_secret "${TWS_USERID_SECRET_NAME:-}" "${TWS_USERID:-}" "TWS_USERID" | |
| sync_secret "${TWS_PASSWORD_SECRET_NAME:-}" "${TWS_PASSWORD:-}" "TWS_PASSWORD" | |
| sync_secret "${TOTP_SECRET_SECRET_NAME:-}" "${TOTP_SECRET:-}" "TOTP_SECRET" | |
| sync_secret "${VNC_SERVER_PASSWORD_SECRET_NAME:-}" "${VNC_SERVER_PASSWORD:-}" "VNC_SERVER_PASSWORD" | |
| - name: Prepare SSH key and deployment files | |
| env: | |
| SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} | |
| TWS_USERID: ${{ secrets.TWS_USERID }} | |
| TWS_PASSWORD: ${{ secrets.TWS_PASSWORD }} | |
| TOTP_SECRET: ${{ secrets.TOTP_SECRET }} | |
| VNC_SERVER_PASSWORD: ${{ secrets.VNC_SERVER_PASSWORD }} | |
| run: | | |
| set -euo pipefail | |
| resolve_secret() { | |
| local secret_name="$1" | |
| local fallback_value="$2" | |
| if [ -n "${secret_name:-}" ]; then | |
| gcloud secrets versions access latest --project "${GCP_PROJECT_ID}" --secret "${secret_name}" | |
| else | |
| printf '%s' "${fallback_value}" | |
| fi | |
| } | |
| ssh_private_key="$(resolve_secret "${SSH_PRIVATE_KEY_SECRET_NAME:-}" "${SSH_PRIVATE_KEY:-}")" | |
| tws_userid="$(resolve_secret "${TWS_USERID_SECRET_NAME:-}" "${TWS_USERID:-}")" | |
| tws_password="$(resolve_secret "${TWS_PASSWORD_SECRET_NAME:-}" "${TWS_PASSWORD:-}")" | |
| totp_secret="$(resolve_secret "${TOTP_SECRET_SECRET_NAME:-}" "${TOTP_SECRET:-}")" | |
| vnc_server_password="$(resolve_secret "${VNC_SERVER_PASSWORD_SECRET_NAME:-}" "${VNC_SERVER_PASSWORD:-}")" | |
| for required_value in ssh_private_key tws_userid tws_password totp_secret vnc_server_password; do | |
| if [ -z "${!required_value:-}" ]; then | |
| echo "Resolved ${required_value} is empty." >&2 | |
| exit 1 | |
| fi | |
| done | |
| echo "::add-mask::${tws_userid}" | |
| echo "::add-mask::${tws_password}" | |
| echo "::add-mask::${totp_secret}" | |
| echo "::add-mask::${vnc_server_password}" | |
| install -d -m 700 "$RUNNER_TEMP/ssh" | |
| SSH_KEY_FILE="$RUNNER_TEMP/ssh/google_compute_engine" | |
| printf '%s\n' "$ssh_private_key" | tr -d '\r' > "$SSH_KEY_FILE" | |
| chmod 600 "$SSH_KEY_FILE" | |
| if ! ssh-keygen -y -f "$SSH_KEY_FILE" > "$SSH_KEY_FILE.pub"; then | |
| echo "Resolved SSH private key is invalid or passphrase-protected; provide an unencrypted private key that matches the VM authorized_keys entry." >&2 | |
| exit 1 | |
| fi | |
| chmod 644 "$SSH_KEY_FILE.pub" | |
| echo "SSH_KEY_FILE=$SSH_KEY_FILE" >> "$GITHUB_ENV" | |
| ENV_FILE="$RUNNER_TEMP/ibkr-gateway.env" | |
| export ENV_FILE | |
| export RESOLVED_TWS_USERID="$tws_userid" | |
| export RESOLVED_TWS_PASSWORD="$tws_password" | |
| export RESOLVED_TOTP_SECRET="$totp_secret" | |
| export RESOLVED_VNC_SERVER_PASSWORD="$vnc_server_password" | |
| python3 - <<'PY' | |
| import os | |
| env_file = os.environ["ENV_FILE"] | |
| values = { | |
| "TWS_USERID": os.environ["RESOLVED_TWS_USERID"], | |
| "TWS_PASSWORD": os.environ["RESOLVED_TWS_PASSWORD"], | |
| "TOTP_SECRET": os.environ["RESOLVED_TOTP_SECRET"], | |
| "VNC_SERVER_PASSWORD": os.environ["RESOLVED_VNC_SERVER_PASSWORD"], | |
| "TRADING_MODE": os.environ["IB_GATEWAY_MODE"], | |
| "ACCEPT_API_FROM_IP": os.environ["CLOUD_RUN_EGRESS_CIDR"], | |
| "ALLOW_CONNECTIONS_FROM_LOCALHOST_ONLY": os.environ["ALLOW_CONNECTIONS_FROM_LOCALHOST_ONLY"], | |
| "TWS_ACCEPT_INCOMING": os.environ["TWS_ACCEPT_INCOMING"], | |
| "READ_ONLY_API": os.environ["READ_ONLY_API"], | |
| } | |
| def quote(value: str) -> str: | |
| return "'" + value.replace("'", "'\"'\"'") + "'" | |
| with open(env_file, "w", encoding="utf-8") as fh: | |
| for key, value in values.items(): | |
| fh.write(f"{key}={quote(value)}\n") | |
| PY | |
| echo "ENV_FILE=$ENV_FILE" >> "$GITHUB_ENV" | |
| - name: Deploy and Log Setup | |
| run: | | |
| set -euo pipefail | |
| log_step() { | |
| echo "[$(date -u +%FT%TZ)] $*" | |
| } | |
| REMOTE_TARGET="${GCE_USER}@${GCE_INSTANCE_NAME}" | |
| REMOTE_COMMON_FLAGS=( | |
| --project "${GCP_PROJECT_ID}" | |
| --zone "${GCE_ZONE}" | |
| --quiet | |
| --tunnel-through-iap | |
| --ssh-key-file "${SSH_KEY_FILE}" | |
| ) | |
| SSH_FLAGS=( | |
| "${REMOTE_COMMON_FLAGS[@]}" | |
| --ssh-flag="-o ServerAliveInterval=30" | |
| --ssh-flag="-o ServerAliveCountMax=10" | |
| --ssh-flag="-o TCPKeepAlive=yes" | |
| ) | |
| SCP_FLAGS=( | |
| "${REMOTE_COMMON_FLAGS[@]}" | |
| --scp-flag="-o ServerAliveInterval=30" | |
| --scp-flag="-o ServerAliveCountMax=10" | |
| --scp-flag="-o TCPKeepAlive=yes" | |
| ) | |
| REMOTE_SYNC_COMMAND=$(cat <<EOF | |
| set -euo pipefail | |
| echo "[\$(date -u +%FT%TZ)] Syncing repository contents" | |
| sudo mkdir -p '${DEPLOY_PATH}' | |
| sudo chown -R '${GCE_USER}':'${GCE_USER}' '${DEPLOY_PATH}' | |
| if [ -d '${DEPLOY_PATH}/.git' ]; then | |
| cd '${DEPLOY_PATH}' | |
| git fetch --all | |
| git reset --hard origin/main | |
| else | |
| rm -rf '${DEPLOY_PATH}' | |
| git clone 'https://github.com/${{ github.repository }}.git' '${DEPLOY_PATH}' | |
| fi | |
| EOF | |
| ) | |
| log_step "Syncing repository to ${DEPLOY_PATH}" | |
| gcloud compute ssh "${REMOTE_TARGET}" "${SSH_FLAGS[@]}" --command "${REMOTE_SYNC_COMMAND}" | |
| log_step "Uploading runtime env file" | |
| gcloud compute scp "${ENV_FILE}" "${REMOTE_TARGET}:${DEPLOY_PATH}/.env" "${SCP_FLAGS[@]}" | |
| DEPLOY_MODE="full" | |
| if [ "${DEPLOY_EVENT_NAME}" = "schedule" ]; then | |
| DEPLOY_MODE="keepalive" | |
| elif [ "${DEPLOY_EVENT_NAME}" = "workflow_dispatch" ]; then | |
| DEPLOY_MODE="${WORKFLOW_DISPATCH_MODE:-keepalive}" | |
| fi | |
| if [ "${DEPLOY_MODE}" = "keepalive" ]; then | |
| REMOTE_DEPLOY_COMMAND=$(cat <<EOF | |
| set -euo pipefail | |
| cd '${DEPLOY_PATH}' | |
| echo "[\$(date -u +%FT%TZ)] Scheduled keepalive mode: skip docker build" | |
| sudo docker compose up -d --no-build | |
| sudo bash ./scripts/install_2fa_bot_watcher.sh | |
| sudo bash ./scripts/wait_for_ib_gateway_ready.sh '${IB_GATEWAY_MODE}' | |
| sudo docker compose ps | |
| sudo systemctl status ibkr-2fa-bot.timer --no-pager | |
| echo "[\$(date -u +%FT%TZ)] Keepalive complete." | |
| EOF | |
| ) | |
| else | |
| REMOTE_DEPLOY_COMMAND=$(cat <<EOF | |
| set -euo pipefail | |
| cd '${DEPLOY_PATH}' | |
| echo "[\$(date -u +%FT%TZ)] Full deploy mode: rebuilding container" | |
| sudo docker compose build | |
| sudo docker compose down | |
| sudo docker compose up -d | |
| sudo bash ./scripts/install_2fa_bot_watcher.sh | |
| sudo bash ./scripts/wait_for_ib_gateway_ready.sh '${IB_GATEWAY_MODE}' | |
| sudo docker compose ps | |
| sudo systemctl status ibkr-2fa-bot.timer --no-pager | |
| echo "Deployment complete. Bot is monitoring the login window." | |
| echo "Run 'docker exec ib-gateway tail -f /home/ibgateway/2fa.log' for real-time status." | |
| EOF | |
| ) | |
| fi | |
| log_step "Running remote deploy commands for event ${DEPLOY_EVENT_NAME} in mode ${DEPLOY_MODE}" | |
| gcloud compute ssh "${REMOTE_TARGET}" "${SSH_FLAGS[@]}" --command "${REMOTE_DEPLOY_COMMAND}" |