From af11270b466540553de4a9614b80f12dc5eb27ed Mon Sep 17 00:00:00 2001 From: Weston Clark Date: Sat, 24 Oct 2026 12:17:21 -0400 Subject: [PATCH 1/2] chore: point web-client at its merged main Picks up web-client #11 (verify a stored session before showing the app) and #10 (favicon asset). Co-Authored-By: Claude Opus 5.5 --- web-client | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web-client b/web-client index 4da6c21..1f5835b 160000 --- a/web-client +++ b/web-client @@ -1 +1 @@ -Subproject commit 4da6c21c0f934093b06b0bdfbb1748304f27eb98 +Subproject commit 1f5835b1c6fcc0dae8d7cfc04cdf078a57d22e52 From c9c6fc457695ef2ba34a24d01f1f2556dffc8e0b Mon Sep 17 00:00:00 2001 From: Weston Clark Date: Sat, 24 Oct 2026 12:17:22 -0400 Subject: [PATCH 2/2] feat: deploy test/* branches to a dev slot on EC2 Merging a PR into a test/* branch now runs the tests and deploys that branch's pinned web-client and cloud-services to a separate dev slot: nginx on :4174 serving /var/www/hyperloop-gui-dev and proxying a backend-dev service on 127.0.0.1:8081. Prod (:4173, backend) is untouched. - ci.yml: run on PRs into test/**; gate the prod deploy on base main and add a deploy-dev job for test/* bases - deploy-dev.yml: reusable + manual (workflow_dispatch) dev deploy, serialized with a concurrency group - deploy-dev-to-ec2 action: builds web-client on the runner, ships cloud-services source, keeps the dev venv and .env.dev (seeded from prod's env on first deploy), and waits for /health - nginx and systemd units for the dev slot; backend-dev is capped at 300M so it cannot starve prod Co-Authored-By: Claude Opus 5.5 --- .github/actions/deploy-dev-to-ec2/action.yml | 182 +++++++++++++++++++ .github/workflows/ci.yml | 13 +- .github/workflows/deploy-dev.yml | 50 +++++ deploy/nginx/hyperloop-gui-dev.conf | 53 ++++++ deploy/systemd/backend-dev.service | 18 ++ 5 files changed, 314 insertions(+), 2 deletions(-) create mode 100644 .github/actions/deploy-dev-to-ec2/action.yml create mode 100644 .github/workflows/deploy-dev.yml create mode 100644 deploy/nginx/hyperloop-gui-dev.conf create mode 100644 deploy/systemd/backend-dev.service diff --git a/.github/actions/deploy-dev-to-ec2/action.yml b/.github/actions/deploy-dev-to-ec2/action.yml new file mode 100644 index 0000000..f23f2b8 --- /dev/null +++ b/.github/actions/deploy-dev-to-ec2/action.yml @@ -0,0 +1,182 @@ +name: 'Deploy dev slot to EC2' +description: > + Deploy the web-client and cloud-services commits pinned by the checked-out branch to the dev + slot on EC2 (nginx :4174 -> backend-dev :8081). Expects the caller to have checked out the + branch with submodules. Prod (port 4173, backend service) is not touched. + +inputs: + ec2_pem_key: + description: 'EC2 PEM key content for SSH authentication' + required: true + ec2_host: + description: 'EC2 host address' + required: true + ec2_user: + description: 'EC2 SSH user' + required: true + branch: + description: 'Branch being deployed (recorded in deploy-info.txt)' + required: true + dev_dir: + description: 'Directory on EC2 that holds the dev cloud-services copy' + required: false + default: '/home/ubuntu/dev' + prod_cloud_services_dir: + description: 'Prod cloud-services directory on EC2; its env file seeds the dev env file on first deploy' + required: false + default: '/home/ubuntu/cloud-services' + web_api_base_url: + description: 'API URL baked into the dev web-client build; empty means the page origin, which nginx proxies to backend-dev' + required: false + default: '' + +runs: + using: 'composite' + steps: + # Builds exactly the web-client commit this branch pins, on the runner (EC2 is too small for vite build) + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: 'web-client/package-lock.json' + + - name: Build web-client and package cloud-services + shell: bash + env: + WEB_API_BASE_URL: ${{ inputs.web_api_base_url }} + BRANCH: ${{ inputs.branch }} + run: | + set -euo pipefail + cd "$GITHUB_WORKSPACE/web-client" + # Set even when empty so it overrides web-client/.env.production (which points at prod) + export VITE_API_BASE_URL="$WEB_API_BASE_URL" + npm ci + npm run build + + cd "$GITHUB_WORKSPACE" + { + echo "branch: $BRANCH" + echo "gui commit: $(git rev-parse HEAD)" + echo "web-client: $(git -C web-client rev-parse HEAD)" + echo "cloud-services: $(git -C cloud-services rev-parse HEAD)" + echo "deployed at: $(date -u +%Y-%m-%dT%H:%M:%SZ)" + echo "run: $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" + } > web-client/dist/deploy-info.txt + cat web-client/dist/deploy-info.txt + + tar -czf "$RUNNER_TEMP/web-client-dev.tgz" -C web-client/dist . + tar -czf "$RUNNER_TEMP/cloud-services-dev.tgz" -C cloud-services \ + --exclude=.git --exclude=venv --exclude=.venv --exclude=__pycache__ --exclude='.env*' . + + - name: Configure SSH + shell: bash + run: | + mkdir -p ~/.ssh + echo "${{ inputs.ec2_pem_key }}" > ~/.ssh/ec2_deploy.pem + chmod 600 ~/.ssh/ec2_deploy.pem + cat >> ~/.ssh/config << EOF + Host ec2-deploy + HostName ${{ inputs.ec2_host }} + User ${{ inputs.ec2_user }} + IdentityFile ~/.ssh/ec2_deploy.pem + StrictHostKeyChecking no + EOF + + - name: Upload build, source and configs + shell: bash + run: | + scp "$RUNNER_TEMP/web-client-dev.tgz" "$RUNNER_TEMP/cloud-services-dev.tgz" \ + "$GITHUB_WORKSPACE/deploy/nginx/hyperloop-gui-dev.conf" \ + "$GITHUB_WORKSPACE/deploy/systemd/backend-dev.service" \ + ec2-deploy:/tmp/ + + - name: Deploy dev slot + shell: bash + run: | + ssh ec2-deploy << 'DEPLOY_EOF' + set -euo pipefail + + DEV_DIR="${{ inputs.dev_dir }}" + PROD_APP="${{ inputs.prod_cloud_services_dir }}" + APP="$DEV_DIR/cloud-services" + WEB_ROOT=/var/www/hyperloop-gui-dev + + # --- Web client (nginx :4174) --- + echo "Deploying dev web-client..." + if ! command -v nginx >/dev/null; then + sudo apt-get update -q + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -q nginx + sudo rm -f /etc/nginx/sites-enabled/default + fi + sudo rm -rf "$WEB_ROOT.new" "$WEB_ROOT.old" + sudo mkdir -p "$WEB_ROOT.new" + sudo tar -xzf /tmp/web-client-dev.tgz --no-same-owner -C "$WEB_ROOT.new" + if [ -d "$WEB_ROOT" ]; then sudo mv "$WEB_ROOT" "$WEB_ROOT.old"; fi + sudo mv "$WEB_ROOT.new" "$WEB_ROOT" + sudo rm -rf "$WEB_ROOT.old" + rm -f /tmp/web-client-dev.tgz + + sudo install -m 644 /tmp/hyperloop-gui-dev.conf /etc/nginx/sites-available/hyperloop-gui-dev + sudo ln -sfn /etc/nginx/sites-available/hyperloop-gui-dev /etc/nginx/sites-enabled/hyperloop-gui-dev + rm -f /tmp/hyperloop-gui-dev.conf + sudo nginx -t + sudo systemctl reload-or-restart nginx + + # --- Cloud services (backend-dev :8081) --- + echo "Deploying dev cloud-services..." + mkdir -p "$APP" + # Replace the source but keep the venv and the dev env file + find "$APP" -mindepth 1 -maxdepth 1 ! -name venv ! -name .env.dev -exec rm -rf {} + + tar -xzf /tmp/cloud-services-dev.tgz -C "$APP" + rm -f /tmp/cloud-services-dev.tgz + + # First deploy only: start from prod's settings so the same logins and hub tokens work. + # Edit $APP/.env.dev afterwards to diverge (it is never overwritten). + if [ ! -f "$APP/.env.dev" ]; then + SEED="" + for f in "$PROD_APP/.env.production" "$PROD_APP/.env"; do + if [ -f "$f" ]; then SEED="$f"; break; fi + done + if [ -n "$SEED" ]; then + echo "Seeding .env.dev from $SEED" + cp "$SEED" "$APP/.env.dev" + else + echo "No prod env file found; dev runs with development defaults" + : > "$APP/.env.dev" + fi + chmod 600 "$APP/.env.dev" + fi + + cd "$APP" + if [ ! -x venv/bin/python ]; then + echo "Creating virtual environment..." + python3 -m venv venv + fi + # Reinstall only when requirements.txt changes + REQ_HASH="$(sha256sum requirements.txt | cut -d' ' -f1)" + if [ "$(cat venv/.requirements.sha256 2>/dev/null || true)" != "$REQ_HASH" ]; then + venv/bin/python -m pip install -q --upgrade pip setuptools wheel + venv/bin/pip install -q -r requirements.txt + echo "$REQ_HASH" > venv/.requirements.sha256 + fi + + sed -e "s|@APP_DIR@|$APP|g" -e "s|@USER@|$(id -un)|g" /tmp/backend-dev.service \ + | sudo tee /etc/systemd/system/backend-dev.service > /dev/null + rm -f /tmp/backend-dev.service + sudo systemctl daemon-reload + sudo systemctl enable backend-dev + sudo systemctl restart backend-dev + + echo "Waiting for backend-dev..." + for i in $(seq 1 20); do + if curl -fsS http://127.0.0.1:8081/health > /dev/null; then + echo "✅ Dev deployment completed: backend-dev healthy" + exit 0 + fi + sleep 1 + done + echo "❌ backend-dev did not become healthy" + sudo journalctl -u backend-dev -n 50 --no-pager + exit 1 + DEPLOY_EOF diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dee77cb..349b5c0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI/CD Pipeline on: pull_request: - branches: [ "main" ] + branches: [ "main", "test/**" ] types: [opened, synchronize, reopened, closed] jobs: @@ -34,10 +34,19 @@ jobs: # Deploy when PR is merged to main deploy: - if: github.event.pull_request.merged == true && github.event.action == 'closed' + if: github.event.pull_request.merged == true && github.event.action == 'closed' && github.base_ref == 'main' needs: [test-api, test-web] uses: ./.github/workflows/deploy.yml with: branch: 'main' repo_base_dir: '/home/ubuntu' secrets: inherit + + # Deploy to the dev slot (port 4174) when a PR is merged into a test/* branch + deploy-dev: + if: github.event.pull_request.merged == true && github.event.action == 'closed' && startsWith(github.base_ref, 'test/') + needs: [test-api, test-web] + uses: ./.github/workflows/deploy-dev.yml + with: + branch: ${{ github.base_ref }} + secrets: inherit diff --git a/.github/workflows/deploy-dev.yml b/.github/workflows/deploy-dev.yml new file mode 100644 index 0000000..9fec159 --- /dev/null +++ b/.github/workflows/deploy-dev.yml @@ -0,0 +1,50 @@ +name: Deploy dev slot to EC2 + +on: + # Called by ci.yml when a PR is merged into a test/* branch + workflow_call: + inputs: + branch: + description: 'Branch to deploy to the dev slot' + required: true + type: string + secrets: + EC2_PEM_KEY: + required: true + EC2_HOST: + required: true + EC2_USER: + required: true + # Manual redeploy from the Actions tab (any branch, e.g. a test/* branch you pushed to directly) + workflow_dispatch: + inputs: + branch: + description: 'Branch to deploy to the dev slot (e.g. test/my-feature)' + required: true + type: string + +jobs: + deploy: + runs-on: ubuntu-latest + name: Deploy dev slot + # One dev slot: let deploys finish in order instead of racing on the server + concurrency: + group: deploy-dev + cancel-in-progress: false + + steps: + - name: Checkout branch with pinned submodules + uses: actions/checkout@v5 + with: + ref: ${{ inputs.branch }} + submodules: recursive + token: ${{ github.token }} + + - name: Deploy dev slot + uses: ./.github/actions/deploy-dev-to-ec2 + with: + ec2_pem_key: ${{ secrets.EC2_PEM_KEY }} + ec2_host: ${{ secrets.EC2_HOST }} + ec2_user: ${{ secrets.EC2_USER }} + branch: ${{ inputs.branch }} + web_api_base_url: ${{ vars.DEV_WEB_API_BASE_URL }} diff --git a/deploy/nginx/hyperloop-gui-dev.conf b/deploy/nginx/hyperloop-gui-dev.conf new file mode 100644 index 0000000..73bb709 --- /dev/null +++ b/deploy/nginx/hyperloop-gui-dev.conf @@ -0,0 +1,53 @@ +# Dev slot: serves the web-client built from the latest merged test/* branch and proxies +# the dev cloud-services instance (backend-dev, 127.0.0.1:8081) on the same origin. +# Mirrors hyperloop-gui.conf; only the port, web root and upstream differ. +# The deploy-dev-to-ec2 action installs this as /etc/nginx/sites-available/hyperloop-gui-dev. + +map $http_upgrade $hyperloop_dev_connection_upgrade { + default upgrade; + '' close; +} + +server { + listen 4174; + server_name _; + + root /var/www/hyperloop-gui-dev; + index index.html; + + gzip on; + gzip_types text/css application/javascript application/json image/svg+xml; + + client_max_body_size 25m; + + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $hyperloop_dev_connection_upgrade; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 1h; + proxy_send_timeout 1h; + + location /auth/ { proxy_pass http://127.0.0.1:8081; } + location /api/ { proxy_pass http://127.0.0.1:8081; } + location /ws/ { proxy_pass http://127.0.0.1:8081; } + location = /hub { proxy_pass http://127.0.0.1:8081; } + location = /health { proxy_pass http://127.0.0.1:8081; } + + # Which branch and commits are deployed here + location = /deploy-info.txt { + add_header Cache-Control "no-cache"; + default_type text/plain; + } + + location /assets/ { + add_header Cache-Control "public, max-age=31536000, immutable"; + try_files $uri =404; + } + + location / { + add_header Cache-Control "no-cache"; + try_files $uri $uri/ /index.html; + } +} diff --git a/deploy/systemd/backend-dev.service b/deploy/systemd/backend-dev.service new file mode 100644 index 0000000..cc1c115 --- /dev/null +++ b/deploy/systemd/backend-dev.service @@ -0,0 +1,18 @@ +# Dev cloud-services instance for test/* branches. Installed by the deploy-dev-to-ec2 action, +# which replaces @APP_DIR@ and @USER@. Kept on localhost; nginx (hyperloop-gui-dev) proxies it. +[Unit] +Description=Hyperloop cloud-services (dev slot, test/* branches) +After=network.target + +[Service] +User=@USER@ +WorkingDirectory=@APP_DIR@ +Environment=ENV_FILE=.env.dev +ExecStart=@APP_DIR@/venv/bin/uvicorn src.main:app --host 127.0.0.1 --port 8081 +Restart=on-failure +RestartSec=3 +# The instance is small; keep a misbehaving dev build from starving prod +MemoryMax=300M + +[Install] +WantedBy=multi-user.target