diff --git a/Dockerfile b/Dockerfile index aafa859..396a672 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,6 @@ -FROM python:3.11.7-slim-bookworm +FROM python:3.11.7-alpine WORKDIR /app COPY api . +RUN apk add --no-cache curl RUN pip install -r requirements.txt CMD python3 app.py diff --git a/api/app.py b/api/app.py index c4c08bd..051bce1 100644 --- a/api/app.py +++ b/api/app.py @@ -4,7 +4,8 @@ from flask_jwt_extended import JWTManager from jwt import ExpiredSignatureError from models import db, RevokedToken import os -from utils import init_db +from tech_views import tech_bp +from utils import init_db, wait_for_db from views import user_bp from werkzeug.exceptions import HTTPException @@ -26,6 +27,7 @@ def create_app(config_name="default"): # Blueprints registration app.register_blueprint(user_bp) + app.register_blueprint(tech_bp) # Database and JWT initialization db.init_app(app) @@ -53,6 +55,7 @@ def create_app(config_name="default"): # Fill database by initial values (only if we are not testing) with app.app_context(): + wait_for_db(max_retries=100) db.create_all() if config_name != "testing": init_db() diff --git a/api/tech_views.py b/api/tech_views.py new file mode 100644 index 0000000..0f2271c --- /dev/null +++ b/api/tech_views.py @@ -0,0 +1,20 @@ +from flask import Blueprint, jsonify +from models import db +from sqlalchemy import text +from utils import db_ready + +# Blueprint with technical endpoints +tech_bp = Blueprint('tech_bp', __name__) + +@tech_bp.route('/health', methods=['GET']) +def health_check(): + "Check if service works and database is functional" + try: + with db.engine.connect() as connection: + connection.execute(text("SELECT 1")) + return jsonify(status="healthy"), 200 + except Exception: + if db_ready: + return jsonify(status="unhealthy"), 500 + else: + return jsonify(status="starting"), 503 \ No newline at end of file diff --git a/api/utils.py b/api/utils.py index beffffb..2f5d69d 100644 --- a/api/utils.py +++ b/api/utils.py @@ -2,17 +2,22 @@ from flask import abort from flask_jwt_extended import get_jwt_identity from models import User, db import os +from sqlalchemy import text +from sqlalchemy.exc import DatabaseError, InterfaceError +import time from werkzeug.security import generate_password_hash +db_ready = False def admin_required(user_id, message='Access denied.'): + "Check if common user try to make administrative action." user = db.session.get(User, user_id) if user is None or user.role != "Administrator": abort(403, message) def validate_access(owner_id, message='Access denied.'): - # Check if user try to access or edit resource that does not belong to them + "Check if user try to access or edit resource that does not belong to them." logged_user_id = int(get_jwt_identity()) logged_user_role = db.session.get(User, logged_user_id).role if logged_user_role != "Administrator" and logged_user_id != owner_id: @@ -27,6 +32,20 @@ def get_user_or_404(user_id): return user +def wait_for_db(max_retries): + "Try to connect with database times." + global db_ready + for _ in range(max_retries): + try: + with db.engine.connect() as connection: + connection.execute(text("SELECT 1")) + db_ready = True + return + except DatabaseError | InterfaceError: + time.sleep(3) + raise Exception("Failed to connect to database.") + + def init_db(): """Create default admin account if database is empty""" with db.session.begin(): diff --git a/docker-compose.yml b/docker-compose.yml index 4717f40..dec3e28 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,9 +7,24 @@ services: build: . env_file: - api/.env + ports: + - 80:80 + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost/health"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 15s db: container_name: db hostname: db image: mysql:latest env_file: - db/.env + ports: + - 3306:3306 + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] + interval: 10s + timeout: 5s + retries: 5