diff --git a/.gitea/workflows/cicd.yaml b/.gitea/workflows/cicd.yaml index 5c9d88b..65e06ee 100644 --- a/.gitea/workflows/cicd.yaml +++ b/.gitea/workflows/cicd.yaml @@ -1,20 +1,67 @@ name: Build and Deploy Demo App + on: push: branches: - main + jobs: - build-and-deploy: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Install deps + run: pip install -r requirements.txt + - name: Run tests + run: pytest tests.py + + build: + needs: test runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 + - name: Cache Docker layers + uses: actions/cache@v4 + with: + path: /tmp/.buildx-cache + key: ${{ runner.os }}-buildx-${{ hashFiles('Dockerfile', 'requirements.txt') }} + restore-keys: ${{ runner.os }}-buildx- - name: Build Docker image - run: docker build -t demo-app:latest . + uses: docker/build-push-action@v6 + with: + load: true + tags: demo-app:latest + cache-from: type=local,src=/tmp/.buildx-cache + cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max - name: Save Docker image to tar run: docker save demo-app:latest > demo-app.tar + + scan: + needs: build + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Scan Image + uses: aquasecurity/trivy-action@master + with: + image-ref: demo-app:latest + format: table + exit-code: 1 + severity: CRITICAL,HIGH + + deploy: + needs: [build, scan] + runs-on: ubuntu-latest + steps: - name: Set up SSH run: | apt update && apt install -y openssh-client @@ -24,41 +71,19 @@ jobs: eval "$(ssh-agent -s)" ssh-add ~/.ssh/id_ed25519 ssh-keyscan -p ${{ secrets.SERVER_PORT }} ${{ secrets.SERVER_HOST }} >> ~/.ssh/known_hosts - - name: Copy files to server via SCP - run: | - scp -o StrictHostKeyChecking=no -P ${{ secrets.SERVER_PORT }} demo-app.tar ${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}:${{ secrets.DEPLOY_PATH }}demo-app.tar - scp -o StrictHostKeyChecking=no -P ${{ secrets.SERVER_PORT }} docker-compose.yml ${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}:${{ secrets.DEPLOY_PATH }}docker-compose.yml - scp -o StrictHostKeyChecking=no -P ${{ secrets.SERVER_PORT }} -r nginx_user_conf.d ${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}:${{ secrets.DEPLOY_PATH }}nginx_user_conf.d - - name: Deploy on server via SSH + - name: Copy tar to server + run: scp -o StrictHostKeyChecking=no -P ${{ secrets.SERVER_PORT }} demo-app.tar ${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}:${{ secrets.DEPLOY_PATH }}demo-app.tar + - name: Deploy on server run: | ssh -o StrictHostKeyChecking=no -p ${{ secrets.SERVER_PORT }} ${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }} << EOF cd ${{ secrets.DEPLOY_PATH }} - - # Check and install Docker if not present (Ubuntu/Debian assumed) - if ! command -v docker &> /dev/null; then - sudo apt update -y - sudo apt install -y ca-certificates curl - sudo install -m 0755 -d /etc/apt/keyrings - sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc - sudo chmod a+r /etc/apt/keyrings/docker.asc - echo "deb [arch=\$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \$(. /etc/os-release && echo "\$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null - sudo apt update -y - sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin - sudo systemctl start docker - sudo systemctl enable docker - fi - - # Ensure docker-compose-plugin is installed (for 'docker compose' command) - if ! docker compose version &> /dev/null; then - sudo apt update -y - sudo apt install -y docker-compose-plugin - fi - - # Load image and deploy with Compose + # Create/update .env with secrets + echo "DB_USER=${{ secrets.DB_USER }}" > .env + echo "DB_PASS=${{ secrets.DB_PASS }}" >> .env + echo "DB_NAME=${{ secrets.DB_NAME }}" >> .env + # Load and deploy docker load -i demo-app.tar - docker compose down --remove-orphans -v || true # Graceful stop - docker compose up -d --force-recreate - - # Cleanup + docker compose down + docker compose --env-file .env up -d --remove-orphans rm demo-app.tar EOF diff --git a/Dockerfile b/Dockerfile index e090deb..2774f77 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,19 @@ -FROM python:3.12-slim - +# Build stage +FROM python:3.12-slim AS builder WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt -COPY app.py . + +# Runtime stage +FROM python:3.12-slim +WORKDIR /app +COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages +COPY app.py models.py . COPY templates ./templates + +RUN useradd -m appuser +USER appuser + EXPOSE 5000 -CMD ["python", "app.py"] +HEALTHCHECK --interval=30s --timeout=3s CMD wget --no-verbose --tries=1 --spider http://localhost:5000/ || exit 1 +CMD ["gunicorn", "--bind", "0.0.0.0:5000", "app:app"] diff --git a/app.py b/app.py index 8cbfaca..8d095d4 100644 --- a/app.py +++ b/app.py @@ -1,12 +1,21 @@ +import os from flask import Flask, render_template -from flask_bootstrap import Bootstrap5 # Updated import for Bootstrap 5 support +from flask_bootstrap import Bootstrap5 +from models import db, Feature app = Flask(__name__) +db_user = os.getenv('DB_USER', 'postgres') +db_pass = os.getenv('DB_PASS', 'password') # Fallback; overridden by env +db_name = os.getenv('DB_NAME', 'demo_db') +app.config['SQLALCHEMY_DATABASE_URI'] = f'postgresql://{db_user}:{db_pass}@db:5432/{db_name}' +app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False + bootstrap = Bootstrap5(app) +db.init_app(app) @app.route('/') def index(): - return render_template('index.html') - -if __name__ == '__main__': - app.run(host='0.0.0.0', port=5000) + with app.app_context(): + Feature.seed_db() # Seed on first load if empty + features = Feature.query.all() + return render_template('index.html', features=features) diff --git a/docker-compose.yml b/docker-compose.yml index d0706db..fe3b8e9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,8 +1,37 @@ services: + db: + image: postgres:16-alpine + container_name: demo-db + restart: unless-stopped + environment: + POSTGRES_USER: ${DB_USER} + POSTGRES_PASSWORD: ${DB_PASS} + POSTGRES_DB: ${DB_NAME} + volumes: + - db-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USER}"] + interval: 10s + timeout: 5s + retries: 5 + app: image: demo-app:latest container_name: demo-app restart: unless-stopped + environment: + - DB_USER=${DB_USER} + - DB_PASS=${DB_PASS} + - DB_NAME=${DB_NAME} + depends_on: + db: + condition: service_healthy + healthcheck: + test: ["CMD", "wget", "--spider", "http://localhost:5000"] + interval: 30s + timeout: 10s + retries: 3 + nginx: image: jonasal/nginx-certbot:latest container_name: demo-nginx @@ -12,14 +41,25 @@ services: - 4433:443 environment: - CERTBOT_EMAIL=the.dark.mist23@gmail.com - - ENVSUBST_TEMPLATE_SUFFIX=.tmpl # Enables template processing if needed - - CERTBOT_DISABLED=true # Disable auto Certbot to use manual certs + - ENVSUBST_TEMPLATE_SUFFIX=.tmpl + - CERTBOT_DISABLED=true volumes: - ./nginx_user_conf.d:/etc/nginx/conf.d/ - letsencrypt:/etc/letsencrypt - - /home/devroot/demo/certs/fullchain.pem:/etc/nginx/ssl/origin_cert.pem:ro # Mount cert - - /home/devroot/demo/certs/prvkey.pem:/etc/nginx/ssl/origin_key.key:ro # Mount key + - /home/devroot/demo/certs/fullchain.pem:/etc/nginx/ssl/origin_cert.pem:ro + - /home/devroot/demo/certs/prvkey.pem:/etc/nginx/ssl/origin_key.key:ro depends_on: - - app + app: + condition: service_healthy + logging: + driver: json-file + options: + max-size: 10m + +networks: + default: + driver: bridge + volumes: letsencrypt: + db-data: diff --git a/models.py b/models.py new file mode 100644 index 0000000..78d217c --- /dev/null +++ b/models.py @@ -0,0 +1,18 @@ +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() + +class Feature(db.Model): + id = db.Column(db.Integer, primary_key=True) + title = db.Column(db.String(100), nullable=False) + description = db.Column(db.String(200), nullable=False) + +def seed_db(): + if Feature.query.count() == 0: # Seed only if empty + features = [ + Feature(title="Responsive Design", description="Adapts seamlessly to mobile, tablet, and desktop devices."), + Feature(title="Modern UI", description="Uses Bootstrap 5 for clean, professional styling."), + Feature(title="Easy Deployment", description="Containerized with Docker for quick setup on any server.") + ] + db.session.bulk_save_objects(features) + db.session.commit() diff --git a/nginx_user_conf.d/server.conf b/nginx_user_conf.d/server.conf index 3e7da65..155427f 100644 --- a/nginx_user_conf.d/server.conf +++ b/nginx_user_conf.d/server.conf @@ -1,8 +1,6 @@ server { listen 80; server_name demo.networkwizard.xyz; - - # Optional: Redirect HTTP to HTTPS (adjust port if needed) location / { return 301 https://$host:4433$request_uri; } @@ -11,11 +9,8 @@ server { server { listen 443 ssl; server_name demo.networkwizard.xyz; - ssl_certificate /etc/nginx/ssl/origin_cert.pem; ssl_certificate_key /etc/nginx/ssl/origin_key.key; - - # Optional: Enhance security ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers HIGH:!aNULL:!MD5; @@ -26,4 +21,9 @@ server { proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } + + error_page 500 502 503 504 /50x.html; + location = /50x.html { + root /usr/share/nginx/html; + } } diff --git a/requirements.txt b/requirements.txt index 660fe77..e5eef44 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,6 @@ flask==3.0.3 bootstrap-flask==2.5.0 +gunicorn==22.0.0 +flask-sqlalchemy==3.1.1 +psycopg2-binary==2.9.9 # Postgres driver +pytest==8.3.3 # For tests diff --git a/templates/index.html b/templates/index.html index aaa5dfb..2df4733 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1,18 +1,11 @@ - - Professional Demo Site - - {{ bootstrap.load_css() }} - - + -
-

