Skip to content

Commit

Permalink
A Bundle of Docker Deploy Improvements (#676)
Browse files Browse the repository at this point in the history
* feat: build github workflow images with metadata
* feat cors + cookie changes for vercel
* feat: Improve deploy script CLI APIs
* docs: added documentation for deploy scripts + component
  • Loading branch information
AlpacaFur authored Nov 27, 2023
1 parent 00ef0b1 commit 67c3f3a
Show file tree
Hide file tree
Showing 29 changed files with 442 additions and 31 deletions.
6 changes: 3 additions & 3 deletions .github/workflows/aws-cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ jobs:
# Build a docker container and
# push it to ECR so that it can
# be deployed to ECS.
docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG -f infrastructure/prod/Dockerfile.server .
docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG --build-arg=\"COMMIT=$IMAGE_TAG\" --build-arg=\"BUILD_TIMESTAMP=$(date +%s)\" --build-arg=\"COMMIT_MESSAGE=$(git --no-pager show -s --format=%s)\" -f infrastructure/prod/Dockerfile.server .
docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
echo "image=$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" >> $GITHUB_OUTPUT
Expand All @@ -49,15 +49,15 @@ jobs:
# Build a docker container and
# push it to ECR so that it can
# be deployed to ECS.
docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG -f infrastructure/prod/Dockerfile.app .
docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG --build-arg=\"COMMIT=$IMAGE_TAG\" --build-arg=\"BUILD_TIMESTAMP=$(date +%s)\" --build-arg=\"COMMIT_MESSAGE=$(git --no-pager show -s --format=%s)\" -f infrastructure/prod/Dockerfile.app .
docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
echo "image=$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" >> $GITHUB_OUTPUT
- name: Update & Deploy ECS task definitions
run: |
# - create new revision for task definition with latest image.
# - redeploy ECS services with the latest revision.
./infrastructure/prod/redeploy.sh staging
./infrastructure/prod/redeploy.sh staging latest-main both
- name: Logout of Amazon ECR
if: always()
Expand Down
8 changes: 8 additions & 0 deletions infrastructure/prod/Dockerfile.app
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@ COPY packages/frontend-v2 packages/frontend-v2
COPY packages/api-client packages/api-client
COPY packages/common packages/common

ARG COMMIT
ARG BUILD_TIMESTAMP
ARG COMMIT_MESSAGE

ENV NEXT_PUBLIC_COMMIT_HASH $COMMIT
ENV NEXT_PUBLIC_COMMIT_MESSAGE $COMMIT_MESSAGE
ENV NEXT_PUBLIC_BUILD_TIMESTAMP $BUILD_TIMESTAMP

RUN yarn packages/api-client build
RUN yarn packages/common build
RUN yarn packages/frontend-v2 build
Expand Down
7 changes: 7 additions & 0 deletions infrastructure/prod/Dockerfile.server
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,14 @@ RUN yarn packages/api-v2 build
FROM node:16-alpine AS runner
WORKDIR /server

ARG COMMIT
ARG COMMIT_MESSAGE
ARG BUILD_TIMESTAMP

ENV NODE_ENV production
ENV COMMIT_HASH $COMMIT
ENV COMMIT_MESSAGE $COMMIT_MESSAGE
ENV BUILD_TIMESTAMP $BUILD_TIMESTAMP

RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nestjs
Expand Down
36 changes: 36 additions & 0 deletions infrastructure/prod/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# How to do a Manual Deploy (to staging or prod).

Note: All of the `yarn` scripts in this guide are in the root `package.json`.

**This will deploy the current files as they are in your local repo**. They will be tagged with the local HEAD commit.

If you don't want this, you'll need to run modified calls rather than the package.json scripts directly.

## Prereqs:

- Install Docker
- Install the AWS CLI
- Have an AWS Access Key ID and AWS Secret Access Key pair.
- Run `aws configure`, and input those keys along with us-east-1 as the region. You can set the Default Output Format to json.

## Steps:

1. Run `yarn frontend:build` and/or `yarn backend:build` depending on what you want to build.
2. Run `yarn frontend:run` and/or `yarn backend:run` to test that the image runs (the backend won't be able to connect to the database which is expected, and the frontend will not connect to the backend).
3. You may have to run `yarn ecr:docker:auth` to reauthenticate Docker with AWS ECR. (There is no harm in running this command unnecessarily, so if you're unsure you can just run it).
4. If the images run successfully, run `yarn frontend:push` and/or `yarn backend:push` to push the built images to AWS ECR.
5. Run `./infrastructure/prod/redeploy.sh <environment> local-head <service>`

`<environment>` is either `prod` or `staging`.
`<service>` is one of `frontend`, `backend`, or `both`.

# How to Manually Redeploy a Previously Deployed Commit

If you know that a commit already has an image pushed to ECR (ex. the commit is in main, or you already pushed the image using the above steps) and you want to deploy that particular image to staging or prod, you can use the redeploy.sh script.

1. Find the commit's hash (looks like this: 1445f9533c4ec9a7324c721f09fc5ccec1542d8d).
2. Run `./infrastructure/prod/redeploy.sh <environment> <commit_hash> <service>`

`<environment>` is either `prod` or `staging`.
`<commit_hash>` is the hash of the commit you just found.
`<service>` is one of `frontend`, `backend`, or `both`.
1 change: 1 addition & 0 deletions infrastructure/prod/entrypoint.server.sh
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#!/bin/sh

cd packages/api-v2
echo "Running on commit: $COMMIT_HASH"
yarn typeorm migration:run
exec "$@"

34 changes: 34 additions & 0 deletions infrastructure/prod/get-ecr-image-name.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
if [[ $# != 2 ]]; then
echo "Usage: get-ecr-image-name.sh <COMMIT_TAG | latest-main | local-head> <frontend | backend>"
exit 1
fi

# Accept a few different formats for the commit hash.
if [[ ${#1} = 40 ]]; then
ECR_IMAGE_COMMIT_HASH=$1
elif [[ $1 = "latest-main" ]]; then
ECR_IMAGE_COMMIT_HASH=$(git ls-remote https://github.com/sandboxnu/graduatenu.git main | awk '{ print $1 }')
elif [[ $1 = "local-head" ]]; then
ECR_IMAGE_COMMIT_HASH=$(git rev-parse HEAD)
else
echo "ERROR: Invalid commit '$1'"
exit 1
fi

if [[ $2 = "frontend" ]]; then
REPO="graduatenu-node"
elif [[ $2 = "backend" ]]; then
REPO="graduatenu-rails"
else
echo "Please choose a service to create an image name for: 'frontend' or 'backend'"
exit 1
fi

AWS_ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
AWS_DEFAULT_REGION="us-east-1"

ECR_REGISTRY="${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_DEFAULT_REGION}.amazonaws.com"

ECR_IMAGE_NAME="${ECR_REGISTRY}/${REPO}:${ECR_IMAGE_COMMIT_HASH}"

echo $ECR_IMAGE_NAME
46 changes: 38 additions & 8 deletions infrastructure/prod/redeploy.sh
Original file line number Diff line number Diff line change
@@ -1,35 +1,65 @@
#!/bin/bash

if [[ $# != 3 ]]; then
echo "Usage: redeploy.sh <prod | staging> <COMMIT_TAG | latest-main | local-head> <frontend | backend | both>"
exit 1
fi

if [[ ! " prod staging " =~ " $1 " ]]; then
echo "Please provide environment to use: prod or staging"
exit 1
fi

# Accept a few different formats for the commit hash.
if [[ ${#2} = 40 ]]; then
ECR_IMAGE_COMMIT_HASH=$2
elif [[ $2 = "latest-main" ]]; then
ECR_IMAGE_COMMIT_HASH=$(git ls-remote https://github.com/sandboxnu/graduatenu.git main | awk '{ print $1 }')
elif [[ $2 = "local-head" ]]; then
ECR_IMAGE_COMMIT_HASH=$(git rev-parse HEAD)
else
echo "ERROR: Invalid commit '$2'"
exit 1
fi

# Only deploy the service specified.
if [[ $3 = "frontend" ]]; then
DEPLOY_INDEXES=(1)
elif [[ $3 = "backend" ]]; then
DEPLOY_INDEXES=(0)
elif [[ $3 = "both" ]]; then
DEPLOY_INDEXES=(0 1)
else
echo "Please choose a service to deploy: 'frontend', 'backend', or 'both'"
exit 1
fi

echo "Deploying $3 repo(s) to $1 with commit "$2" ($ECR_IMAGE_COMMIT_HASH)"

AWS_ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
AWS_DEFAULT_REGION="us-east-1"
REPOS=( "graduatenu-rails" "graduatenu-node" )
LATEST_HASH=$(git ls-remote https://github.com/sandboxnu/graduatenu.git main | awk '{ print $1 }')
ECS_CLUSTER="$1-graduatenu"
TASK_FAMILIES=( "${ECS_CLUSTER}-api" "${ECS_CLUSTER}-web" )
SERVICES=( "${ECS_CLUSTER}-api" "${ECS_CLUSTER}-web" )


# Disable aws from sending stdout to less
export AWS_PAGER=""

echo "Redeploying services for cluster: ${ECS_CLUSTER} with last pushed image"


for i in "${!REPOS[@]}"; do
for service_index in "${!DEPLOY_INDEXES[@]}"; do
echo "Deploying ${REPOS[DEPLOY_INDEXES[service_index]]}..."
i=${DEPLOY_INDEXES[service_index]}
# Last pushed image should always be tagged with the latest commit hash on main
ECR_IMAGE="${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_DEFAULT_REGION}.amazonaws.com/${REPOS[$i]}:${LATEST_HASH}"
ECR_IMAGE="${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_DEFAULT_REGION}.amazonaws.com/${REPOS[$i]}:${ECR_IMAGE_COMMIT_HASH}"
TASK_FAMILY="${TASK_FAMILIES[$i]}"
SERVICE="${SERVICES[$i]}"
# fetch template for task definition
TASK_DEFINITION=$(aws ecs describe-task-definition --task-definition "$TASK_FAMILY" --region "$AWS_DEFAULT_REGION")
# update the template's image to use the latest ECR_IMAGE
NEW_TASK_DEFINTIION=$(echo $TASK_DEFINITION | jq --arg IMAGE "$ECR_IMAGE" '.taskDefinition | .containerDefinitions[0].image = $IMAGE | del(.taskDefinitionArn) | del(.revision) | del(.status) | del(.requiresAttributes) | del(.compatibilities) | del(.registeredAt) | del(.registeredBy)')
NEW_TASK_DEFINITION=$(echo $TASK_DEFINITION | jq --arg IMAGE "$ECR_IMAGE" '.taskDefinition | .containerDefinitions[0].image = $IMAGE | del(.taskDefinitionArn) | del(.revision) | del(.status) | del(.requiresAttributes) | del(.compatibilities) | del(.registeredAt) | del(.registeredBy)')
# register the new revision for the task definition
NEW_TASK_INFO=$(aws ecs register-task-definition --region "$AWS_DEFAULT_REGION" --cli-input-json "$NEW_TASK_DEFINTIION")
NEW_TASK_INFO=$(aws ecs register-task-definition --region "$AWS_DEFAULT_REGION" --cli-input-json "$NEW_TASK_DEFINITION")
NEW_REVISION=$(echo $NEW_TASK_INFO | jq '.taskDefinition.revision')
# update the service to replace tasks with the latest revision using the latest image
aws ecs update-service --cluster ${ECS_CLUSTER} \
Expand Down
7 changes: 7 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,13 @@
"backend:docker:build": "docker compose -f infrastructure/develop/docker-compose.api.yml build",
"backend:docker:run": "docker compose -f infrastructure/develop/docker-compose.api.yml up -d",
"backend:docker:down": "docker compose -f infrastructure/develop/docker-compose.api.yml down",
"frontend:build": "docker build --platform linux/amd64 --build-arg=\"COMMIT=$(git rev-parse HEAD)\" --build-arg=\"BUILD_TIMESTAMP=$(date +%s)\" --build-arg=\"COMMIT_MESSAGE=$(git --no-pager show -s --format=%s)\" -t $(sh ./infrastructure/prod/get-ecr-image-name.sh local-head frontend) -f ./infrastructure/prod/Dockerfile.app .",
"frontend:run": "docker run -p 4000:3000 $(sh ./infrastructure/prod/get-ecr-image-name.sh local-head frontend)",
"frontend:push": "docker push $(sh ./infrastructure/prod/get-ecr-image-name.sh local-head frontend)",
"backend:build": "docker build --platform linux/amd64 --build-arg=\"COMMIT=$(git rev-parse HEAD)\" --build-arg=\"BUILD_TIMESTAMP=$(date +%s)\" --build-arg=\"COMMIT_MESSAGE=$(git --no-pager show -s --format=%s)\" -t $(sh ./infrastructure/prod/get-ecr-image-name.sh local-head backend) -f ./infrastructure/prod/Dockerfile.server .",
"backend:run": "docker run -p 4001:3001 $(sh ./infrastructure/prod/get-ecr-image-name.sh local-head backend)",
"backend:push": "docker push $(sh ./infrastructure/prod/get-ecr-image-name.sh local-head backend)",
"ecr:docker:auth": "aws ecr get-login-password --region us-east-1 | docker login --username AWS --password-stdin $(aws sts get-caller-identity --query Account --output text).dkr.ecr.us-east-1.amazonaws.com",
"lint": "eslint packages/ --ext .ts,.tsx .",
"tsc": "yarn workspaces foreach -v --exclude . run tsc",
"g:babel": "cd $INIT_CWD && babel",
Expand Down
6 changes: 6 additions & 0 deletions packages/api-client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
ResetPasswordDto,
courseToString,
NUPathEnum,
GetMetaInfoResponse,
} from "@graduate/common";
import { ClassConstructor, plainToInstance } from "class-transformer";

Expand Down Expand Up @@ -103,6 +104,11 @@ class APIClient {
getSupportedMajors: (): Promise<GetSupportedMajorsResponse> =>
this.req("GET", `/majors/supportedMajors`, GetSupportedMajorsResponse),
};

meta = {
getInfo: (): Promise<GetMetaInfoResponse> =>
this.req("GET", "/meta/info", GetMetaInfoResponse),
};
}

/**
Expand Down
2 changes: 2 additions & 0 deletions packages/api-v2/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { LoggingInterceptor } from "./interceptors/logging.interceptor";
import { MajorModule } from "./major/major.module";
import { EmailModule } from "./email/email.module";
import { ThrottlerGuard, ThrottlerModule } from "@nestjs/throttler";
import { MetaModule } from "./meta/meta.module";

@Module({
imports: [
Expand All @@ -29,6 +30,7 @@ import { ThrottlerGuard, ThrottlerModule } from "@nestjs/throttler";
PlanModule,
MajorModule,
EmailModule,
MetaModule,
],
providers: [
{
Expand Down
11 changes: 9 additions & 2 deletions packages/api-v2/src/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,16 @@ import {
weakPasswordError,
} from "@graduate/common";
import { Response } from "express";
import EmailConfirmationService from "src/emailConfirmation/emailConfirmation.service";
import EmailConfirmationService from "../../src/emailConfirmation/emailConfirmation.service";
import {
EmailAlreadyExists,
EmailNotConfirmed,
NoSuchEmail,
WeakPassword,
} from "src/student/student.errors";
} from "../../src/student/student.errors";
import { BadToken, InvalidPayload, TokenExpiredError } from "./auth.errors";
import { Throttle } from "@nestjs/throttler";
import { COOKIE_DOMAIN } from "../../src/constants";

@Controller("auth")
export class AuthController {
Expand Down Expand Up @@ -61,11 +62,13 @@ export class AuthController {
const { accessToken } = student;

const isSecure = process.env.NODE_ENV !== "development";

// Store JWT token in a cookie
response.cookie("auth_cookie", accessToken, {
httpOnly: true,
sameSite: "strict",
secure: isSecure,
domain: COOKIE_DOMAIN,
});
if (process.env.NODE_ENV !== "testing") {
await this.emailConfirmationService.sendVerificationLink(
Expand All @@ -90,11 +93,13 @@ export class AuthController {
const { accessToken } = student;

const isSecure = process.env.NODE_ENV !== "development";

// Store JWT token in a cookie
response.cookie("auth_cookie", accessToken, {
httpOnly: true,
sameSite: "strict",
secure: isSecure,
domain: COOKIE_DOMAIN,
});

return student;
Expand Down Expand Up @@ -152,10 +157,12 @@ export class AuthController {
@Res({ passthrough: true }) response: Response
): Promise<void> {
const isSecure = process.env.NODE_ENV !== "development";

response.clearCookie("auth_cookie", {
httpOnly: true,
sameSite: "strict",
secure: isSecure,
domain: COOKIE_DOMAIN,
});
}
}
2 changes: 1 addition & 1 deletion packages/api-v2/src/auth/auth.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { StudentModule } from "../student/student.module";
import { AuthController } from "./auth.controller";
import { AuthService } from "./auth.service";
import { JwtStrategy } from "./jwt.strategy";
import { EmailModule } from "src/email/email.module";
import { EmailModule } from "../../src/email/email.module";

@Module({
imports: [
Expand Down
6 changes: 3 additions & 3 deletions packages/api-v2/src/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,10 @@ import {
EmailNotConfirmed,
NoSuchEmail,
WeakPassword,
} from "src/student/student.errors";
} from "../../src/student/student.errors";
import { ConfigService } from "@nestjs/config";
import { EnvironmentVariables } from "src/environment-variables";
import EmailService from "src/email/email.service";
import { EnvironmentVariables } from "../../src/environment-variables";
import EmailService from "../../src/email/email.service";
import { BadToken, InvalidPayload, TokenExpiredError } from "./auth.errors";

@Injectable()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Student } from "src/student/entities/student.entity";
import { Student } from "../../../src/student/entities/student.entity";

/** Represents an authenticated request using the JwtAuthGuard. */
export interface AuthenticatedRequest extends Request {
Expand Down
9 changes: 9 additions & 0 deletions packages/api-v2/src/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/**
* The root Domain on which all cookies should be set. (See:
* https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#define_where_cookies_are_sent)
*
* In production, this should be set to "graduatenu.com" which allows
* api.graduatenu.com to set cookies on every other *.graduatenu.com domain.
*/
export const COOKIE_DOMAIN =
process.env.NODE_ENV === "production" ? "graduatenu.com" : "localhost";
4 changes: 2 additions & 2 deletions packages/api-v2/src/email/email.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import { ConfigModule } from "@nestjs/config";
import EmailService from "./email.service";
import { JwtModule } from "@nestjs/jwt";
import EmailConfirmationService from "../emailConfirmation/emailConfirmation.service";
import { StudentModule } from "src/student/student.module";
import { EmailConfirmationController } from "src/emailConfirmation/emailConfirmation.controller";
import { StudentModule } from "../../src/student/student.module";
import { EmailConfirmationController } from "../../src/emailConfirmation/emailConfirmation.controller";

@Module({
imports: [
Expand Down
2 changes: 1 addition & 1 deletion packages/api-v2/src/email/email.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { createTransport } from "nodemailer";
import Mail from "nodemailer/lib/mailer";
import { EnvironmentVariables } from "src/environment-variables";
import { EnvironmentVariables } from "../../src/environment-variables";

@Injectable()
export default class EmailService {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ import {
emailAlreadyConfirmed,
unableToSendEmail,
} from "@graduate/common";
import { AuthenticatedRequest } from "src/auth/interfaces/authenticated-request";
import { JwtAuthGuard } from "src/guards/jwt-auth.guard";
import { AuthenticatedRequest } from "../../src/auth/interfaces/authenticated-request";
import { JwtAuthGuard } from "../../src/guards/jwt-auth.guard";
import EmailConfirmationService from "./emailConfirmation.service";
import {
EmailAlreadyConfirmed,
Expand Down
Loading

0 comments on commit 67c3f3a

Please sign in to comment.