Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.template
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
WEB_APP_URL=
# MICROSERVICES
AUTH_GRPC_URL=
# SUPABASE
SUPABASE_URL=
SUPABASE_ANON_KEY=
Expand Down
117 changes: 87 additions & 30 deletions .github/workflows/build-and-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,57 +5,114 @@ on:
branches: ['main']

jobs:
build-deploy:
# =================================================================================
# JOB 1: Detect changed services and prepare variables
# =================================================================================
detect-changes:
runs-on: ubuntu-latest
outputs:
services_to_build: ${{ steps.filter.outputs.changes }}
version: ${{ steps.prep.outputs.VERSION }}
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 2

- name: Prepare version tag
id: prep
run: |
VERSION=$(date +%Y%m%d%H%M%S)-${GITHUB_SHA::7}
echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT

- name: Detect changed services
id: filter
uses: dorny/paths-filter@v3
with:
list-files: none
filters: |
all: &all
- 'libs/shared/**'
- '.github/workflows/**'
- 'package.json'
- 'pnpm-lock.yaml'
gateway:
- 'apps/gateway/**'
- *all
auth:
- 'apps/auth/**'
- *all

# =================================================================================
# JOB 2: Build and push Docker images (in parallel via matrix)
# =================================================================================
build-and-push:
needs: detect-changes
if: needs.detect-changes.outputs.services_to_build != '[]'
runs-on: ubuntu-latest
permissions:
contents: write
packages: write

strategy:
fail-fast: false
matrix:
service: ${{ fromJson(needs.detect-changes.outputs.services_to_build) }}
steps:
- uses: actions/checkout@v4
- name: Checkout code
uses: actions/checkout@v4

- uses: docker/login-action@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Build image
- name: Build and Push Docker image for ${{ matrix.service }}
run: |
VERSION=$(date +%Y%m%d%H%M%S)
echo "VERSION=$VERSION" >> $GITHUB_ENV
IMAGE_NAME="api-${{ matrix.service }}"
docker build \
-t ghcr.io/${{ github.repository_owner }}/api:$VERSION .
docker tag ghcr.io/${{ github.repository_owner }}/api:$VERSION ghcr.io/${{ github.repository_owner }}/api:latest

- name: Push image
run: |
docker push ghcr.io/${{ github.repository_owner }}/api:$VERSION
docker push ghcr.io/${{ github.repository_owner }}/api:latest
-f apps/${{ matrix.service }}/Dockerfile \
-t ghcr.io/${{ github.repository_owner }}/${IMAGE_NAME}:${{ needs.detect-changes.outputs.version }} \
-t ghcr.io/${{ github.repository_owner }}/${IMAGE_NAME}:latest .
docker push ghcr.io/${{ github.repository_owner }}/${IMAGE_NAME} --all-tags

# =================================================================================
# JOB 3: Update infrastructure repository (runs once)
# =================================================================================
update-infra:
needs: [detect-changes, build-and-push]
if: needs.detect-changes.outputs.services_to_build != '[]'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Clone infra repo
run: |
git clone https://ci-bot:${{ secrets.PAT_TOKEN }}@github.com/${{ github.repository_owner }}/infra.git

- name: Update api deployment image tag
- name: Update deployment image tags
run: |
cd infra/apps/services/api
sed -i "s|image: ghcr.io.*/api.*|image: ghcr.io/${{ github.repository_owner }}/api:$VERSION|" deployment.yaml
cd infra
SERVICES_JSON='${{ needs.detect-changes.outputs.services_to_build }}'
VERSION='${{ needs.detect-changes.outputs.version }}'

for service in $(echo $SERVICES_JSON | jq -r '.[]'); do
IMAGE_NAME="api-$service"
DEPLOYMENT_PATH="apps/services/api/$service/deployment.yaml"
echo "Updating deployment for $service in $DEPLOYMENT_PATH"

sed -i "s|image: ghcr.io.*/${IMAGE_NAME}:.*|image: ghcr.io/${{ github.repository_owner }}/${IMAGE_NAME}:${VERSION}|" "$DEPLOYMENT_PATH"
done

- name: Commit manifest change
- name: Commit and Push Manifest Changes
run: |
cd infra
git config user.name "ci-bot"
git config user.email "ci-bot@github.com"

git remote set-url origin https://ci-bot:${{ secrets.PAT_TOKEN }}@github.com/${{ github.repository_owner }}/infra.git