Welcome to the Professional Demo Site

-

This is a realistic, Bootstrap-powered Flask application for demonstration purposes. It features responsive design and modern UI elements.

+

This is a realistic, Bootstrap-powered Flask application with DB integration for demonstration.

Learn More
- -
-

Key Features

+

Key Features (from DB)

+ {% for feature in features %}
-
Responsive Design
-

Adapts seamlessly to mobile, tablet, and desktop devices.

-
-
-
-
-
-
-
Modern UI
-

Uses Bootstrap 5 for clean, professional styling.

-
-
-
-
-
-
-
Easy Deployment
-

Containerized with Docker for quick setup on any server.

+
{{ feature.title }}
+

{{ feature.description }}

+ {% endfor %}
- -

About This Demo

-

This site serves as a starting point for building more complex web applications. Extend it with databases, APIs, or user authentication as needed.

+

Now with PostgreSQL for data persistence. Extend with more models/queries.

- - {{ bootstrap.load_js() }} diff --git a/test.py b/test.py new file mode 100644 index 0000000..42475ea --- /dev/null +++ b/test.py @@ -0,0 +1,24 @@ +import pytest +from app import app, db +from models import Feature + +@pytest.fixture +def client(): + app.config['TESTING'] = True + app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///:memory:' # In-memory DB for tests + with app.test_client() as client: + with app.app_context(): + db.create_all() + yield client + db.drop_all() + +def test_index(client): + response = client.get('/') + assert response.status_code == 200 + assert b'Professional Demo Site' in response.data + +def test_seed_db(client): + with app.app_context(): + db.create_all() + Feature.seed_db() + assert Feature.query.count() == 3