#!/usr/bin/env bash # Open SWE app deploy — RE-RUNNABLE (first boot + every subsequent release). # # Pulls the current release from S3 (open-swe--assets), builds the venv # natively on the box, and restarts the service. This is the SINGLE source of the # app-deploy procedure; it runs in two places: # # 1. first boot — user-data.sh decodes this script to /opt/open-swe/bin and # calls it ONCE (non-fatal: if no release is published yet, # nginx is already up and the box waits for the first deploy). # 2. every release — the `open-swe--deploy` SSM document (CI fires it after # uploading app.tar.gz / spa.tar.gz) runs this same script. # # It deploys CODE + STATIC ASSETS only. Secrets/config are NOT fetched here: the # systemd unit's ExecStartPre=fetch-config.sh materializes the tmpfs .env on every # (re)start, fail-fast — so `systemctl restart` below is what reloads config too. # # Contract: # app.tar.gz = the Python source tree (pyproject.toml + uv.lock + agent/ + # deploy/ + langgraph.json + README.md, NO ui/, NO .venv). The venv # is built HERE with `uv sync` so it is native ARM64 and lives at # the real runtime path (no cross-built / non-relocatable venv). # spa.tar.gz = the built dashboard SPA (vite output: _shell.html + assets), # extracted to the nginx web root. set -euo pipefail exec > >(tee -a /var/log/open-swe/deploy.log) 2>&1 echo "==> open-swe deploy start $(date -u +%FT%TZ)" # Non-secret pointers written by user-data.sh (env, region, bucket, artifact prefix). # shellcheck disable=SC1091 . /etc/open-swe/boot.env export AWS_DEFAULT_REGION="${AWS_REGION:?boot.env missing AWS_REGION}" : "${ASSETS_BUCKET:?boot.env missing ASSETS_BUCKET}" : "${ARTIFACT_PREFIX:?boot.env missing ARTIFACT_PREFIX}" # Fixed layout — must match provision.sh + user-data.sh + the templates. SERVICE_USER="openswe" APP_DIR="/opt/open-swe/app" WWW_ROOT="/var/www/open-swe" ENV_FILE="/run/open-swe/.env" UV_BIN="/usr/local/bin/uv" UV_PYTHON_INSTALL_DIR="/opt/uv/python" # where provision.sh pre-installed py3.12 SERVICE_HOME="/opt/open-swe" # Benign-vs-failure distinction: on a brand-new env no release is published yet. # Treat "app.tar.gz absent in S3" as a benign no-op (exit 0) so first boot is not a # scary failure; ONCE a release exists, any later step failing is loud (set -e). if ! aws s3 ls "s3://${ASSETS_BUCKET}/${ARTIFACT_PREFIX}/app.tar.gz" >/dev/null 2>&1; then echo "==> no release published at s3://${ASSETS_BUCKET}/${ARTIFACT_PREFIX}/ yet — nothing to deploy" exit 0 fi echo "==> pull release from s3://${ASSETS_BUCKET}/${ARTIFACT_PREFIX}/" tmp="$(mktemp -d)" trap 'rm -rf "$tmp"' EXIT aws s3 cp "s3://${ASSETS_BUCKET}/${ARTIFACT_PREFIX}/app.tar.gz" "${tmp}/app.tar.gz" aws s3 cp "s3://${ASSETS_BUCKET}/${ARTIFACT_PREFIX}/spa.tar.gz" "${tmp}/spa.tar.gz" # Replace app source + SPA atomically-ish: clear the dirs (drops files removed in # this release) then extract. The venv is rebuilt below, so wiping .venv too is # fine — uv's cache (in the service home) makes the rebuild fast. # # Hardening: deploy.sh runs as root, so extract with --no-same-owner # --no-same-permissions — files take root:root + umask perms (NOT the archive's # uid/mode), so a tarball cannot land a setuid/setgid binary or a foreign-owned # file; the chown -R below then hands the tree to the service user. (GNU tar also # refuses `..`-escaping members by default.) Defense-in-depth: the only writer of # this bucket is the CI OIDC app role, but the box never trusts the archive's # ownership/mode regardless. echo "==> install app source -> ${APP_DIR}" install -d -o "$SERVICE_USER" -g "$SERVICE_USER" -m 0755 "$APP_DIR" "$WWW_ROOT" find "$APP_DIR" -mindepth 1 -delete find "$WWW_ROOT" -mindepth 1 -delete tar --no-same-owner --no-same-permissions -xzf "${tmp}/app.tar.gz" -C "$APP_DIR" tar --no-same-owner --no-same-permissions -xzf "${tmp}/spa.tar.gz" -C "$WWW_ROOT" # langgraph reads ./.env from WorkingDirectory; point it at the tmpfs file the # systemd ExecStartPre materializes. ln -sfn "$ENV_FILE" "${APP_DIR}/.env" chown -R "$SERVICE_USER":"$SERVICE_USER" "$APP_DIR" "$WWW_ROOT" echo "==> build venv natively (uv sync --frozen --no-dev)" # Run as the service user so the venv + uv cache are owned by it. Pin the # pre-baked interpreter dir so uv never reaches out to download Python at deploy. cd "$APP_DIR" sudo -u "$SERVICE_USER" env \ HOME="$SERVICE_HOME" \ UV_PYTHON_INSTALL_DIR="$UV_PYTHON_INSTALL_DIR" \ UV_CACHE_DIR="${SERVICE_HOME}/.cache/uv" \ "$UV_BIN" sync --frozen --no-dev echo "==> restart open-swe.service + reload nginx" # ExecStartPre=fetch-config.sh fails-fast if secrets/config are missing, so a # restart here surfaces a bad config as a failed unit (non-zero exit below). systemctl restart open-swe.service nginx -t && systemctl reload nginx if systemctl is-active --quiet open-swe.service; then echo "==> open-swe deploy OK $(date -u +%FT%TZ)" else echo "!! open-swe.service is not active after deploy (check fetch-config/secrets)" exit 1 fi