git config user.email "ci-bot@users.noreply.github.com"
git add .
git commit -m "deploy: api $VERSION" || echo "No changes to commit"
git push origin main
env:
GIT_AUTHOR_NAME: ci-bot
GIT_AUTHOR_EMAIL: ci-bot@github.com
GIT_COMMITTER_NAME: ci-bot
GIT_COMMITTER_EMAIL: ci-bot@github.com
if git diff --staged --quiet; then
echo "No changes to commit."
else
git commit -m "deploy(api): update image versions to ${{ needs.detect-changes.outputs.version }}"
git push
fi
13 changes: 8 additions & 5 deletions Dockerfile → apps/auth/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,23 +1,26 @@
# 1. Build Stage
# 1. Builder stage
FROM node:22-slim AS builder
RUN apt-get update && apt-get install -y protobuf-compiler && rm -rf /var/lib/apt/lists/*
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
WORKDIR /usr/src/app
COPY package.json pnpm-lock.yaml ./
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
COPY . .
RUN pnpm run build
RUN pnpm run build auth

# 2. Production Stage
# 2. Production stage
FROM node:22-slim AS production
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
WORKDIR /usr/src/app
COPY package.json pnpm-lock.yaml ./
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --prod --frozen-lockfile

COPY libs/shared/src/protos ./libs/shared/src/protos
COPY --from=builder /usr/src/app/dist ./dist

USER node
EXPOSE 3000
CMD ["node", "dist/main.js"]
CMD ["node", "dist/apps/auth/src/main.js"]
66 changes: 66 additions & 0 deletions apps/auth/src/auth.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { ValidateTokenResponse } from '@app/shared/protos/__generated__';

describe('AuthController', () => {
let authController: AuthController;
let authService: AuthService;

const mockAuthService = {
validateToken: jest.fn(),
};

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [AuthController],
providers: [
{
provide: AuthService,
useValue: mockAuthService,
},
],
}).compile();

authController = module.get<AuthController>(AuthController);
authService = module.get<AuthService>(AuthService);
});

it('should be defined', () => {
expect(authController).toBeDefined();
});

describe('validateToken', () => {
it('should return a user payload for a valid token', async () => {
const token = 'valid-token';
const expectedResponse: ValidateTokenResponse = {
user: {
id: 'user-id',
email: 'test@example.com',
exp: 1234567890,
},
};

mockAuthService.validateToken.mockResolvedValue(expectedResponse);

const result = await authController.validateToken({ token });

expect(result).toEqual(expectedResponse);
expect(authService.validateToken).toHaveBeenCalledWith(token);
});

it('should return an error for an invalid token', async () => {
const token = 'invalid-token';
const expectedResponse: ValidateTokenResponse = {
error: 'Invalid token',
};

mockAuthService.validateToken.mockResolvedValue(expectedResponse);

const result = await authController.validateToken({ token });

expect(result).toEqual(expectedResponse);
expect(authService.validateToken).toHaveBeenCalledWith(token);
});
});
});
24 changes: 24 additions & 0 deletions apps/auth/src/auth.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { Controller } from '@nestjs/common';
import { AuthService } from './auth.service';
import {
AuthServiceController,
AuthServiceControllerMethods,
ValidateTokenRequest,
ValidateTokenResponse,
} from '@app/shared/protos/__generated__';
import { Observable } from 'rxjs';

@Controller()
@AuthServiceControllerMethods()
export class AuthController implements AuthServiceController {
constructor(private readonly authService: AuthService) {}

validateToken(
request: ValidateTokenRequest,
):
| ValidateTokenResponse
| Promise<ValidateTokenResponse>
| Observable<ValidateTokenResponse> {
return this.authService.validateToken(request.token);
}
}
17 changes: 17 additions & 0 deletions apps/auth/src/auth.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtModule } from '@nestjs/jwt';
import { SharedModule } from '@app/shared';

@Module({
imports: [
SharedModule,
JwtModule.register({
secret: process.env.SUPABASE_JWT_SECRET,
}),
],
controllers: [AuthController],
providers: [AuthService],
})
export class AuthModule {}
26 changes: 26 additions & 0 deletions apps/auth/src/auth.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ValidateTokenResponse } from '@app/shared/protos/__generated__';
import { JwtPayload } from '@supabase/supabase-js';

@Injectable()
export class AuthService {
constructor(private readonly jwtService: JwtService) {}

validateToken = async (token: string): Promise<ValidateTokenResponse> => {
try {
const payload = await this.jwtService.verifyAsync<JwtPayload>(token);
return {
user: {
id: payload.sub,
email: payload.email,
exp: payload.exp,
},
};
} catch {
return {
error: 'Invalid token',
};
}
};
}
22 changes: 22 additions & 0 deletions apps/auth/src/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { NestFactory } from '@nestjs/core';

import { SharedService } from '@app/shared';

import { AuthModule } from './auth.module';
import { AUTH_PACKAGE_NAME } from '@app/shared/protos/__generated__';

async function bootstrap() {
const app = await NestFactory.create(AuthModule);

const sharedService = app.get(SharedService);

app.connectMicroservice(
sharedService.getGrpcOptions('auth', AUTH_PACKAGE_NAME),
);

await app.startAllMicroservices();
}
bootstrap().catch((err) => {
console.error('Error starting Auth microservice:', err);
process.exit(1);
});
16 changes: 16 additions & 0 deletions apps/auth/test/app.e2e-spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import { AuthModule } from './../src/auth.module';

describe('AuthController (e2e)', () => {
let app: INestApplication;

beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AuthModule],
}).compile();

app = moduleFixture.createNestApplication();
await app.init();
});
});
File renamed without changes.
9 changes: 9 additions & 0 deletions apps/auth/tsconfig.app.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"declaration": false,
"outDir": "../../dist/apps/auth"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "test", "**/*spec.ts"]
}
Loading