mirror of
https://github.com/dat515-2025/Group-8.git
synced 2026-03-22 15:12:08 +01:00
feat(infrastructure): add basic project deployment
This commit is contained in:
10
.github/workflows/workflow.yml
vendored
10
.github/workflows/workflow.yml
vendored
@@ -3,6 +3,8 @@ name: Build, Push and Update Image in Manifest
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ "main" ]
|
branches: [ "main" ]
|
||||||
|
paths:
|
||||||
|
- 'backend/**'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -26,9 +28,9 @@ jobs:
|
|||||||
id: build
|
id: build
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: ./cd-test
|
context: ./backend
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ secrets.DOCKER_USER }}/cd-test:latest
|
tags: ${{ secrets.DOCKER_USER }}/cc-app-demo:latest
|
||||||
|
|
||||||
- name: Get image digest
|
- name: Get image digest
|
||||||
run: echo "IMAGE_DIGEST=${{ steps.build.outputs.digest }}" >> $GITHUB_ENV
|
run: echo "IMAGE_DIGEST=${{ steps.build.outputs.digest }}" >> $GITHUB_ENV
|
||||||
@@ -36,9 +38,9 @@ jobs:
|
|||||||
- name: Update manifest with new image digest
|
- name: Update manifest with new image digest
|
||||||
uses: OpsVerseIO/image-updater-action@0.1.0
|
uses: OpsVerseIO/image-updater-action@0.1.0
|
||||||
with:
|
with:
|
||||||
valueFile: 'cd-test/guestbook-ui-deployment.yaml'
|
valueFile: 'deployment/guestbook-ui-deployment.yaml'
|
||||||
propertyPath: 'spec.template.spec.containers[0].image'
|
propertyPath: 'spec.template.spec.containers[0].image'
|
||||||
value: ${{ secrets.DOCKER_USER }}/cd-test@${{ env.IMAGE_DIGEST }}
|
value: ${{ secrets.DOCKER_USER }}/cc-app-demo@${{ env.IMAGE_DIGEST }}
|
||||||
commitChange: true
|
commitChange: true
|
||||||
message: "${{ github.event.head_commit.message }}"
|
message: "${{ github.event.head_commit.message }}"
|
||||||
createPR: 'false'
|
createPR: 'false'
|
||||||
|
|||||||
7
backend/Dockerfile
Normal file
7
backend/Dockerfile
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
WORKDIR /app
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
COPY . .
|
||||||
|
EXPOSE 8000
|
||||||
|
CMD ["uvicorn", "app.app:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
@@ -1,11 +1,24 @@
|
|||||||
from fastapi import Depends, FastAPI
|
from fastapi import Depends, FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from .db import User, create_db_and_tables
|
from .db import User, create_db_and_tables
|
||||||
from .schemas import UserCreate, UserRead, UserUpdate
|
from .schemas import UserCreate, UserRead, UserUpdate
|
||||||
from .users import auth_backend, current_active_user, fastapi_users
|
from .users import auth_backend, current_active_verified_user, fastapi_users
|
||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
|
|
||||||
|
# CORS for frontend dev server
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=[
|
||||||
|
"http://localhost:5173",
|
||||||
|
"http://127.0.0.1:5173",
|
||||||
|
],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
app.include_router(
|
app.include_router(
|
||||||
fastapi_users.get_auth_router(auth_backend), prefix="/auth/jwt", tags=["auth"]
|
fastapi_users.get_auth_router(auth_backend), prefix="/auth/jwt", tags=["auth"]
|
||||||
)
|
)
|
||||||
@@ -32,7 +45,7 @@ app.include_router(
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/authenticated-route")
|
@app.get("/authenticated-route")
|
||||||
async def authenticated_route(user: User = Depends(current_active_user)):
|
async def authenticated_route(user: User = Depends(current_active_verified_user)):
|
||||||
return {"message": f"Hello {user.email}!"}
|
return {"message": f"Hello {user.email}!"}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
47
backend/app/queue.py
Normal file
47
backend/app/queue.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
RABBITMQ_URL = os.getenv("RABBITMQ_URL") or (
|
||||||
|
f"amqp://{os.getenv('RABBITMQ_USERNAME', 'user')}:"
|
||||||
|
f"{os.getenv('RABBITMQ_PASSWORD', 'bitnami123')}@"
|
||||||
|
f"{os.getenv('RABBITMQ_HOST', 'localhost')}:"
|
||||||
|
f"{os.getenv('RABBITMQ_PORT', '5672')}"
|
||||||
|
)
|
||||||
|
QUEUE_NAME = os.getenv("MAIL_QUEUE", "mail_queue")
|
||||||
|
|
||||||
|
|
||||||
|
async def _publish_async(message: Dict[str, Any]) -> None:
|
||||||
|
# Import locally to avoid hard dependency at import-time
|
||||||
|
import aio_pika
|
||||||
|
|
||||||
|
connection = await aio_pika.connect_robust(RABBITMQ_URL)
|
||||||
|
try:
|
||||||
|
channel = await connection.channel()
|
||||||
|
await channel.declare_queue(QUEUE_NAME, durable=True)
|
||||||
|
body = json.dumps(message).encode("utf-8")
|
||||||
|
await channel.default_exchange.publish(
|
||||||
|
aio_pika.Message(body=body, delivery_mode=aio_pika.DeliveryMode.PERSISTENT),
|
||||||
|
routing_key=QUEUE_NAME,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await connection.close()
|
||||||
|
|
||||||
|
|
||||||
|
def enqueue_email(to: str, subject: str, body: str) -> None:
|
||||||
|
"""
|
||||||
|
Enqueue an email to RabbitMQ. If RabbitMQ or aio_pika is not available,
|
||||||
|
this function will raise ImportError/ConnectionError. The caller may
|
||||||
|
implement fallback (e.g., direct send).
|
||||||
|
"""
|
||||||
|
message = {"type": "email", "to": to, "subject": subject, "body": body}
|
||||||
|
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
# Fire-and-forget task so we don't block the request path
|
||||||
|
loop.create_task(_publish_async(message))
|
||||||
|
except RuntimeError:
|
||||||
|
# No running loop (e.g., called from sync context) – run a short loop
|
||||||
|
asyncio.run(_publish_async(message))
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
@@ -12,7 +13,9 @@ from fastapi_users.db import SQLAlchemyUserDatabase
|
|||||||
|
|
||||||
from .db import User, get_user_db
|
from .db import User, get_user_db
|
||||||
|
|
||||||
SECRET = "SECRET"
|
SECRET = os.getenv("SECRET", "CHANGE_ME_SECRET")
|
||||||
|
FRONTEND_URL = os.getenv("FRONTEND_URL", "http://localhost:5173")
|
||||||
|
BACKEND_URL = os.getenv("BACKEND_URL", "http://localhost:8000")
|
||||||
|
|
||||||
|
|
||||||
class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
|
class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
|
||||||
@@ -20,7 +23,8 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
|
|||||||
verification_token_secret = SECRET
|
verification_token_secret = SECRET
|
||||||
|
|
||||||
async def on_after_register(self, user: User, request: Optional[Request] = None):
|
async def on_after_register(self, user: User, request: Optional[Request] = None):
|
||||||
print(f"User {user.id} has registered.")
|
# Ask FastAPI Users to generate a verification token and trigger the hook below
|
||||||
|
await self.request_verify(user, request)
|
||||||
|
|
||||||
async def on_after_forgot_password(
|
async def on_after_forgot_password(
|
||||||
self, user: User, token: str, request: Optional[Request] = None
|
self, user: User, token: str, request: Optional[Request] = None
|
||||||
@@ -30,7 +34,26 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
|
|||||||
async def on_after_request_verify(
|
async def on_after_request_verify(
|
||||||
self, user: User, token: str, request: Optional[Request] = None
|
self, user: User, token: str, request: Optional[Request] = None
|
||||||
):
|
):
|
||||||
print(f"Verification requested for user {user.id}. Verification token: {token}")
|
# Build verification email and send through RabbitMQ (with direct SMTP fallback)
|
||||||
|
verify_frontend_link = f"{FRONTEND_URL}/verify?token={token}"
|
||||||
|
verify_backend_link = f"{BACKEND_URL}/auth/verify?token={token}"
|
||||||
|
subject = "Ověření účtu"
|
||||||
|
body = (
|
||||||
|
"Ahoj,\n\n"
|
||||||
|
"děkujeme za registraci. Prosíme, ověř svůj účet kliknutím na tento odkaz:\n"
|
||||||
|
f"{verify_frontend_link}\n\n"
|
||||||
|
"Pokud by odkaz nefungoval, můžeš použít i přímý odkaz na backend:\n"
|
||||||
|
f"{verify_backend_link}\n\n"
|
||||||
|
"Pokud jsi registraci neprováděl(a), tento email ignoruj.\n"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
from .queue import enqueue_email
|
||||||
|
enqueue_email(to=user.email, subject=subject, body=body)
|
||||||
|
except Exception:
|
||||||
|
# Fallback: if queue is unavailable, log the email content (dev fallback)
|
||||||
|
print("[Email Fallback] To:", user.email)
|
||||||
|
print("[Email Fallback] Subject:", subject)
|
||||||
|
print("[Email Fallback] Body:\n", body)
|
||||||
|
|
||||||
|
|
||||||
async def get_user_manager(user_db: SQLAlchemyUserDatabase = Depends(get_user_db)):
|
async def get_user_manager(user_db: SQLAlchemyUserDatabase = Depends(get_user_db)):
|
||||||
@@ -53,3 +76,4 @@ auth_backend = AuthenticationBackend(
|
|||||||
fastapi_users = FastAPIUsers[User, uuid.UUID](get_user_manager, [auth_backend])
|
fastapi_users = FastAPIUsers[User, uuid.UUID](get_user_manager, [auth_backend])
|
||||||
|
|
||||||
current_active_user = fastapi_users.current_user(active=True)
|
current_active_user = fastapi_users.current_user(active=True)
|
||||||
|
current_active_verified_user = fastapi_users.current_user(active=True, verified=True)
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
aio-pika==9.5.6
|
||||||
|
aiormq==6.8.1
|
||||||
|
aiosqlite==0.21.0
|
||||||
annotated-types==0.7.0
|
annotated-types==0.7.0
|
||||||
anyio==4.11.0
|
anyio==4.11.0
|
||||||
argon2-cffi==23.1.0
|
argon2-cffi==23.1.0
|
||||||
@@ -18,6 +21,9 @@ h11==0.16.0
|
|||||||
httptools==0.6.4
|
httptools==0.6.4
|
||||||
idna==3.10
|
idna==3.10
|
||||||
makefun==1.16.0
|
makefun==1.16.0
|
||||||
|
multidict==6.6.4
|
||||||
|
pamqp==3.3.0
|
||||||
|
propcache==0.3.2
|
||||||
pwdlib==0.2.1
|
pwdlib==0.2.1
|
||||||
pycparser==2.23
|
pycparser==2.23
|
||||||
pydantic==2.11.9
|
pydantic==2.11.9
|
||||||
@@ -35,3 +41,4 @@ uvicorn==0.37.0
|
|||||||
uvloop==0.21.0
|
uvloop==0.21.0
|
||||||
watchfiles==1.1.0
|
watchfiles==1.1.0
|
||||||
websockets==15.0.1
|
websockets==15.0.1
|
||||||
|
yarl==1.20.1
|
||||||
0
backend/worker/__init__.py
Normal file
0
backend/worker/__init__.py
Normal file
57
backend/worker/email_worker.py
Normal file
57
backend/worker/email_worker.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
|
||||||
|
RABBITMQ_URL = os.getenv("RABBITMQ_URL") or (
|
||||||
|
f"amqp://{os.getenv('RABBITMQ_USERNAME', 'user')}:"
|
||||||
|
f"{os.getenv('RABBITMQ_PASSWORD', 'bitnami123')}@"
|
||||||
|
f"{os.getenv('RABBITMQ_HOST', 'localhost')}:"
|
||||||
|
f"{os.getenv('RABBITMQ_PORT', '5672')}"
|
||||||
|
)
|
||||||
|
QUEUE_NAME = os.getenv("MAIL_QUEUE", "mail_queue")
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_message(message_body: bytes) -> None:
|
||||||
|
try:
|
||||||
|
data: Dict[str, Any] = json.loads(message_body.decode("utf-8"))
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[email_worker] Failed to decode message: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
if data.get("type") != "email":
|
||||||
|
print(f"[email_worker] Unknown message type: {data}")
|
||||||
|
return
|
||||||
|
|
||||||
|
to = data.get("to")
|
||||||
|
subject = data.get("subject")
|
||||||
|
body = data.get("body")
|
||||||
|
if not (to and subject and body):
|
||||||
|
print(f"[email_worker] Incomplete email message: {data}")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
await send_email(to=to, subject=subject, body=body)
|
||||||
|
print(f"[email_worker] Sent email to {to}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[email_worker] Error sending email to {to}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
import aio_pika
|
||||||
|
|
||||||
|
print(f"[email_worker] Connecting to RabbitMQ at {RABBITMQ_URL}")
|
||||||
|
connection = await aio_pika.connect_robust(RABBITMQ_URL)
|
||||||
|
channel = await connection.channel()
|
||||||
|
queue = await channel.declare_queue(QUEUE_NAME, durable=True)
|
||||||
|
print(f"[email_worker] Waiting for messages in queue '{QUEUE_NAME}' ...")
|
||||||
|
|
||||||
|
async with queue.iterator() as queue_iter:
|
||||||
|
async for message in queue_iter:
|
||||||
|
async with message.process(requeue=False):
|
||||||
|
await handle_message(message.body)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
FROM httpd
|
|
||||||
RUN echo "<html><body><h1>Current Skibidi Date and Skibidi Time</h1><p>$(date)</p></body></html>" > /usr/local/apache2/htdocs/index.html
|
|
||||||
19
deployment/app-demo-database-grant.yaml
Normal file
19
deployment/app-demo-database-grant.yaml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
apiVersion: k8s.mariadb.com/v1alpha1
|
||||||
|
kind: Grant
|
||||||
|
metadata:
|
||||||
|
name: grant
|
||||||
|
spec:
|
||||||
|
mariaDbRef:
|
||||||
|
name: mariadb
|
||||||
|
privileges:
|
||||||
|
- "ALL PRIVILEGES"
|
||||||
|
database: "app-demo-database"
|
||||||
|
table: "*"
|
||||||
|
username: "app-demo-user"
|
||||||
|
grantOption: true
|
||||||
|
host: "%"
|
||||||
|
# Delete the resource in the database whenever the CR gets deleted.
|
||||||
|
# Alternatively, you can specify Skip in order to omit deletion.
|
||||||
|
cleanupPolicy: Skip
|
||||||
|
requeueInterval: 10h
|
||||||
|
retryInterval: 30s
|
||||||
7
deployment/app-demo-database-secret.yaml
Normal file
7
deployment/app-demo-database-secret.yaml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: app-demo-database-secret
|
||||||
|
type: kubernetes.io/basic-auth
|
||||||
|
stringData:
|
||||||
|
password: "strongpassword"
|
||||||
19
deployment/app-demo-database-user.yaml
Normal file
19
deployment/app-demo-database-user.yaml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
apiVersion: k8s.mariadb.com/v1alpha1
|
||||||
|
kind: User
|
||||||
|
metadata:
|
||||||
|
name: app-demo-user
|
||||||
|
spec:
|
||||||
|
# If you want the user to be created with a different name than the resource name
|
||||||
|
# name: user-custom
|
||||||
|
mariaDbRef:
|
||||||
|
name: mariadb
|
||||||
|
passwordSecretKeyRef:
|
||||||
|
name: app-demo-database-secret
|
||||||
|
key: password
|
||||||
|
maxUserConnections: 20
|
||||||
|
host: "%"
|
||||||
|
# Delete the resource in the database whenever the CR gets deleted.
|
||||||
|
# Alternatively, you can specify Skip in order to omit deletion.
|
||||||
|
cleanupPolicy: Skip
|
||||||
|
requeueInterval: 10h
|
||||||
|
retryInterval: 30s
|
||||||
14
deployment/app-demo-database.yaml
Normal file
14
deployment/app-demo-database.yaml
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
apiVersion: k8s.mariadb.com/v1alpha1
|
||||||
|
kind: Database
|
||||||
|
metadata:
|
||||||
|
name: app-demo-database
|
||||||
|
spec:
|
||||||
|
mariaDbRef:
|
||||||
|
name: mariadb
|
||||||
|
characterSet: utf8
|
||||||
|
collate: utf8_general_ci
|
||||||
|
# Delete the resource in the database whenever the CR gets deleted.
|
||||||
|
# Alternatively, you can specify Skip in order to omit deletion.
|
||||||
|
cleanupPolicy: Skip
|
||||||
|
requeueInterval: 10h
|
||||||
|
retryInterval: 30s
|
||||||
@@ -1,34 +1,37 @@
|
|||||||
apiVersion: apps/v1
|
apiVersion: apps/v1
|
||||||
kind: Deployment
|
kind: Deployment
|
||||||
metadata:
|
metadata:
|
||||||
name: guestbook-ui
|
name: app-demo
|
||||||
spec:
|
spec:
|
||||||
replicas: 2
|
replicas: 2
|
||||||
revisionHistoryLimit: 3
|
revisionHistoryLimit: 3
|
||||||
selector:
|
selector:
|
||||||
matchLabels:
|
matchLabels:
|
||||||
app: guestbook-ui
|
app: app-demo
|
||||||
template:
|
template:
|
||||||
metadata:
|
metadata:
|
||||||
labels:
|
labels:
|
||||||
app: guestbook-ui
|
app: app-demo
|
||||||
spec:
|
spec:
|
||||||
containers:
|
containers:
|
||||||
- image: lukastrkan/cd-test@sha256:10590db789cef5f1a58bb603cce0b502ce2b4054af956d9b71d60e3f02045894
|
- image: lukastrkan/cc-app-demo@sha256:10590db789cef5f1a58bb603cce0b502ce2b4054af956d9b71d60e3f02045894
|
||||||
name: guestbook-ui
|
name: app-demo
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 80
|
- containerPort: 8000
|
||||||
livenessProbe:
|
livenessProbe:
|
||||||
httpGet:
|
httpGet:
|
||||||
path: /
|
path: /
|
||||||
port: 80
|
port: 8000
|
||||||
initialDelaySeconds: 60
|
initialDelaySeconds: 60
|
||||||
periodSeconds: 10
|
periodSeconds: 10
|
||||||
failureThreshold: 3
|
failureThreshold: 3
|
||||||
readinessProbe:
|
readinessProbe:
|
||||||
httpGet:
|
httpGet:
|
||||||
path: /
|
path: /
|
||||||
port: 80
|
port: 8000
|
||||||
initialDelaySeconds: 60
|
initialDelaySeconds: 60
|
||||||
periodSeconds: 10
|
periodSeconds: 10
|
||||||
failureThreshold: 3
|
failureThreshold: 3
|
||||||
|
- image: lukastrkan/cc-app-demo
|
||||||
|
name: app-demo-worker
|
||||||
|
command: [ "python3", "worker/email_worker.py" ]
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: Service
|
kind: Service
|
||||||
metadata:
|
metadata:
|
||||||
name: guestbook-ui
|
name: app-demo
|
||||||
spec:
|
spec:
|
||||||
ports:
|
ports:
|
||||||
- port: 80
|
- port: 80
|
||||||
targetPort: 80
|
targetPort: 8000
|
||||||
selector:
|
selector:
|
||||||
app: guestbook-ui
|
app: app-demo
|
||||||
@@ -6,8 +6,8 @@ metadata:
|
|||||||
subjects:
|
subjects:
|
||||||
- name: app-server
|
- name: app-server
|
||||||
spec:
|
spec:
|
||||||
target: http://guestbook-ui.group-project.svc.cluster.local
|
target: http://app-demo.group-project.svc.cluster.local
|
||||||
fqdn: guestbook.ltrk.cz
|
fqdn: demo.ltrk.cz
|
||||||
noTlsVerify: true
|
noTlsVerify: true
|
||||||
tunnelRef:
|
tunnelRef:
|
||||||
kind: ClusterTunnel
|
kind: ClusterTunnel
|
||||||
Reference in New Issue
Block a user