Skip to content

Commit 2f96629

Browse files
committed
Initial commit: FastAPI task API with CI/CD pipeline
0 parents  commit 2f96629

4 files changed

Lines changed: 186 additions & 0 deletions

File tree

.github/workflows/deploy.yml

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
name: Build & Deploy to K3s
2+
3+
on:
4+
push:
5+
branches: [main]
6+
workflow_dispatch:
7+
8+
env:
9+
IMAGE_NAME: taskapi
10+
K3S_HOST: ${{ secrets.K3S_HOST }}
11+
12+
jobs:
13+
build-and-deploy:
14+
runs-on: ubuntu-latest
15+
steps:
16+
- name: Checkout code
17+
uses: actions/checkout@v4
18+
19+
- name: Build Docker image
20+
run: |
21+
docker build -t $IMAGE_NAME:${{ github.sha }} -t $IMAGE_NAME:latest .
22+
23+
- name: Save Docker image
24+
run: |
25+
docker save $IMAGE_NAME:${{ github.sha }} | gzip > image.tar.gz
26+
27+
- name: Copy image to K3s node
28+
uses: appleboy/scp-action@v0.1.7
29+
with:
30+
host: ${{ secrets.K3S_HOST }}
31+
username: ${{ secrets.K3S_USER }}
32+
key: ${{ secrets.K3S_SSH_KEY }}
33+
source: "image.tar.gz"
34+
target: "/tmp"
35+
36+
- name: Deploy to K3s
37+
uses: appleboy/ssh-action@v1.0.3
38+
with:
39+
host: ${{ secrets.K3S_HOST }}
40+
username: ${{ secrets.K3S_USER }}
41+
key: ${{ secrets.K3S_SSH_KEY }}
42+
script: |
43+
# Import image
44+
gunzip -c /tmp/image.tar.gz | sudo k3s ctr images import -
45+
rm /tmp/image.tar.gz
46+
47+
# Update deployment with new image tag
48+
sudo kubectl set image deployment/taskapi \
49+
taskapi=docker.io/library/taskapi:${{ github.sha }} \
50+
-n app
51+
52+
# Wait for rollout
53+
sudo kubectl rollout status deployment/taskapi -n app --timeout=60s
54+
55+
echo "Deploy complete: taskapi:${{ github.sha }}"
56+
57+
- name: Verify deployment
58+
uses: appleboy/ssh-action@v1.0.3
59+
with:
60+
host: ${{ secrets.K3S_HOST }}
61+
username: ${{ secrets.K3S_USER }}
62+
key: ${{ secrets.K3S_SSH_KEY }}
63+
script: |
64+
sudo kubectl get pods -n app -l app=taskapi
65+
echo "---"
66+
curl -sf http://localhost:30081/health || echo "Health check failed"

Dockerfile

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
FROM python:3.12-slim
2+
WORKDIR /app
3+
COPY app/requirements.txt .
4+
RUN pip install --no-cache-dir -r requirements.txt
5+
COPY app/main.py .
6+
EXPOSE 8000
7+
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

