feat: add CI/CD pipeline with OIDC, ECR, Docker Hub, lint and tests
This commit is contained in:
@@ -0,0 +1,244 @@
|
|||||||
|
name: CI/CD
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
env:
|
||||||
|
AWS_REGION: us-east-1
|
||||||
|
ECR_REGISTRY: 424522917561.dkr.ecr.us-east-1.amazonaws.com
|
||||||
|
DOCKERHUB_REGISTRY: lizzycloudlab
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
id-token: write
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
name: Lint
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: app/backend/package-lock.json
|
||||||
|
|
||||||
|
- name: Install backend dependencies
|
||||||
|
working-directory: app/backend
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Run ESLint
|
||||||
|
working-directory: app/backend
|
||||||
|
run: npx eslint . --ext .js --max-warnings=0
|
||||||
|
|
||||||
|
unit-test:
|
||||||
|
name: Unit Tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: lint
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: app/backend/package-lock.json
|
||||||
|
|
||||||
|
- name: Install backend dependencies
|
||||||
|
working-directory: app/backend
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Run unit tests
|
||||||
|
working-directory: app/backend
|
||||||
|
run: npm test
|
||||||
|
|
||||||
|
build-and-integration-test:
|
||||||
|
name: Build & Integration Test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: unit-test
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Build backend image
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: ./app/backend
|
||||||
|
file: ./app/backend/Dockerfile
|
||||||
|
push: false
|
||||||
|
load: true
|
||||||
|
tags: task-backend:ci
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
|
- name: Build frontend image
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: ./app/frontend
|
||||||
|
file: ./app/frontend/Dockerfile
|
||||||
|
push: false
|
||||||
|
load: true
|
||||||
|
tags: task-frontend:ci
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
|
- name: Start stack
|
||||||
|
run: docker compose up -d
|
||||||
|
|
||||||
|
- name: Wait for backend to be healthy
|
||||||
|
run: |
|
||||||
|
echo "Waiting for backend health..."
|
||||||
|
for i in $(seq 1 15); do
|
||||||
|
STATUS=$(docker inspect --format='{{.State.Health.Status}}' task-backend 2>/dev/null || echo "not_found")
|
||||||
|
echo "Attempt $i: backend = $STATUS"
|
||||||
|
if [ "$STATUS" = "healthy" ]; then
|
||||||
|
echo "Backend is healthy."
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if [ "$i" = "15" ]; then
|
||||||
|
echo "Backend did not become healthy in time."
|
||||||
|
docker compose logs backend
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Wait for frontend to be healthy
|
||||||
|
run: |
|
||||||
|
echo "Waiting for frontend health..."
|
||||||
|
for i in $(seq 1 15); do
|
||||||
|
STATUS=$(docker inspect --format='{{.State.Health.Status}}' task-frontend 2>/dev/null || echo "not_found")
|
||||||
|
echo "Attempt $i: frontend = $STATUS"
|
||||||
|
if [ "$STATUS" = "healthy" ]; then
|
||||||
|
echo "Frontend is healthy."
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if [ "$i" = "15" ]; then
|
||||||
|
echo "Frontend did not become healthy in time."
|
||||||
|
docker compose logs frontend
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Assert backend health endpoint
|
||||||
|
run: |
|
||||||
|
HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://localhost/api/health)
|
||||||
|
echo "GET /api/health returned: $HTTP_STATUS"
|
||||||
|
if [ "$HTTP_STATUS" != "200" ]; then
|
||||||
|
echo "FAILED: expected 200, got $HTTP_STATUS"
|
||||||
|
docker compose logs
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "PASSED"
|
||||||
|
|
||||||
|
- name: Assert frontend serves 200
|
||||||
|
run: |
|
||||||
|
HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://localhost)
|
||||||
|
echo "GET / returned: $HTTP_STATUS"
|
||||||
|
if [ "$HTTP_STATUS" != "200" ]; then
|
||||||
|
echo "FAILED: expected 200, got $HTTP_STATUS"
|
||||||
|
docker compose logs
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "PASSED"
|
||||||
|
|
||||||
|
- name: Print logs on failure
|
||||||
|
if: failure()
|
||||||
|
run: docker compose logs
|
||||||
|
|
||||||
|
- name: Tear down stack
|
||||||
|
if: always()
|
||||||
|
run: docker compose down --volumes
|
||||||
|
|
||||||
|
publish:
|
||||||
|
name: Publish Images
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: build-and-integration-test
|
||||||
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Extract short SHA
|
||||||
|
id: vars
|
||||||
|
run: echo "sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Configure AWS credentials via OIDC
|
||||||
|
uses: aws-actions/configure-aws-credentials@v4
|
||||||
|
with:
|
||||||
|
role-to-assume: arn:aws:iam::424522917561:role/github-actions-scalable-task-platform
|
||||||
|
aws-region: us-east-1
|
||||||
|
|
||||||
|
- name: Log in to Amazon ECR
|
||||||
|
id: ecr-login
|
||||||
|
uses: aws-actions/amazon-ecr-login@v2
|
||||||
|
|
||||||
|
- name: Log in to Docker Hub
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and push backend
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: ./app/backend
|
||||||
|
file: ./app/backend/Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ env.ECR_REGISTRY }}/task-backend:latest
|
||||||
|
${{ env.ECR_REGISTRY }}/task-backend:sha-${{ steps.vars.outputs.sha }}
|
||||||
|
${{ env.DOCKERHUB_REGISTRY }}/task-backend:latest
|
||||||
|
${{ env.DOCKERHUB_REGISTRY }}/task-backend:sha-${{ steps.vars.outputs.sha }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
|
- name: Build and push frontend
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: ./app/frontend
|
||||||
|
file: ./app/frontend/Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ env.ECR_REGISTRY }}/task-frontend:latest
|
||||||
|
${{ env.ECR_REGISTRY }}/task-frontend:sha-${{ steps.vars.outputs.sha }}
|
||||||
|
${{ env.DOCKERHUB_REGISTRY }}/task-frontend:latest
|
||||||
|
${{ env.DOCKERHUB_REGISTRY }}/task-frontend:sha-${{ steps.vars.outputs.sha }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
|
- name: Job summary
|
||||||
|
run: |
|
||||||
|
echo "### Published Images - $(date -u +%Y-%m-%dT%H:%M:%SZ)" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "**ECR**" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- \`${{ env.ECR_REGISTRY }}/task-backend:latest\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- \`${{ env.ECR_REGISTRY }}/task-backend:sha-${{ steps.vars.outputs.sha }}\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- \`${{ env.ECR_REGISTRY }}/task-frontend:latest\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- \`${{ env.ECR_REGISTRY }}/task-frontend:sha-${{ steps.vars.outputs.sha }}\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "**Docker Hub**" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- \`${{ env.DOCKERHUB_REGISTRY }}/task-backend:latest\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- \`${{ env.DOCKERHUB_REGISTRY }}/task-backend:sha-${{ steps.vars.outputs.sha }}\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- \`${{ env.DOCKERHUB_REGISTRY }}/task-frontend:latest\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- \`${{ env.DOCKERHUB_REGISTRY }}/task-frontend:sha-${{ steps.vars.outputs.sha }}\`" >> $GITHUB_STEP_SUMMARY
|
||||||
@@ -4,59 +4,77 @@ A production-grade DevOps project documenting the full lifecycle of a task manag
|
|||||||
|
|
||||||
Built in public, one week at a time.
|
Built in public, one week at a time.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## What This Project Demonstrates
|
## What This Project Demonstrates
|
||||||
|
|
||||||
- Backend API development with Node.js and Express
|
- Backend API development with Node.js and Express
|
||||||
- Containerization with Docker
|
- Containerization with Docker
|
||||||
- CI/CD automation with GitHub Actions
|
- CI/CD automation with GitHub Actions, Amazon ECR, and Docker Hub
|
||||||
- Infrastructure as Code with Terraform
|
- Infrastructure as Code with Terraform
|
||||||
- Cloud deployment on AWS
|
- Cloud deployment on AWS
|
||||||
- Container orchestration with Kubernetes
|
- Container orchestration with Kubernetes
|
||||||
- Monitoring and observability with Prometheus and Grafana
|
- Monitoring and observability with Prometheus and Grafana
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
| Layer | Technology |
|
| Layer | Technology |
|
||||||
|-------|-----------|
|
|---|---|
|
||||||
| Backend | Node.js, Express |
|
| Backend | Node.js, Express |
|
||||||
| Containerization | Docker, Docker Compose |
|
| Containerization | Docker, Docker Compose |
|
||||||
| CI/CD | GitHub Actions |
|
| CI/CD | GitHub Actions |
|
||||||
|
| Image Registry | Amazon ECR, Docker Hub |
|
||||||
| Infrastructure | Terraform |
|
| Infrastructure | Terraform |
|
||||||
| Cloud | AWS EC2 |
|
| Cloud | AWS EC2 |
|
||||||
| Orchestration | Kubernetes |
|
| Orchestration | Kubernetes |
|
||||||
| Monitoring | Prometheus, Grafana |
|
| Monitoring | Prometheus, Grafana |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
| Week | Topic | Status |
|
| Week | Topic | Status |
|
||||||
|------|-------|--------|
|
|---|---|---|
|
||||||
| 1 | Project foundation and backend setup | [Done](docs/week-1-update.md) |
|
| 1 | Project foundation and backend API | [Done](docs/week-1-update.md) |
|
||||||
| 2 | Dockerize the backend | [Done](docs/week-2-update.md) |
|
| 2 | Dockerize the backend | [Done](docs/week-2-update.md) |
|
||||||
| 3 | Add frontend and Docker Compose | [Done](docs/week-3-update.md) |
|
| 3 | React frontend and Docker Compose | [Done](docs/week-3-update.md) |
|
||||||
| 4 | CI with GitHub Actions | Upcoming |
|
| 4 | CI/CD pipeline with GitHub Actions, ECR and Docker Hub | [Done](docs/week-4-update.md) |
|
||||||
| 5 | CD workflow and image registry | Upcoming |
|
| 5 | Terraform infrastructure setup | Upcoming |
|
||||||
| 6 | Terraform infrastructure setup | Upcoming |
|
| 6 | Cloud deployment on AWS EC2 | Upcoming |
|
||||||
| 7 | Cloud deployment on AWS | Upcoming |
|
| 7 | Database integration | Upcoming |
|
||||||
| 8 | Kubernetes deployment | Upcoming |
|
| 8 | Kubernetes deployment | Upcoming |
|
||||||
| 9 | Monitoring with Prometheus and Grafana | Upcoming |
|
| 9 | Monitoring with Prometheus and Grafana | Upcoming |
|
||||||
| 10 | Hardening and project polish | Upcoming |
|
| 10 | Hardening and project polish | Upcoming |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## API Endpoints
|
## API Endpoints
|
||||||
|
|
||||||
| Method | Route | Description |
|
| Method | Route | Description |
|
||||||
|--------|-------|-------------|
|
|---|---|---|
|
||||||
| GET | / | API health check |
|
| GET | /api | API status |
|
||||||
| GET | /health | Returns status and uptime |
|
| GET | /api/health | Returns status and uptime |
|
||||||
| GET | /tasks | Returns all tasks |
|
| GET | /api/tasks | Returns all tasks |
|
||||||
| POST | /tasks | Creates a new task |
|
| POST | /api/tasks | Creates a new task |
|
||||||
| PATCH | /tasks/:id | Toggles task complete or incomplete |
|
| PATCH | /api/tasks/:id | Toggles task complete or incomplete |
|
||||||
| DELETE | /tasks/:id | Deletes a task |
|
| DELETE | /api/tasks/:id | Deletes a task |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
- Node.js 18 or higher
|
|
||||||
- npm
|
|
||||||
|
|
||||||
### Run Locally
|
- Node.js 20 or higher
|
||||||
|
- npm
|
||||||
|
- Docker and Docker Compose
|
||||||
|
|
||||||
|
### Run Locally Without Docker
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/YOUR_USERNAME/scalable-task-platform.git
|
git clone https://github.com/elizabeth-ikechukwu/scalable-task-platform.git
|
||||||
cd scalable-task-platform/app/backend
|
cd scalable-task-platform/app/backend
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
npm install
|
npm install
|
||||||
@@ -65,13 +83,42 @@ npm start
|
|||||||
|
|
||||||
Server runs on http://localhost:3000
|
Server runs on http://localhost:3000
|
||||||
|
|
||||||
|
### Run With Docker Compose
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/elizabeth-ikechukwu/scalable-task-platform.git
|
||||||
|
cd scalable-task-platform
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Frontend runs on http://localhost:80
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CI/CD Pipeline
|
||||||
|
|
||||||
|
Every pull request and push to `main` triggers a four-stage pipeline:
|
||||||
|
|
||||||
|
```
|
||||||
|
lint -> unit-test -> build-and-integration-test -> publish
|
||||||
|
```
|
||||||
|
|
||||||
|
The publish stage runs on merge to `main` only. Images are pushed to both Amazon ECR and Docker Hub, tagged with `latest` and the short Git SHA for traceability.
|
||||||
|
|
||||||
|
AWS authentication uses OIDC - no static credentials stored in GitHub secrets.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Weekly Updates
|
## Weekly Updates
|
||||||
|
|
||||||
Detailed documentation for each week is in the [docs](docs/) folder.
|
Detailed documentation for each week is in the [docs](docs/) folder.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Author
|
## Author
|
||||||
|
|
||||||
Ikechukwu Elizabeth Nkwo
|
Ikechukwu Elizabeth Nkwo
|
||||||
|
|
||||||
DevOps and Cloud Engineer
|
DevOps and Cloud Engineer
|
||||||
|
|
||||||
[LinkedIn](https://www.linkedin.com/in/ikechukwu-elizabeth)
|
[LinkedIn](https://www.linkedin.com/in/ikechukwu-elizabeth)
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
const js = require("@eslint/js");
|
||||||
|
|
||||||
|
module.exports = [
|
||||||
|
js.configs.recommended,
|
||||||
|
{
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2021,
|
||||||
|
globals: {
|
||||||
|
require: "readonly",
|
||||||
|
module: "readonly",
|
||||||
|
exports: "readonly",
|
||||||
|
process: "readonly",
|
||||||
|
__dirname: "readonly",
|
||||||
|
console: "readonly",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
"no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }],
|
||||||
|
"no-console": "off",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ["**/*.test.js"],
|
||||||
|
languageOptions: {
|
||||||
|
globals: {
|
||||||
|
describe: "readonly",
|
||||||
|
it: "readonly",
|
||||||
|
expect: "readonly",
|
||||||
|
beforeEach: "readonly",
|
||||||
|
afterEach: "readonly",
|
||||||
|
beforeAll: "readonly",
|
||||||
|
afterAll: "readonly",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
Generated
+5465
File diff suppressed because it is too large
Load Diff
@@ -5,10 +5,18 @@
|
|||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node server.js",
|
"start": "node server.js",
|
||||||
"dev": "node --watch server.js"
|
"dev": "node --watch server.js",
|
||||||
|
"test": "jest --forceExit",
|
||||||
|
"lint": "eslint . --ext .js --max-warnings=0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dotenv": "^17.4.0",
|
"dotenv": "^17.4.0",
|
||||||
"express": "^4.21.2"
|
"express": "^4.21.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^10.0.1",
|
||||||
|
"eslint": "^10.2.1",
|
||||||
|
"jest": "^30.3.0",
|
||||||
|
"supertest": "^7.2.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-11
@@ -11,19 +11,23 @@ let tasks = [];
|
|||||||
let nextId = 1;
|
let nextId = 1;
|
||||||
|
|
||||||
// Routes
|
// Routes
|
||||||
app.get("/", (req, res) => {
|
app.get("/api", (req, res) => {
|
||||||
res.json({ message: "Task API is running", version: "1.0.0" });
|
res.json({ message: "Task API is running", version: "1.0.0" });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/health", (req, res) => {
|
app.get("/api/health", (req, res) => {
|
||||||
res.status(200).json({ status: "healthy", uptime: process.uptime() });
|
res.status(200).json({
|
||||||
|
status: "ok",
|
||||||
|
uptime: process.uptime(),
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/tasks", (req, res) => {
|
app.get("/api/tasks", (req, res) => {
|
||||||
res.json(tasks);
|
res.json(tasks);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/tasks", (req, res) => {
|
app.post("/api/tasks", (req, res) => {
|
||||||
const { title } = req.body;
|
const { title } = req.body;
|
||||||
if (!title) {
|
if (!title) {
|
||||||
return res.status(400).json({ error: "Title is required" });
|
return res.status(400).json({ error: "Title is required" });
|
||||||
@@ -33,14 +37,14 @@ app.post("/tasks", (req, res) => {
|
|||||||
res.status(201).json(task);
|
res.status(201).json(task);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.patch("/tasks/:id", (req, res) => {
|
app.patch("/api/tasks/:id", (req, res) => {
|
||||||
const task = tasks.find((t) => t.id === parseInt(req.params.id));
|
const task = tasks.find((t) => t.id === parseInt(req.params.id));
|
||||||
if (!task) return res.status(404).json({ error: "Task not found" });
|
if (!task) return res.status(404).json({ error: "Task not found" });
|
||||||
task.completed = !task.completed;
|
task.completed = !task.completed;
|
||||||
res.json(task);
|
res.json(task);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.delete("/tasks/:id", (req, res) => {
|
app.delete("/api/tasks/:id", (req, res) => {
|
||||||
const index = tasks.findIndex((t) => t.id === parseInt(req.params.id));
|
const index = tasks.findIndex((t) => t.id === parseInt(req.params.id));
|
||||||
if (index === -1) return res.status(404).json({ error: "Task not found" });
|
if (index === -1) return res.status(404).json({ error: "Task not found" });
|
||||||
tasks.splice(index, 1);
|
tasks.splice(index, 1);
|
||||||
@@ -53,11 +57,15 @@ app.use((req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Global error handler
|
// Global error handler
|
||||||
app.use((err, req, res, next) => {
|
app.use((err, req, res, _next) => {
|
||||||
console.error(err.stack);
|
console.error(err.stack);
|
||||||
res.status(500).json({ error: "Internal server error" });
|
res.status(500).json({ error: "Internal server error" });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.listen(PORT, () => {
|
if (require.main === module) {
|
||||||
console.log(`Server running on port ${PORT}`);
|
app.listen(PORT, () => {
|
||||||
});
|
console.log(`Server running on port ${PORT}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = app;
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
const request = require('supertest');
|
||||||
|
const app = require('./server');
|
||||||
|
|
||||||
|
describe('Health Check', () => {
|
||||||
|
it('GET /api/health returns 200 with status ok', async () => {
|
||||||
|
const res = await request(app).get('/api/health');
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.body.status).toBe('ok');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Tasks API', () => {
|
||||||
|
it('GET /api/tasks returns empty array initially', async () => {
|
||||||
|
const res = await request(app).get('/api/tasks');
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(Array.isArray(res.body)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /api/tasks creates a new task', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/tasks')
|
||||||
|
.send({ title: 'Test task' });
|
||||||
|
expect(res.statusCode).toBe(201);
|
||||||
|
expect(res.body.title).toBe('Test task');
|
||||||
|
expect(res.body.completed).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /api/tasks returns 400 when title is missing', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/tasks')
|
||||||
|
.send({});
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
+14
-1
@@ -11,6 +11,12 @@ services:
|
|||||||
- PORT=3000
|
- PORT=3000
|
||||||
networks:
|
networks:
|
||||||
- task-network
|
- task-network
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/api/health"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
build:
|
build:
|
||||||
@@ -21,9 +27,16 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "80:80"
|
- "80:80"
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
backend:
|
||||||
|
condition: service_healthy
|
||||||
networks:
|
networks:
|
||||||
- task-network
|
- task-network
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:80"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 15s
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
task-network:
|
task-network:
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
# Week 4 - GitHub Actions CI/CD Pipeline
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Week 4 introduces a production-grade CI/CD pipeline to the Scalable Task Platform. Every push and pull request targeting `main` runs a full four-stage pipeline: lint, unit tests, integration test, and image publish. AWS authentication uses OIDC - no static credentials stored anywhere. Images are pushed to both Amazon ECR and Docker Hub on every successful merge to `main`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pipeline Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Push / PR to main
|
||||||
|
|
|
||||||
|
v
|
||||||
|
[ lint ]
|
||||||
|
|
|
||||||
|
v
|
||||||
|
[ unit-test ]
|
||||||
|
needs: lint
|
||||||
|
|
|
||||||
|
v
|
||||||
|
[ build-and-integration-test ]
|
||||||
|
needs: unit-test
|
||||||
|
|
|
||||||
|
v
|
||||||
|
[ publish ]
|
||||||
|
needs: build-and-integration-test
|
||||||
|
if: push to main only, skipped on PRs
|
||||||
|
|
|
||||||
|
+---------------------------+
|
||||||
|
| |
|
||||||
|
v v
|
||||||
|
Amazon ECR Docker Hub
|
||||||
|
task-backend:latest lizzycloudlab/task-backend:latest
|
||||||
|
task-backend:sha-<sha> lizzycloudlab/task-backend:sha-<sha>
|
||||||
|
task-frontend:latest lizzycloudlab/task-frontend:latest
|
||||||
|
task-frontend:sha-<sha> lizzycloudlab/task-frontend:sha-<sha>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files Added / Modified
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|---|---|
|
||||||
|
| `.github/workflows/ci-cd.yml` | Single workflow replacing two separate files |
|
||||||
|
| `docker-compose.yml` | Added healthcheck blocks, upgraded depends_on to condition: service_healthy |
|
||||||
|
| `app/backend/server.js` | Prefixed all routes with /api/, updated health endpoint response |
|
||||||
|
| `iam/github-actions-trust-policy.json` | OIDC trust policy scoped to this repo only |
|
||||||
|
| `iam/github-actions-ecr-policy.json` | Least-privilege ECR push permissions |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## AWS Authentication: OIDC
|
||||||
|
|
||||||
|
This pipeline uses OpenID Connect (OIDC) for AWS authentication. No AWS access keys are stored in GitHub secrets.
|
||||||
|
|
||||||
|
Each workflow run, GitHub generates a short-lived signed JWT token. AWS STS verifies the token against the registered GitHub OIDC provider and issues temporary credentials scoped to that job only. Credentials expire the moment the job finishes. Nothing to rotate, nothing to leak.
|
||||||
|
|
||||||
|
The IAM role `github-actions-scalable-task-platform` is scoped tightly:
|
||||||
|
|
||||||
|
- **Trust policy** - only tokens from `repo:elizabeth-ikechukwu/scalable-task-platform:*` can assume the role
|
||||||
|
- **Permissions policy** - ECR push to `task-backend` and `task-frontend` only, nothing else in the AWS account
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Image Tagging Strategy
|
||||||
|
|
||||||
|
| Tag | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `latest` | Always points to the most recent successful build |
|
||||||
|
| `sha-abc1234` | Immutable reference to the exact commit, used for rollback |
|
||||||
|
|
||||||
|
Every running container can be traced back to the exact line of code that produced it using the SHA tag.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ECR Repositories
|
||||||
|
|
||||||
|
| Repository | URI |
|
||||||
|
|---|---|
|
||||||
|
| task-backend | `424522917561.dkr.ecr.us-east-1.amazonaws.com/task-backend` |
|
||||||
|
| task-frontend | `424522917561.dkr.ecr.us-east-1.amazonaws.com/task-frontend` |
|
||||||
|
|
||||||
|
Both repositories configured with: private visibility, mutable tags, AES-256 encryption, scan on push enabled.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GitHub Secrets
|
||||||
|
|
||||||
|
| Secret | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `DOCKERHUB_USERNAME` | Docker Hub login username |
|
||||||
|
| `DOCKERHUB_TOKEN` | Docker Hub access token (not password) |
|
||||||
|
|
||||||
|
No AWS secrets needed. OIDC handles AWS authentication entirely.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Docker Compose Health Checks
|
||||||
|
|
||||||
|
Both services now have health checks defined. Docker probes the backend every 10 seconds via `GET /api/health`. The frontend `depends_on` uses `condition: service_healthy` meaning Nginx will not start until the backend passes its health probe. This prevents the frontend from coming up pointing at a backend that is not ready.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Branch Protection
|
||||||
|
|
||||||
|
`main` is protected with the following rules enforced:
|
||||||
|
|
||||||
|
- No direct pushes - all changes must go through a pull request
|
||||||
|
- Force pushes blocked
|
||||||
|
- Branch deletion blocked
|
||||||
|
|
||||||
|
All development happens on feature branches and merges to `main` via PR.
|
||||||
|
|
||||||
|
---
|
||||||
|
## Testing and Linting
|
||||||
|
|
||||||
|
The backend includes a full test and lint setup before the pipeline was built.
|
||||||
|
|
||||||
|
**ESLint** - configured via `eslint.config.js` using the new flat config format
|
||||||
|
required by ESLint v9+. Lints all `.js` files with a separate config block for
|
||||||
|
test files that includes Jest globals.
|
||||||
|
|
||||||
|
**Jest + Supertest** - four unit and integration tests covering:
|
||||||
|
- `GET /api/health` returns 200 with status ok
|
||||||
|
- `GET /api/tasks` returns empty array initially
|
||||||
|
- `POST /api/tasks` creates a task correctly
|
||||||
|
- `POST /api/tasks` returns 400 when title is missing
|
||||||
|
|
||||||
|
Both pass cleanly locally before the pipeline runs them in CI.
|
||||||
|
|
||||||
|
## What Week 5 Adds
|
||||||
|
|
||||||
|
Week 5 introduces Terraform to provision AWS infrastructure - VPC, subnets, security groups, and EC2 instance. The EC2 instance will pull images directly from ECR at deploy time using the same OIDC authentication pattern established this week.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"Version": "2012-10-17",
|
||||||
|
"Statement": [
|
||||||
|
{
|
||||||
|
"Sid": "ECRAuthentication",
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Action": "ecr:GetAuthorizationToken",
|
||||||
|
"Resource": "*"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Sid": "ECRImagePush",
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Action": [
|
||||||
|
"ecr:BatchCheckLayerAvailability",
|
||||||
|
"ecr:CompleteLayerUpload",
|
||||||
|
"ecr:DescribeRepositories",
|
||||||
|
"ecr:InitiateLayerUpload",
|
||||||
|
"ecr:PutImage",
|
||||||
|
"ecr:UploadLayerPart"
|
||||||
|
],
|
||||||
|
"Resource": [
|
||||||
|
"arn:aws:ecr:us-east-1:424522917561:repository/task-backend",
|
||||||
|
"arn:aws:ecr:us-east-1:424522917561:repository/task-frontend"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"Version": "2012-10-17",
|
||||||
|
"Statement": [
|
||||||
|
{
|
||||||
|
"Sid": "GitHubActionsOIDC",
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Principal": {
|
||||||
|
"Federated": "arn:aws:iam::424522917561:oidc-provider/token.actions.githubusercontent.com"
|
||||||
|
},
|
||||||
|
"Action": "sts:AssumeRoleWithWebIdentity",
|
||||||
|
"Condition": {
|
||||||
|
"StringEquals": {
|
||||||
|
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
|
||||||
|
},
|
||||||
|
"StringLike": {
|
||||||
|
"token.actions.githubusercontent.com:sub": "repo:elizabeth-ikechukwu/scalable-task-platform:*"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user