diff --git a/.github/actions/deploy-service.yml b/.github/actions/deploy-service.yml new file mode 100644 index 00000000..b581d297 --- /dev/null +++ b/.github/actions/deploy-service.yml @@ -0,0 +1,77 @@ +name: 'Deploy Service' +description: 'Pulls a Docker image and starts a service on a remote EC2 instance.' +inputs: + target_app: + description: 'The target application name (used for container naming).' + required: true + publish_port: + description: 'The port to publish the service on.' + required: true + deploy_tag: + description: 'The Docker image tag to deploy.' + required: true + ec2_host: + description: 'The EC2 host address.' + required: true + secret: true + ec2_user: + description: 'The EC2 username.' + required: true + secret: true + ec2_ssh_key: + description: 'The SSH private key for EC2 access.' + required: true + secret: true + aws_access_key_id: + description: 'AWS Access Key ID.' + required: true + secret: true + aws_secret_access_key: + description: 'AWS Secret Access Key.' + required: true + secret: true + aws_region: + description: 'AWS Region.' + required: true + secret: true + aws_ecr_uri: + description: 'AWS ECR URI.' + required: true + secret: true +runs: + using: 'composite' + steps: + - name: Pull Image and Start Service + uses: appleboy/ssh-action@v1 + with: + host: ${{ inputs.ec2_host }} + username: ${{ inputs.ec2_user }} + key: ${{ inputs.ec2_ssh_key }} + script: | + set -e + export AWS_ACCESS_KEY_ID=${{ inputs.aws_access_key_id }} + export AWS_SECRET_ACCESS_KEY=${{ inputs.aws_secret_access_key }} + + PUBLISH_PORT=${{ inputs.publish_port }} + DEPLOY_TAG=${{ inputs.deploy_tag }} + TARGET_APP=${{ inputs.target_app }} + + echo "Logging into AWS ECR..." + aws ecr get-login-password --region ${{ inputs.aws_region }} | docker login --username AWS --password-stdin ${{ inputs.aws_ecr_uri }} + + echo "Pulling Docker image..." + docker pull ${{ inputs.aws_ecr_uri }}/$TARGET_APP:$DEPLOY_TAG + + echo "Stopping any existing container named $TARGET_APP or using published port $PUBLISH_PORT..." + docker stop $TARGET_APP || true + docker ps --filter "publish=$PUBLISH_PORT" --format "{{.ID}}" | xargs -r docker stop + + echo "Removing any existing container named $TARGET_APP or using published port $PUBLISH_PORT..." + docker rm $TARGET_APP || true + docker ps -a --filter "publish=$PUBLISH_PORT" --format "{{.ID}}" | xargs -r docker rm + + echo "Starting new Docker container..." + docker run -d --name $TARGET_APP -p $PUBLISH_PORT:8080 --restart unless-stopped --env-file .env ${{ inputs.aws_ecr_uri }}/$TARGET_APP:$DEPLOY_TAG + + echo "Pruning unused Docker objects..." + docker system prune -f diff --git a/.github/actions/latest_tag.yml b/.github/actions/latest_tag.yml new file mode 100644 index 00000000..4b7865d2 --- /dev/null +++ b/.github/actions/latest_tag.yml @@ -0,0 +1,31 @@ +name: 'Get Latest Git Tag' +inputs: + target: + description: 'build target tag prefix' + required: true +outputs: + latest_tag: + description: 'The latest tag for target' + value: ${{ steps.get_tag.outputs.latest_tag }} +runs: + using: 'composite' + steps: + - id: get_tag + outputs: + latest_tag: + run: | + TARGET_APP="${{ inputs.target }}" + + # Get latest tag for build target + LATEST_TAG=$(git tag -l "${TARGET_APP}" | sort -V | tail -n 1) + + if [ -z "$LATEST_TAG" ]; then + echo "No existing tags found for ${TARGET_APP}. Initializing with v0.0.0" + CURRENT_TAG="${TARGET_APP}/v0.0.0" + else + echo "Latest tag for ${TARGET_APP}: $LATEST_TAG" + CURRENT_TAG="$LATEST_TAG" + fi + + echo "latest_tag=$CURRENT_TAG" + echo "latest_tag=$CURRENT_TAG" >> $GITHUB_OUTPUT diff --git a/.github/actions/semver.yml b/.github/actions/semver.yml new file mode 100644 index 00000000..75304d93 --- /dev/null +++ b/.github/actions/semver.yml @@ -0,0 +1,48 @@ +name: 'Version Bump' +inputs: + version: + description: 'Semver formatted version' + required: true + bump: + description: 'major, minor, or patch' + required: true +outputs: + next_version: + description: 'The next version' + value: ${{ steps.version_bump.outputs.next_version }} +runs: + using: 'composite' + steps: + - id: version_bump + shell: bash + env: + VERSION: ${{ inputs.version }} + BUMP: ${{ inputs.bump }} + run: | + # Strip all characters that aren't part of the semver section + VERSION=$(echo "$VERSION" | tr -dc '0-9.') + + # Parse in semver + IFS='.' read -r major minor patch <<< "$VERSION" + + case "$BUMP" in + major) + major=$((major + 1)) + minor=0 + patch=0 + ;; + minor) + minor=$((minor + 1)) + patch=0 + ;; + patch) + patch=$((patch + 1)) + ;; + *) + echo "Invalid bump type. Must be major, minor, or patch." + exit 1 + ;; + esac + + NEXT_VERSION="$major.$minor.$patch" + echo "next_version=$NEXT_VERSION" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/deploy-auto.yml b/.github/workflows/deploy-auto.yml index a2130494..7bb55069 100644 --- a/.github/workflows/deploy-auto.yml +++ b/.github/workflows/deploy-auto.yml @@ -8,6 +8,4 @@ on: jobs: trigger-build-and-deploy: uses: ./.github/workflows/deploy-base.yml - with: - dry_run: false secrets: inherit diff --git a/.github/workflows/deploy-base.yml b/.github/workflows/deploy-base.yml index 8d6f9513..eb74cf92 100644 --- a/.github/workflows/deploy-base.yml +++ b/.github/workflows/deploy-base.yml @@ -3,139 +3,222 @@ name: Build & Deploy Base Workflow on: workflow_call: inputs: - dry_run: + target: + description: 'The application target to deploy (e.g., backend, auth).' required: false - type: boolean - default: true + type: string + version: + description: 'The specific version to deploy (e.g., v1.2.3). Defaults to latest if not provided.' + required: false + type: string jobs: - dry-run: - if: ${{ inputs.dry_run }} + validate_inputs: runs-on: ubuntu-latest - steps: - name: Checkout code uses: actions/checkout@v5 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Log in to Amazon ECR - env: - AWS_REGION: ${{ secrets.AWS_REGION }} - AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + - name: Validate Build Target + if: inputs.target != '' run: | - aws ecr get-login-password --region $AWS_REGION | docker login --username AWS --password-stdin ${{ secrets.AWS_ECR_REPOSITORY }} + TARGET_INPUT="${{ inputs.target }}" - - name: Compare Docker image digests - env: - AWS_REGION: ${{ secrets.AWS_REGION }} - AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + if [ ! -d "apps/$TARGET_INPUT" ]; then + echo "Error: The target directory 'apps/$TARGET_INPUT' does not exist." >&2 + exit 1 + fi + echo "target input '$TARGET_INPUT' is valid." + + - name: Validate Version Input Format + if: inputs.version != '' run: | - set -e - IMAGE_URI="${{ secrets.AWS_ECR_REPOSITORY }}:latest" + VERSION_INPUT="${{ inputs.version }}" + if [[ ! "$VERSION_INPUT" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?(\+[a-zA-Z0-9.]+)?$ && "$VERSION_INPUT" != "latest" ]]; then + echo "Error: The 'version' input must follow semantic versioning format (e.g., v1.0.0, v1.0.0-beta.1). Received: $VERSION_INPUT" >&2 + exit 1 + fi + echo "Version input '$VERSION_INPUT' is valid." - echo "Fetching remote image digest..." - REMOTE_DIGEST=$(aws ecr describe-images --repository-name wxyc_backend_service --query 'sort_by(imageDetails,& imagePushedAt)[-1].imageDigest' --output text) + detect-build-targets: + needs: validate_inputs + runs-on: ubuntu-latest + outputs: + targets: ${{ steps.detect_targets.outputs.TARGETS }} + has_targets: ${{ steps.detect_targets.outputs.HAS_TARGETS }} + steps: + - name: Checkout code + uses: actions/checkout@v5 + with: + fetch-depth: 2 - if [ -z "$REMOTE_DIGEST" ]; then - echo "Failed to fetch remote image digest." - exit 1 + - name: Detect Build Target + id: detect_targets + run: | + if [ -n "${{ inputs.target }}" ]; then + echo "Using provided target: ${{ inputs.target }}" + TARGETS="${{ inputs.target }}" + else + echo "Detecting targets from git diff..." + TARGETS=$(git diff --name-only HEAD~1..HEAD | grep '^apps' | cut -d '/' -f 2 | sort -u) fi - echo "Remote Digest: $REMOTE_DIGEST" + if [ -z "$TARGETS" ] ; then + echo "No targets found. Exiting." + echo "TARGETS=[]" >> $GITHUB_OUTPUT + echo "HAS_TARGETS=false" >> $GITHUB_OUTPUT + else + echo "TARGETS=$TARGETS" + echo "TARGETS=$(echo "$TARGETS" | jq -R -s -c 'split("\n") | map(select(length > 0))')" >> $GITHUB_OUTPUT + echo "HAS_TARGETS=true" >> $GITHUB_OUTPUT + fi - echo "Building Docker image locally..." - docker build --platform linux/amd64 -f Dockerfile.backend -t wxyc_backend_service:latest . + handle-git-tags: + needs: detect-build-targets + runs-on: ubuntu-latest + if: needs.detect-build-targets.outputs.has_targets == 'true' + strategy: + matrix: + target: ${{ fromJSON(needs.detect-build-targets.outputs.targets) }} - echo "Fetching local image ID..." - LOCAL_ID=$(docker inspect --format='{{.Id}}' wxyc_backend_service:latest) + outputs: + deploy_version: ${{ steps.determine_deploy_version.outputs.deploy_version }} - if [ -z "$LOCAL_ID" ]; then - echo "Failed to fetch local image ID." - exit 1 - fi + steps: + - name: Get Latest Tag + id: latest_tag + uses: ./.github/actions/latest_tag.yml + with: + target: ${{ matrix.target }} - echo "Local ID: $LOCAL_ID" - echo "Remote Digest: $REMOTE_DIGEST" + - name: Bump Tag Version + id: bump_version + if: inputs.version == '' + uses: ./.github/actions/semver.yml + with: + version: ${{ steps.latest_tag.outputs.latest_tag }} + bump: patch - if [ "$REMOTE_DIGEST" = "sha256:$LOCAL_ID" ]; then - echo "The image to be deployed is the same as the current image in ECR." + - name: Determine Deploy Version + id: determine_deploy_version + run: | + if [ "${{ inputs.version }}" == "latest" ]; then + LATEST_VERSION=$(echo ${{ steps.latest_tag.outputs.latest_tag }} | sed 's/[^0-9.]//g') + echo "deploy_version=v$LATEST_VERSION" >> $GITHUB_OUTPUT + elif [ -n "${{ inputs.version }}" ]; then + echo "deploy_version=${{ inputs.version }}" >> $GITHUB_OUTPUT else - echo "The image to be deployed is different from the current image in ECR." + echo "deploy_version=v${{ steps.bump_version.outputs.next_version }}" >> $GITHUB_OUTPUT fi - - name: Dry run - list Docker images - uses: appleboy/ssh-action@v1 + - name: Apply Tag to Ref + if: inputs.version == '' # Only apply new tag if no specific version was provided + uses: actions/github-script@v5 with: - host: ${{ secrets.EC2_HOST }} - username: ${{ secrets.EC2_USER }} - key: ${{ secrets.EC2_SSH_KEY }} script: | - echo "Listing Docker images..." - docker images || exit 1 - echo "Listing running Docker containers..." - docker ps || exit 1 - - build-and-deploy: - if: ${{ !inputs.dry_run }} + github.rest.git.createRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: 'refs/tags/${{ matrix.target }}/${{ steps.determine_deploy_version.outputs.deploy_version }}', + sha: context.sha + }) + + build: + needs: [handle-git-tags, detect-build-targets] runs-on: ubuntu-latest + if: needs.detect-build-targets.outputs.has_targets == 'true' + strategy: + matrix: + target: ${{ fromJSON(needs.detect-build-targets.outputs.targets) }} steps: - name: Checkout code uses: actions/checkout@v5 + with: + ref: ${{ inputs.version == 'latest' || inputs.version == '' ? github.sha : format('refs/tags/{0}/{1}', matrix.target, inputs.version) }} # Checkout specific version if provided, otherwise current SHA - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Log in to Amazon ECR + id: login-ecr env: AWS_REGION: ${{ secrets.AWS_REGION }} AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} run: | - aws ecr get-login-password --region $AWS_REGION | docker login --username AWS --password-stdin ${{ secrets.AWS_ECR_REPOSITORY }} + aws ecr get-login-password --region $AWS_REGION | docker login --username AWS --password-stdin ${{ secrets.AWS_ECR_URI }} - - name: Build, tag, and push Docker image to Amazon ECR + - name: Check if image exists in ECR + id: check_image run: | - IMAGE_URI="${{ secrets.AWS_ECR_REPOSITORY }}:latest" - echo "Building Docker image..." - docker build --platform linux/amd64 -f Dockerfile.backend -t wxyc_backend_service:latest . - echo "Tagging Docker image..." - docker tag wxyc_backend_service:latest $IMAGE_URI - echo "Pushing Docker image to ECR..." - docker push $IMAGE_URI + TARGET_APP=${{ matrix.target }} + DEPLOY_TAG=${{ needs.handle-git-tags.outputs.deploy_version }} + IMAGE_URI="${{ secrets.AWS_ECR_URI }}/$TARGET_APP:$DEPLOY_TAG" - - name: Execute remote commands via SSH - uses: appleboy/ssh-action@v1 - with: - host: ${{ secrets.EC2_HOST }} - username: ${{ secrets.EC2_USER }} - key: ${{ secrets.EC2_SSH_KEY }} - script: | - set -e - export AWS_ACCESS_KEY_ID=${{ secrets.AWS_ACCESS_KEY_ID }} - export AWS_SECRET_ACCESS_KEY=${{ secrets.AWS_SECRET_ACCESS_KEY }} - export AWS_DEFAULT_REGION=${{ secrets.AWS_REGION }} + if aws ecr describe-images --repository-name $TARGET_APP --image-ids imageTag=$DEPLOY_TAG --region ${{ secrets.AWS_REGION }} > /dev/null 2>&1; then + echo "Image $IMAGE_URI already exists in ECR. Skipping build." + echo "image_exists=true" >> $GITHUB_OUTPUT + else + echo "Image $IMAGE_URI not found in ECR. Proceeding with build." + echo "image_exists=false" >> $GITHUB_OUTPUT + fi + + - name: Build Image + if: steps.check_image.outputs.image_exists == 'false' + run: | + TARGET_APP=${{ matrix.target }} + + docker build --platform linux/amd64 -f Dockerfile.${TARGET_APP} -t ${TARGET_APP}:ci . + + - name: Tag and Push Image + if: steps.check_image.outputs.image_exists == 'false' + run: | + TARGET_APP=${{ matrix.target }} + TAG=${{ needs.handle-git-tags.outputs.deploy_version }} + + IMAGE_URI="${{ secrets.AWS_ECR_URI }}/${TARGET_APP}" + + echo "Tagging Docker image..." + docker tag ${TARGET_APP}:ci $IMAGE_URI:latest + docker tag ${TARGET_APP}:ci $IMAGE_URI:$TAG - echo "Logging into AWS ECR..." - aws ecr get-login-password --region $AWS_DEFAULT_REGION | docker login --username AWS --password-stdin ${{ secrets.AWS_ECR_REPOSITORY }} + echo "Pushing Docker image to ECR..." + docker push $IMAGE_URI:latest + docker push $IMAGE_URI:$TAG - echo "Pulling Docker image..." - docker pull ${{ secrets.AWS_ECR_REPOSITORY }}:latest + deploy: + needs: [detect-build-targets, handle-git-tags, build] + runs-on: ubuntu-latest + if: needs.detect-build-targets.outputs.has_targets == 'true' + strategy: + matrix: + target: ${{ fromJSON(needs.detect-build-targets.outputs.targets) }} + steps: + - name: Checkout code + uses: actions/checkout@v5 - echo "Stopping any existing container using port 8080..." - docker ps --filter "ancestor=${{ secrets.AWS_ECR_REPOSITORY }}:latest" --format "{{.ID}}" | xargs -r docker stop - docker ps --filter "publish=8080" --format "{{.ID}}" | xargs -r docker stop + - name: Get Deploy Vars + id: deploy_vars + run: | + TARGET_APP=${{ matrix.target }} + PUBLISH_PORT=$(yq .publishPort apps/$TARGET_APP/package.json) - echo "Removing any existing container using port 8080..." - docker ps -a --filter "ancestor=${{ secrets.AWS_ECR_REPOSITORY }}:latest" --format "{{.ID}}" | xargs -r docker rm - docker ps -a --filter "publish=8080" --format "{{.ID}}" | xargs -r docker rm + echo "publish_port=$PUBLISH_PORT" >> $GITHUB_OUTPUT - echo "Starting new Docker container..." - docker run -d -p 8080:8080 --restart unless-stopped --env-file .env ${{ secrets.AWS_ECR_REPOSITORY }}:latest + - name: Deploy Service + uses: ./.github/actions/deploy-service.yml + with: + target_app: ${{ matrix.target }} + publish_port: ${{ steps.deploy_vars.outputs.publish_port }} + deploy_tag: ${{ needs.handle-git-tags.outputs.deploy_version }} # Use provided version or the newly bumped version + ec2_host: ${{ secrets.EC2_HOST }} + ec2_user: ${{ secrets.EC2_USER }} + ec2_ssh_key: ${{ secrets.EC2_SSH_KEY }} + aws_access_key_id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws_secret_access_key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws_region: ${{ secrets.AWS_REGION }} + aws_ecr_uri: ${{ secrets.AWS_ECR_URI }} - name: Confirm server is up uses: appleboy/ssh-action@v1 @@ -144,10 +227,11 @@ jobs: username: ${{ secrets.EC2_USER }} key: ${{ secrets.EC2_SSH_KEY }} script: | + PUBLISH_PORT=${{ steps.deploy_vars.outputs.publish_port }} echo "Waiting for server to start..." sleep 30 # Adjust sleep time as needed echo "Checking server status..." - if curl -s --head --request GET http://localhost:8080/flowsheet | grep "200 OK" > /dev/null; then + if curl -s --head --request GET http://localhost:$PUBLISH_PORT/healthcheck | grep "200 OK" > /dev/null; then echo "Server is up and running." else echo "Server is not running. Deployment failed." >&2 diff --git a/.github/workflows/deploy-manual.yml b/.github/workflows/deploy-manual.yml index e4757725..d3f890b7 100644 --- a/.github/workflows/deploy-manual.yml +++ b/.github/workflows/deploy-manual.yml @@ -3,15 +3,19 @@ name: Manual Build & Deploy on: workflow_dispatch: inputs: - dry_run: - description: 'Perform a dry run' + target: + description: 'The application target to deploy (e.g., backend, auth).' + required: true + type: string + version: + description: 'The specific version to deploy (e.g., v1.2.3). Defaults to latest if not provided.' required: false - type: boolean - default: true + type: string jobs: trigger-build-and-deploy: uses: ./.github/workflows/deploy-base.yml with: - dry_run: ${{ inputs.dry_run }} + target: ${{ github.event.inputs.target }} + version: ${{ github.event.inputs.version || 'latest' }} secrets: inherit diff --git a/apps/backend/app.ts b/apps/backend/app.ts index 5cfacab5..89369d03 100644 --- a/apps/backend/app.ts +++ b/apps/backend/app.ts @@ -13,7 +13,6 @@ import { jwtVerifier, cognitoMiddleware } from './middleware/cognito.auth.js'; import { showMemberMiddleware } from './middleware/checkShowMember.js'; import { activeShow } from './middleware/checkActiveShow.js'; import errorHandler from './middleware/errorHandler.js'; -// import errorHandler from './middleware/errorHandler'; const port = process.env.PORT || 8080; const app = express(); @@ -66,6 +65,11 @@ app.get('/testInShow', cognitoMiddleware(), activeShow, showMemberMiddleware, as res.json({ message: 'Authenticated, active show, & show member' }); }); +//endpoint for healthchecks +app.get('/healthcheck', async (req, res) => { + res.json({ message: 'Healthy!' }); +}); + app.use(errorHandler); //On server startup we pre-fetch all jwt validation keys diff --git a/apps/backend/package.json b/apps/backend/package.json index 4fd31a6b..75876e42 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -1,5 +1,6 @@ { "name": "@wxyc/backend", + "publishPort": "8080", "version": "1.0.0", "description": "An API service for the flowsheet database of WXYC", "main": "./dist/app.js",