app/main.py

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import os
2+
import time
3+
import asyncpg
4+
from fastapi import FastAPI, HTTPException
5+
from pydantic import BaseModel
6+
from prometheus_client import Counter, Histogram, generate_latest
7+
from fastapi.responses import PlainTextResponse
8+
9+
app = FastAPI(title="DevOps Task API", version="1.0.0")
10+
11+
# --- Prometheus metrics ---
12+
REQUEST_COUNT = Counter("http_requests_total", "Total HTTP requests", ["method", "endpoint", "status"])
13+
REQUEST_DURATION = Histogram("http_request_duration_seconds", "Request duration", ["endpoint"])
14+
TASKS_CREATED = Counter("tasks_created_total", "Total tasks created")
15+
TASKS_COMPLETED = Counter("tasks_completed_total", "Total tasks completed")
16+
17+
# --- DB connection ---
18+
DB_URL = (
19+
f"postgresql://{os.getenv('DB_USER', 'devops')}:{os.getenv('DB_PASSWORD', 'devops2026')}"
20+
f"@{os.getenv('DB_HOST', 'postgres')}:5432/{os.getenv('DB_NAME', 'taskdb')}"
21+
)
22+
pool = None
23+
24+
class TaskIn(BaseModel):
25+
title: str
26+
description: str = ""
27+
28+
class TaskOut(BaseModel):
29+
id: int
30+
title: str
31+
description: str
32+
done: bool
33+
34+
@app.on_event("startup")
35+
async def startup():
36+
global pool
37+
pool = await asyncpg.create_pool(DB_URL, min_size=2, max_size=10)
38+
async with pool.acquire() as conn:
39+
await conn.execute("""
40+
CREATE TABLE IF NOT EXISTS tasks (
41+
id SERIAL PRIMARY KEY,
42+
title TEXT NOT NULL,
43+
description TEXT DEFAULT '',
44+
done BOOLEAN DEFAULT FALSE
45+
)
46+
""")
47+
48+
@app.on_event("shutdown")
49+
async def shutdown():
50+
if pool:
51+
await pool.close()
52+
53+
@app.get("/metrics", response_class=PlainTextResponse)
54+
async def metrics():
55+
return generate_latest()
56+
57+
@app.get("/health")
58+
async def health():
59+
try:
60+
async with pool.acquire() as conn:
61+
await conn.fetchval("SELECT 1")
62+
return {"status": "healthy", "db": "connected"}
63+
except Exception as e:
64+
raise HTTPException(status_code=503, detail=str(e))
65+
66+
@app.get("/tasks", response_model=list[TaskOut])
67+
async def list_tasks():
68+
start = time.time()
69+
async with pool.acquire() as conn:
70+
rows = await conn.fetch("SELECT id, title, description, done FROM tasks ORDER BY id")
71+
REQUEST_COUNT.labels("GET", "/tasks", 200).inc()
72+
REQUEST_DURATION.labels("/tasks").observe(time.time() - start)
73+
return [dict(r) for r in rows]
74+
75+
@app.post("/tasks", response_model=TaskOut, status_code=201)
76+
async def create_task(task: TaskIn):
77+
start = time.time()
78+
async with pool.acquire() as conn:
79+
row = await conn.fetchrow(
80+
"INSERT INTO tasks (title, description) VALUES ($1, $2) RETURNING id, title, description, done",
81+
task.title, task.description,
82+
)
83+
TASKS_CREATED.inc()
84+
REQUEST_COUNT.labels("POST", "/tasks", 201).inc()
85+
REQUEST_DURATION.labels("/tasks").observe(time.time() - start)
86+
return dict(row)
87+
88+
@app.patch("/tasks/{task_id}/complete", response_model=TaskOut)
89+
async def complete_task(task_id: int):
90+
start = time.time()
91+
async with pool.acquire() as conn:
92+
row = await conn.fetchrow(
93+
"UPDATE tasks SET done = TRUE WHERE id = $1 RETURNING id, title, description, done",
94+
task_id,
95+
)
96+
if not row:
97+
raise HTTPException(status_code=404, detail="Task not found")
98+
TASKS_COMPLETED.inc()
99+
REQUEST_COUNT.labels("PATCH", "/tasks/complete", 200).inc()
100+
REQUEST_DURATION.labels("/tasks/complete").observe(time.time() - start)
101+
return dict(row)
102+
103+
@app.delete("/tasks/{task_id}", status_code=204)
104+
async def delete_task(task_id: int):
105+
async with pool.acquire() as conn:
106+
result = await conn.execute("DELETE FROM tasks WHERE id = $1", task_id)
107+
if result == "DELETE 0":
108+
raise HTTPException(status_code=404, detail="Task not found")
109+
REQUEST_COUNT.labels("DELETE", "/tasks", 204).inc()

app/requirements.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
fastapi==0.115.0
2+
uvicorn==0.30.0
3+
asyncpg==0.29.0
4+
prometheus-client==0.21.0

0 commit comments

Comments
 (0)