Compare commits
10 Commits
c7f02fcdd6
...
00c5d0bc4b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00c5d0bc4b | ||
|
|
b4f997b15c | ||
|
|
5ec8796ae4 | ||
|
|
a1c4bfb693 | ||
|
|
18a20c77a7 | ||
|
|
0706172e5e | ||
|
|
0fa18ecf4f | ||
|
|
827049956a | ||
|
|
382f8fb1f1 | ||
|
|
ce0f1b0921 |
33
.dockerignore
Normal file
33
.dockerignore
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# Git / repo metadata
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.gitlab-ci.yml
|
||||||
|
|
||||||
|
# Local Python artifacts
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
.pytest_cache/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
|
||||||
|
# Local virtual environments
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
|
||||||
|
# Local databases
|
||||||
|
database_file/*.db
|
||||||
|
|
||||||
|
# Dev/test files not needed at runtime
|
||||||
|
tests/
|
||||||
|
compose.yml
|
||||||
|
manifests/
|
||||||
|
|
||||||
|
# Local/editor/system files
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1 +1,3 @@
|
|||||||
*.pyc
|
*.pyc
|
||||||
|
.venv
|
||||||
|
.coverage
|
||||||
@@ -1,17 +1,15 @@
|
|||||||
stages:
|
stages:
|
||||||
- run
|
- test
|
||||||
- build
|
- build
|
||||||
- deploy
|
- deploy
|
||||||
|
|
||||||
run_flask_app:
|
test:
|
||||||
stage: run
|
stage: test
|
||||||
image: python:3.11
|
image: python:3.11
|
||||||
before_script:
|
before_script:
|
||||||
- pip install -r requirements.txt
|
- pip install -r requirements.txt
|
||||||
script:
|
script:
|
||||||
- python app.py &
|
- PYTHONPATH=. pytest --cov=. --cov-report=term-missing
|
||||||
- sleep 5
|
|
||||||
- curl http://127.0.0.1:5000
|
|
||||||
|
|
||||||
build_docker_image:
|
build_docker_image:
|
||||||
stage: build
|
stage: build
|
||||||
@@ -46,3 +44,24 @@ deploy_stage:
|
|||||||
name: stage
|
name: stage
|
||||||
rules:
|
rules:
|
||||||
- if: '$CI_COMMIT_BRANCH == "main"'
|
- if: '$CI_COMMIT_BRANCH == "main"'
|
||||||
|
|
||||||
|
deploy_production:
|
||||||
|
stage: deploy
|
||||||
|
tags:
|
||||||
|
- manifests-runner
|
||||||
|
image:
|
||||||
|
name: bitnami/kubectl:latest
|
||||||
|
entrypoint: [""]
|
||||||
|
before_script:
|
||||||
|
- mkdir -p ~/.kube
|
||||||
|
- echo "$KUBECONFIGCONTENT" > ~/.kube/config
|
||||||
|
- chmod 600 ~/.kube/config
|
||||||
|
script:
|
||||||
|
- kubectl apply -f manifests/prod/
|
||||||
|
- kubectl set image deployment/flask-app flask-app=$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA -n prod
|
||||||
|
- kubectl rollout status deployment/flask-app -n prod
|
||||||
|
environment:
|
||||||
|
name: production
|
||||||
|
rules:
|
||||||
|
- if: '$CI_COMMIT_BRANCH == "main"'
|
||||||
|
when: manual
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM python:3.11-slim
|
FROM python:3.11-slim
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
177
README.md
177
README.md
@@ -1,60 +1,183 @@
|
|||||||
# flask-example
|
# Flask DevOps Reference Application
|
||||||
|
|
||||||
A minimal web app developed with [Flask](http://flask.pocoo.org/) framework.
|
This repository contains a modified Flask application used as a reference implementation for a DevOps setup for Auby & Brinch Finance.
|
||||||
|
|
||||||
The main purpose is to introduce how to implement the essential elements in web application with Flask, including
|
The project demonstrates how a small development team can move from manual releases to a more controlled workflow using GitLab CI/CD, Docker, automated
|
||||||
|
testing, container registry and Kubernetes deployments to separate staging and production environments.
|
||||||
|
|
||||||
- URL Building
|
## Project Context
|
||||||
|
|
||||||
- Authentication with Sessions
|
Auby & Brinch Finance currently has a small software team with limited DevOps experience. Their internal web application is important for daily business
|
||||||
|
operations, and the company plans to grow the development team.
|
||||||
|
|
||||||
- Template & Template Inheritance
|
The purpose of this repository is to demonstrate a suggested technical setup that supports:
|
||||||
|
|
||||||
- Error Handling
|
- Automated testing with coverage
|
||||||
|
- Containerized application builds
|
||||||
|
- GitLab Container Registry
|
||||||
|
- Automated deployment to staging
|
||||||
|
- Manual approval before production deployment
|
||||||
|
- Separate Kubernetes namespaces for staging and production
|
||||||
|
- SQLite for local development
|
||||||
|
- PostgreSQL for staging and production
|
||||||
|
- Gunicorn for containerized runtime
|
||||||
|
|
||||||
- Integrating with *Bootstrap*
|
## Repository Structure
|
||||||
|
|
||||||
- Interaction with Database (SQLite)
|
```text
|
||||||
|
.
|
||||||
|
├── app.py # Flask application routes
|
||||||
|
├── database.py # SQLite/PostgreSQL database access
|
||||||
|
├── config.py # Flask configuration
|
||||||
|
├── requirements.txt # Python dependencies
|
||||||
|
├── Dockerfile # Container image definition
|
||||||
|
├── compose.yml # Local Docker Compose setup
|
||||||
|
├── .gitlab-ci.yml # GitLab CI/CD pipeline
|
||||||
|
├── .dockerignore # Files excluded from Docker build context
|
||||||
|
├── tests/ # Pytest test suite
|
||||||
|
├── manifests/
|
||||||
|
│ ├── stage/ # Kubernetes manifests for staging
|
||||||
|
│ └── prod/ # Kubernetes manifests for production
|
||||||
|
├── templates/ # Flask HTML templates
|
||||||
|
├── static/ # Static assets
|
||||||
|
├── image_pool/ # Uploaded/example images
|
||||||
|
└── database_file/ # Local SQLite databases
|
||||||
|
```
|
||||||
|
|
||||||
- Invoking static resources
|
## Local Development
|
||||||
|
|
||||||
For more basic knowledge of Flask, you can refer to [a tutorial on Tutorialspoint](https://www.tutorialspoint.com/flask/).
|
The application uses SQLite by default when DATABASE_URL is not set.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
python app.py
|
||||||
|
```
|
||||||
|
|
||||||
## How to Run
|
The application starts on:
|
||||||
|
|
||||||
- Step 1: Make sure you have Python
|
http://localhost:5000
|
||||||
|
|
||||||
- Step 2: Install the requirements: `pip install -r requirements.txt`
|
Example login:
|
||||||
|
|
||||||
- Step 3: Go to this app's directory and run `python app.py`
|
```text
|
||||||
|
Username: admin
|
||||||
|
Password: admin
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running Tests
|
||||||
|
|
||||||
|
Run the automated test suite:
|
||||||
|
|
||||||
## Details about This Toy App
|
```bash
|
||||||
|
PYTHONPATH=. pytest -q
|
||||||
|
```
|
||||||
|
|
||||||
There are three tabs in this toy app
|
Run tests with coverage reporting:
|
||||||
|
|
||||||
- **Public**: this is a page which can be accessed by anyone, no matter if the user has logged in or not.
|
```bash
|
||||||
|
PYTHONPATH=. pytest --cov=. --cov-report=term-missing
|
||||||
|
```
|
||||||
|
|
||||||
- **Private**: Only logged-in user can access this page. Otherwise the user will get a 401 error page.
|
At the time of writing, the test suite covers basic routing, authentication behavior and selected authenticated actions, reaching approximately 69% coverage.
|
||||||
|
|
||||||
- **Admin Page**: This part is only open to the user who logged in as "Admin". In this tab, the administrator can manage accounts (list, delete, or add).
|
## Docker
|
||||||
|
|
||||||
|
Build the application image locally:
|
||||||
|
|
||||||
A few accounts were set for testing, like ***admin*** (password: admin), ***test*** (password: 123456), etc. You can also delete or add accounts after you log in as ***admin***.
|
```bash
|
||||||
|
docker build -t flask-devops-app .
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the image locally:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm -p 5000:5000 flask-devops-app
|
||||||
|
```
|
||||||
|
|
||||||
## References
|
The container uses Gunicorn:
|
||||||
|
|
||||||
- http://flask.pocoo.org/
|
```bash
|
||||||
|
gunicorn -b 0.0.0.0:5000 app:app
|
||||||
|
```
|
||||||
|
|
||||||
- https://www.tutorialspoint.com/flask/
|
## Docker Compose
|
||||||
|
|
||||||
|
A local Compose setup is included:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
## Credict
|
The Compose file starts:
|
||||||
Image private.jpg: https://commons.wikimedia.org/wiki/File:(315-365)_Locked_(6149414678).jpg
|
|
||||||
|
|
||||||
Image public.jpg: https://commons.wikimedia.org/wiki/File:Drown%3F!_(131380682).jpg
|
- Flask application
|
||||||
|
- PostgreSQL database
|
||||||
|
|
||||||
|
When DATABASE_URL is set, the application uses PostgreSQL. Otherwise it uses the local SQLite database files.
|
||||||
|
|
||||||
|
## CI/CD Pipeline
|
||||||
|
|
||||||
|
The GitLab pipeline is defined in .gitlab-ci.yml.
|
||||||
|
|
||||||
|
Pipeline stages:
|
||||||
|
|
||||||
|
```text
|
||||||
|
test -> build -> deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
The pipeline:
|
||||||
|
|
||||||
|
1. Runs pytest with coverage.
|
||||||
|
2. Builds a Docker image with Kaniko.
|
||||||
|
3. Pushes the image to GitLab Container Registry.
|
||||||
|
4. Deploys automatically to staging when changes are merged to main.
|
||||||
|
5. Provides a manual production deployment job.
|
||||||
|
|
||||||
|
## Kubernetes Deployment
|
||||||
|
|
||||||
|
Kubernetes manifests are stored in:
|
||||||
|
|
||||||
|
```text
|
||||||
|
manifests/stage/
|
||||||
|
manifests/prod/
|
||||||
|
```
|
||||||
|
|
||||||
|
The setup uses separate namespaces:
|
||||||
|
|
||||||
|
```text
|
||||||
|
stage
|
||||||
|
prod
|
||||||
|
```
|
||||||
|
|
||||||
|
Each environment contains manifests for:
|
||||||
|
|
||||||
|
- Flask deployment
|
||||||
|
- Flask service
|
||||||
|
- PostgreSQL deployment
|
||||||
|
- PostgreSQL service
|
||||||
|
- PostgreSQL secret
|
||||||
|
|
||||||
|
The Flask application receives a DATABASE_URL environment variable in staging and production.
|
||||||
|
|
||||||
|
## GitLab Variables
|
||||||
|
|
||||||
|
The pipeline expects the following variables:
|
||||||
|
|
||||||
|
```text
|
||||||
|
KUBECONFIGCONTENT
|
||||||
|
CI_REGISTRY
|
||||||
|
CI_REGISTRY_USER
|
||||||
|
CI_REGISTRY_PASSWORD
|
||||||
|
CI_REGISTRY_IMAGE
|
||||||
|
CI_COMMIT_SHORT_SHA
|
||||||
|
```
|
||||||
|
|
||||||
|
Most CI_* variables are provided by GitLab automatically. KUBECONFIGCONTENT must be configured in GitLab CI/CD variables and should be protected.
|
||||||
|
|
||||||
|
## Operational Notes
|
||||||
|
|
||||||
|
This reference setup demonstrates the delivery workflow. In a production rollout,
|
||||||
|
PostgreSQL should use persistent storage, secrets should be managed outside the
|
||||||
|
repository, and Kubernetes health checks/resource limits should be added.
|
||||||
|
|||||||
221
database.py
221
database.py
@@ -1,161 +1,272 @@
|
|||||||
|
import os
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import hashlib
|
import hashlib
|
||||||
import datetime
|
import datetime
|
||||||
|
import psycopg2
|
||||||
|
|
||||||
user_db_file_location = "database_file/users.db"
|
user_db_file_location = "database_file/users.db"
|
||||||
note_db_file_location = "database_file/notes.db"
|
note_db_file_location = "database_file/notes.db"
|
||||||
image_db_file_location = "database_file/images.db"
|
image_db_file_location = "database_file/images.db"
|
||||||
|
|
||||||
|
_schema_initialized = False
|
||||||
|
|
||||||
|
|
||||||
|
def use_postgres():
|
||||||
|
return os.getenv("DATABASE_URL") is not None
|
||||||
|
|
||||||
|
|
||||||
|
def placeholder():
|
||||||
|
return "%s" if use_postgres() else "?"
|
||||||
|
|
||||||
|
|
||||||
|
def get_connection(sqlite_db_file=None):
|
||||||
|
global _schema_initialized
|
||||||
|
|
||||||
|
database_url = os.getenv("DATABASE_URL")
|
||||||
|
|
||||||
|
if use_postgres():
|
||||||
|
conn = psycopg2.connect(database_url)
|
||||||
|
|
||||||
|
if not _schema_initialized:
|
||||||
|
init_postgres_schema(conn)
|
||||||
|
_schema_initialized = True
|
||||||
|
|
||||||
|
return conn
|
||||||
|
|
||||||
|
return sqlite3.connect(sqlite_db_file)
|
||||||
|
|
||||||
|
|
||||||
|
def init_postgres_schema(conn):
|
||||||
|
c = conn.cursor()
|
||||||
|
|
||||||
|
c.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
pw TEXT NOT NULL
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
|
||||||
|
c.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS notes (
|
||||||
|
"user" TEXT NOT NULL,
|
||||||
|
timestamp TEXT NOT NULL,
|
||||||
|
note TEXT NOT NULL,
|
||||||
|
note_id TEXT PRIMARY KEY
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
|
||||||
|
c.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS images (
|
||||||
|
uid TEXT PRIMARY KEY,
|
||||||
|
owner TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
timestamp TEXT NOT NULL
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
c.close()
|
||||||
|
|
||||||
|
|
||||||
def list_users():
|
def list_users():
|
||||||
_conn = sqlite3.connect(user_db_file_location)
|
_conn = get_connection(user_db_file_location)
|
||||||
_c = _conn.cursor()
|
_c = _conn.cursor()
|
||||||
|
|
||||||
_c.execute("SELECT id FROM users;")
|
_c.execute("SELECT id FROM users;")
|
||||||
result = [x[0] for x in _c.fetchall()]
|
result = [x[0] for x in _c.fetchall()]
|
||||||
|
|
||||||
_conn.close()
|
_conn.close()
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def verify(id, pw):
|
def verify(id, pw):
|
||||||
_conn = sqlite3.connect(user_db_file_location)
|
_conn = get_connection(user_db_file_location)
|
||||||
_c = _conn.cursor()
|
_c = _conn.cursor()
|
||||||
|
|
||||||
_c.execute("SELECT pw FROM users WHERE id = '" + id + "';")
|
_c.execute(
|
||||||
result = _c.fetchone()[0] == hashlib.sha256(pw.encode()).hexdigest()
|
f"SELECT pw FROM users WHERE id = {placeholder()};",
|
||||||
|
(id.upper(),)
|
||||||
|
)
|
||||||
|
|
||||||
|
row = _c.fetchone()
|
||||||
_conn.close()
|
_conn.close()
|
||||||
|
|
||||||
return result
|
if row is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return row[0] == hashlib.sha256(pw.encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
def delete_user_from_db(id):
|
def delete_user_from_db(id):
|
||||||
_conn = sqlite3.connect(user_db_file_location)
|
user_id = id.upper()
|
||||||
|
|
||||||
|
_conn = get_connection(user_db_file_location)
|
||||||
_c = _conn.cursor()
|
_c = _conn.cursor()
|
||||||
_c.execute("DELETE FROM users WHERE id = ?;", (id))
|
_c.execute(
|
||||||
|
f"DELETE FROM users WHERE id = {placeholder()};",
|
||||||
|
(user_id,)
|
||||||
|
)
|
||||||
_conn.commit()
|
_conn.commit()
|
||||||
_conn.close()
|
_conn.close()
|
||||||
|
|
||||||
# when we delete a user FROM database USERS, we also need to delete all his or her notes data FROM database NOTES
|
# when we delete a user from USERS, delete all notes owned by the user
|
||||||
_conn = sqlite3.connect(note_db_file_location)
|
_conn = get_connection(note_db_file_location)
|
||||||
_c = _conn.cursor()
|
_c = _conn.cursor()
|
||||||
_c.execute("DELETE FROM notes WHERE user = ?;", (id))
|
_c.execute(
|
||||||
|
f'DELETE FROM notes WHERE "user" = {placeholder()};',
|
||||||
|
(user_id,)
|
||||||
|
)
|
||||||
_conn.commit()
|
_conn.commit()
|
||||||
_conn.close()
|
_conn.close()
|
||||||
|
|
||||||
# when we delete a user FROM database USERS, we also need to
|
# when we delete a user from USERS, delete all image records owned by the user
|
||||||
# [1] delete all his or her images FROM image pool (done in app.py)
|
_conn = get_connection(image_db_file_location)
|
||||||
# [2] delete all his or her images records FROM database IMAGES
|
|
||||||
_conn = sqlite3.connect(image_db_file_location)
|
|
||||||
_c = _conn.cursor()
|
_c = _conn.cursor()
|
||||||
_c.execute("DELETE FROM images WHERE owner = ?;", (id))
|
_c.execute(
|
||||||
|
f"DELETE FROM images WHERE owner = {placeholder()};",
|
||||||
|
(user_id,)
|
||||||
|
)
|
||||||
_conn.commit()
|
_conn.commit()
|
||||||
_conn.close()
|
_conn.close()
|
||||||
|
|
||||||
|
|
||||||
def add_user(id, pw):
|
def add_user(id, pw):
|
||||||
_conn = sqlite3.connect(user_db_file_location)
|
_conn = get_connection(user_db_file_location)
|
||||||
_c = _conn.cursor()
|
_c = _conn.cursor()
|
||||||
|
|
||||||
_c.execute("INSERT INTO users values(?, ?)", (id.upper(), hashlib.sha256(pw.encode()).hexdigest()))
|
_c.execute(
|
||||||
|
f"INSERT INTO users VALUES ({placeholder()}, {placeholder()});",
|
||||||
|
(id.upper(), hashlib.sha256(pw.encode()).hexdigest())
|
||||||
|
)
|
||||||
|
|
||||||
_conn.commit()
|
_conn.commit()
|
||||||
_conn.close()
|
_conn.close()
|
||||||
|
|
||||||
|
|
||||||
def read_note_from_db(id):
|
def read_note_from_db(id):
|
||||||
_conn = sqlite3.connect(note_db_file_location)
|
_conn = get_connection(note_db_file_location)
|
||||||
_c = _conn.cursor()
|
_c = _conn.cursor()
|
||||||
|
|
||||||
command = "SELECT note_id, timestamp, note FROM notes WHERE user = '" + id.upper() + "';"
|
_c.execute(
|
||||||
_c.execute(command)
|
f'SELECT note_id, timestamp, note FROM notes WHERE "user" = {placeholder()};',
|
||||||
|
(id.upper(),)
|
||||||
|
)
|
||||||
result = _c.fetchall()
|
result = _c.fetchall()
|
||||||
|
|
||||||
_conn.commit()
|
|
||||||
_conn.close()
|
_conn.close()
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def match_user_id_with_note_id(note_id):
|
def match_user_id_with_note_id(note_id):
|
||||||
# Given the note id, confirm if the current user is the owner of the note which is being operated.
|
# Given the note id, confirm if the current user is the owner of the note being operated.
|
||||||
_conn = sqlite3.connect(note_db_file_location)
|
_conn = get_connection(note_db_file_location)
|
||||||
_c = _conn.cursor()
|
_c = _conn.cursor()
|
||||||
|
|
||||||
command = "SELECT user FROM notes WHERE note_id = '" + note_id + "';"
|
_c.execute(
|
||||||
_c.execute(command)
|
f'SELECT "user" FROM notes WHERE note_id = {placeholder()};',
|
||||||
result = _c.fetchone()[0]
|
(note_id,)
|
||||||
|
)
|
||||||
|
|
||||||
_conn.commit()
|
row = _c.fetchone()
|
||||||
_conn.close()
|
_conn.close()
|
||||||
|
|
||||||
return result
|
if row is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return row[0]
|
||||||
|
|
||||||
|
|
||||||
def write_note_into_db(id, note_to_write):
|
def write_note_into_db(id, note_to_write):
|
||||||
_conn = sqlite3.connect(note_db_file_location)
|
_conn = get_connection(note_db_file_location)
|
||||||
_c = _conn.cursor()
|
_c = _conn.cursor()
|
||||||
|
|
||||||
current_timestamp = str(datetime.datetime.now())
|
current_timestamp = str(datetime.datetime.now())
|
||||||
_c.execute("INSERT INTO notes values(?, ?, ?, ?)", (id.upper(), current_timestamp, note_to_write, hashlib.sha1((id.upper() + current_timestamp).encode()).hexdigest()))
|
note_id = hashlib.sha1((id.upper() + current_timestamp).encode()).hexdigest()
|
||||||
|
|
||||||
|
_c.execute(
|
||||||
|
f"INSERT INTO notes VALUES ({placeholder()}, {placeholder()}, {placeholder()}, {placeholder()});",
|
||||||
|
(id.upper(), current_timestamp, note_to_write, note_id)
|
||||||
|
)
|
||||||
|
|
||||||
_conn.commit()
|
_conn.commit()
|
||||||
_conn.close()
|
_conn.close()
|
||||||
|
|
||||||
|
|
||||||
def delete_note_from_db(note_id):
|
def delete_note_from_db(note_id):
|
||||||
_conn = sqlite3.connect(note_db_file_location)
|
_conn = get_connection(note_db_file_location)
|
||||||
_c = _conn.cursor()
|
_c = _conn.cursor()
|
||||||
|
|
||||||
_c.execute("DELETE FROM notes WHERE note_id = ?;", (note_id))
|
_c.execute(
|
||||||
|
f"DELETE FROM notes WHERE note_id = {placeholder()};",
|
||||||
|
(note_id,)
|
||||||
|
)
|
||||||
|
|
||||||
_conn.commit()
|
_conn.commit()
|
||||||
_conn.close()
|
_conn.close()
|
||||||
|
|
||||||
|
|
||||||
def image_upload_record(uid, owner, image_name, timestamp):
|
def image_upload_record(uid, owner, image_name, timestamp):
|
||||||
_conn = sqlite3.connect(image_db_file_location)
|
_conn = get_connection(image_db_file_location)
|
||||||
_c = _conn.cursor()
|
_c = _conn.cursor()
|
||||||
|
|
||||||
_c.execute("INSERT INTO images VALUES (?, ?, ?, ?)", (uid, owner, image_name, timestamp))
|
_c.execute(
|
||||||
|
f"INSERT INTO images VALUES ({placeholder()}, {placeholder()}, {placeholder()}, {placeholder()});",
|
||||||
|
(uid, owner, image_name, timestamp)
|
||||||
|
)
|
||||||
|
|
||||||
_conn.commit()
|
_conn.commit()
|
||||||
_conn.close()
|
_conn.close()
|
||||||
|
|
||||||
|
|
||||||
def list_images_for_user(owner):
|
def list_images_for_user(owner):
|
||||||
_conn = sqlite3.connect(image_db_file_location)
|
_conn = get_connection(image_db_file_location)
|
||||||
_c = _conn.cursor()
|
_c = _conn.cursor()
|
||||||
|
|
||||||
command = "SELECT uid, timestamp, name FROM images WHERE owner = '{0}'".format(owner)
|
_c.execute(
|
||||||
_c.execute(command)
|
f"SELECT uid, timestamp, name FROM images WHERE owner = {placeholder()};",
|
||||||
|
(owner,)
|
||||||
|
)
|
||||||
result = _c.fetchall()
|
result = _c.fetchall()
|
||||||
|
|
||||||
_conn.commit()
|
|
||||||
_conn.close()
|
_conn.close()
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def match_user_id_with_image_uid(image_uid):
|
def match_user_id_with_image_uid(image_uid):
|
||||||
# Given the note id, confirm if the current user is the owner of the note which is being operated.
|
# Given the image uid, confirm if the current user is the owner of the image being operated.
|
||||||
_conn = sqlite3.connect(image_db_file_location)
|
_conn = get_connection(image_db_file_location)
|
||||||
_c = _conn.cursor()
|
_c = _conn.cursor()
|
||||||
|
|
||||||
command = "SELECT owner FROM images WHERE uid = '" + image_uid + "';"
|
_c.execute(
|
||||||
_c.execute(command)
|
f"SELECT owner FROM images WHERE uid = {placeholder()};",
|
||||||
result = _c.fetchone()[0]
|
(image_uid,)
|
||||||
|
)
|
||||||
|
|
||||||
_conn.commit()
|
row = _c.fetchone()
|
||||||
_conn.close()
|
_conn.close()
|
||||||
|
|
||||||
return result
|
if row is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return row[0]
|
||||||
|
|
||||||
|
|
||||||
def delete_image_from_db(image_uid):
|
def delete_image_from_db(image_uid):
|
||||||
_conn = sqlite3.connect(image_db_file_location)
|
_conn = get_connection(image_db_file_location)
|
||||||
_c = _conn.cursor()
|
_c = _conn.cursor()
|
||||||
|
|
||||||
_c.execute("DELETE FROM images WHERE uid = ?;", (image_uid))
|
_c.execute(
|
||||||
|
f"DELETE FROM images WHERE uid = {placeholder()};",
|
||||||
|
(image_uid,)
|
||||||
|
)
|
||||||
|
|
||||||
_conn.commit()
|
_conn.commit()
|
||||||
_conn.close()
|
_conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
print(list_users())
|
print(list_users())
|
||||||
|
|
||||||
|
|||||||
10
manifests/prod/postgres-secret.yaml
Normal file
10
manifests/prod/postgres-secret.yaml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: postgres-secret
|
||||||
|
namespace: prod
|
||||||
|
type: Opaque
|
||||||
|
stringData:
|
||||||
|
POSTGRES_DB: appdb
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: password
|
||||||
36
manifests/prod/postgres.yaml
Normal file
36
manifests/prod/postgres.yaml
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: postgres
|
||||||
|
namespace: prod
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: postgres
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: postgres
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: postgres
|
||||||
|
image: postgres:16
|
||||||
|
ports:
|
||||||
|
- containerPort: 5432
|
||||||
|
envFrom:
|
||||||
|
- secretRef:
|
||||||
|
name: postgres-secret
|
||||||
|
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: postgres-service
|
||||||
|
namespace: prod
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: postgres
|
||||||
|
ports:
|
||||||
|
- port: 5432
|
||||||
|
targetPort: 5432
|
||||||
@@ -2,7 +2,7 @@ apiVersion: v1
|
|||||||
kind: Service
|
kind: Service
|
||||||
metadata:
|
metadata:
|
||||||
name: flask-service
|
name: flask-service
|
||||||
namespace: production
|
namespace: prod
|
||||||
spec:
|
spec:
|
||||||
selector:
|
selector:
|
||||||
app: flask-app
|
app: flask-app
|
||||||
|
|||||||
@@ -18,5 +18,11 @@ spec:
|
|||||||
image: registry.internal.uia.no/ikt206-g-26v-devops/group23/flask:latest
|
image: registry.internal.uia.no/ikt206-g-26v-devops/group23/flask:latest
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 5000
|
- containerPort: 5000
|
||||||
|
env:
|
||||||
|
- name: FLASK_ENV
|
||||||
|
value: staging
|
||||||
|
|
||||||
|
- name: DATABASE_URL
|
||||||
|
value: postgresql://postgres:password@postgres-service:5432/appdb
|
||||||
imagePullSecrets:
|
imagePullSecrets:
|
||||||
- name: gitlab-registry-secret
|
- name: gitlab-registry-secret
|
||||||
|
|||||||
10
manifests/stage/postgres-secret.yaml
Normal file
10
manifests/stage/postgres-secret.yaml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: postgres-secret
|
||||||
|
namespace: stage
|
||||||
|
type: Opaque
|
||||||
|
stringData:
|
||||||
|
POSTGRES_DB: appdb
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: password
|
||||||
35
manifests/stage/postgres.yaml
Normal file
35
manifests/stage/postgres.yaml
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: postgres
|
||||||
|
namespace: stage
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: postgres
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: postgres
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: postgres
|
||||||
|
image: postgres:16
|
||||||
|
ports:
|
||||||
|
- containerPort: 5432
|
||||||
|
envFrom:
|
||||||
|
- secretRef:
|
||||||
|
name: postgres-secret
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: postgres-service
|
||||||
|
namespace: stage
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: postgres
|
||||||
|
ports:
|
||||||
|
- port: 5432
|
||||||
|
targetPort: 5432
|
||||||
@@ -5,3 +5,6 @@ itsdangerous==2.2.0
|
|||||||
Jinja2==3.1.6
|
Jinja2==3.1.6
|
||||||
MarkupSafe==3.0.3
|
MarkupSafe==3.0.3
|
||||||
Werkzeug==3.1.8
|
Werkzeug==3.1.8
|
||||||
|
psycopg2-binary==2.9.11
|
||||||
|
pytest==8.3.5
|
||||||
|
pytest-cov==6.1.1
|
||||||
|
|||||||
163
tests/test_app.py
Normal file
163
tests/test_app.py
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import hashlib
|
||||||
|
import sqlite3
|
||||||
|
import pytest
|
||||||
|
import database
|
||||||
|
from app import app
|
||||||
|
|
||||||
|
|
||||||
|
def make_user_db(path):
|
||||||
|
conn = sqlite3.connect(path)
|
||||||
|
conn.execute("CREATE TABLE users (id TEXT PRIMARY KEY, pw TEXT NOT NULL)")
|
||||||
|
conn.execute("INSERT INTO users VALUES (?, ?)", ("ADMIN", hashlib.sha256("admin".encode()).hexdigest()))
|
||||||
|
conn.execute("INSERT INTO users VALUES (?, ?)", ("TEST", hashlib.sha256("123456".encode()).hexdigest()))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def make_notes_db(path):
|
||||||
|
conn = sqlite3.connect(path)
|
||||||
|
conn.execute("""CREATE TABLE notes (
|
||||||
|
user TEXT NOT NULL,
|
||||||
|
timestamp TEXT NOT NULL,
|
||||||
|
note TEXT NOT NULL,
|
||||||
|
note_id TEXT PRIMARY KEY)
|
||||||
|
""")
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def make_images_db(path):
|
||||||
|
conn = sqlite3.connect(path)
|
||||||
|
conn.execute("""CREATE TABLE images (
|
||||||
|
uid TEXT PRIMARY KEY,
|
||||||
|
owner TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
timestamp TEXT NOT NULL)
|
||||||
|
""")
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(tmp_path, monkeypatch):
|
||||||
|
user_db = tmp_path / "users.db"
|
||||||
|
notes_db = tmp_path / "notes.db"
|
||||||
|
images_db = tmp_path / "images.db"
|
||||||
|
|
||||||
|
make_user_db(user_db)
|
||||||
|
make_notes_db(notes_db)
|
||||||
|
make_images_db(images_db)
|
||||||
|
|
||||||
|
monkeypatch.setattr(database, "user_db_file_location", str(user_db))
|
||||||
|
monkeypatch.setattr(database, "note_db_file_location", str(notes_db))
|
||||||
|
monkeypatch.setattr(database, "image_db_file_location", str(images_db))
|
||||||
|
|
||||||
|
monkeypatch.delenv("DATABASE_URL", raising=False)
|
||||||
|
|
||||||
|
app.config.update(TESTING=True, SECRET_KEY="test-secret")
|
||||||
|
|
||||||
|
return app.test_client()
|
||||||
|
|
||||||
|
def test_homepage(client):
|
||||||
|
response = client.get("/")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
def test_public_page(client):
|
||||||
|
response = client.get("/public/")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
def test_private_requires_login(client):
|
||||||
|
response = client.get("/private/")
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_admin_requires_login(client):
|
||||||
|
response = client.get("/admin/")
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_login_valid_user_redirects(client):
|
||||||
|
response = client.post("/login", data={
|
||||||
|
"id": "test",
|
||||||
|
"pw": "123456",
|
||||||
|
})
|
||||||
|
|
||||||
|
assert response.status_code == 302
|
||||||
|
|
||||||
|
def test_private_after_login(client):
|
||||||
|
client.post("/login", data={
|
||||||
|
"id": "test",
|
||||||
|
"pw": "123456",
|
||||||
|
})
|
||||||
|
|
||||||
|
response = client.get("/private/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
def test_admin_page_as_admin(client):
|
||||||
|
client.post("/login", data={
|
||||||
|
"id": "admin",
|
||||||
|
"pw": "admin",
|
||||||
|
})
|
||||||
|
|
||||||
|
response = client.get("/admin/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
def test_invalid_login_does_not_access_private(client):
|
||||||
|
client.post("/login", data={
|
||||||
|
"id": "test",
|
||||||
|
"pw": "wrong",
|
||||||
|
})
|
||||||
|
|
||||||
|
response = client.get("/private/")
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_logout_removes_session(client):
|
||||||
|
client.post("/login", data={
|
||||||
|
"id": "test",
|
||||||
|
"pw": "123456",
|
||||||
|
})
|
||||||
|
|
||||||
|
response = client.get("/logout/")
|
||||||
|
|
||||||
|
assert response.status_code == 302
|
||||||
|
|
||||||
|
private_response = client.get("/private/")
|
||||||
|
assert private_response.status_code == 401
|
||||||
|
|
||||||
|
def test_write_note_after_login(client):
|
||||||
|
client.post("/login", data={
|
||||||
|
"id": "test",
|
||||||
|
"pw": "123456",
|
||||||
|
})
|
||||||
|
|
||||||
|
response = client.post("/write_note", data={
|
||||||
|
"text_note_to_take": "Test note",
|
||||||
|
})
|
||||||
|
|
||||||
|
assert response.status_code == 302
|
||||||
|
|
||||||
|
def test_admin_can_add_user(client):
|
||||||
|
client.post("/login", data={
|
||||||
|
"id": "admin",
|
||||||
|
"pw": "admin",
|
||||||
|
})
|
||||||
|
|
||||||
|
response = client.post("/add_user", data={
|
||||||
|
"id": "newuser",
|
||||||
|
"pw": "password",
|
||||||
|
})
|
||||||
|
|
||||||
|
assert response.status_code == 302
|
||||||
|
|
||||||
|
def test_admin_cannot_add_duplicate_user(client):
|
||||||
|
client.post("/login", data={
|
||||||
|
"id": "admin",
|
||||||
|
"pw": "admin",
|
||||||
|
})
|
||||||
|
|
||||||
|
response = client.post("/add_user", data={
|
||||||
|
"id": "test",
|
||||||
|
"pw": "whatever",
|
||||||
|
})
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert b"test" in response.data.lower() or response.status_code == 200
|
||||||
|
|
||||||
Reference in New Issue
Block a user