Skip to content

Deploy and Keep Active #115

Deploy and Keep Active

Deploy and Keep Active #115

Workflow file for this run

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 docker compose ps
sudo bash ./scripts/install_2fa_bot_watcher.sh
sleep 5
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
echo "Waiting for container to stabilize..."
sleep 30
sudo bash ./scripts/install_2fa_bot_watcher.sh
sleep 5
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}"