Compare commits

...

10 Commits

Author SHA1 Message Date
Chris Sanden
00c5d0bc4b Updated README to accurately reflect the repo 2026-05-13 16:38:46 +02:00
Chris Sanden
b4f997b15c Significantly enhanced testing suite to 69% coverage and updated gitignore 2026-05-13 16:14:36 +02:00
Chris Sanden
5ec8796ae4 added a conservative dockerignore 2026-05-13 15:42:00 +02:00
Henrik Corneliussen
a1c4bfb693 Added automated test coverage 2026-05-08 19:05:56 +02:00
Teodor
18a20c77a7 Completed PostGreSQL support for staging and production 2026-05-08 17:46:14 +02:00
Henrik Corneliussen
0706172e5e Added postgres database to production and staging 2026-05-08 16:54:29 +02:00
Henrik Corneliussen
0fa18ecf4f test 2026-05-08 16:11:08 +02:00
Henrik Corneliussen
827049956a added automatic production 2026-05-08 16:08:33 +02:00
Henrik Corneliussen
382f8fb1f1 test build 2026-05-08 15:59:13 +02:00
Henrik Høie Corneliussen
ce0f1b0921 Edit service.yaml 2026-05-08 13:54:18 +00:00
14 changed files with 642 additions and 91 deletions

33
.dockerignore Normal file
View 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/

2
.gitignore vendored
View File

@@ -1 +1,3 @@
*.pyc
.venv
.coverage

View File

@@ -1,17 +1,15 @@
stages:
- run
- test
- build
- deploy
run_flask_app:
stage: run
test:
stage: test
image: python:3.11
before_script:
- pip install -r requirements.txt
script:
- python app.py &
- sleep 5
- curl http://127.0.0.1:5000
- PYTHONPATH=. pytest --cov=. --cov-report=term-missing
build_docker_image:
stage: build
@@ -46,3 +44,24 @@ deploy_stage:
name: stage
rules:
- 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

177
README.md
View File

@@ -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
Image private.jpg: https://commons.wikimedia.org/wiki/File:(315-365)_Locked_(6149414678).jpg
The Compose file starts:
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.

View File

@@ -1,13 +1,75 @@
import os
import sqlite3
import hashlib
import datetime
import psycopg2
user_db_file_location = "database_file/users.db"
note_db_file_location = "database_file/notes.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():
_conn = sqlite3.connect(user_db_file_location)
_conn = get_connection(user_db_file_location)
_c = _conn.cursor()
_c.execute("SELECT id FROM users;")
@@ -17,145 +79,194 @@ def list_users():
return result
def verify(id, pw):
_conn = sqlite3.connect(user_db_file_location)
_conn = get_connection(user_db_file_location)
_c = _conn.cursor()
_c.execute("SELECT pw FROM users WHERE id = '" + id + "';")
result = _c.fetchone()[0] == hashlib.sha256(pw.encode()).hexdigest()
_c.execute(
f"SELECT pw FROM users WHERE id = {placeholder()};",
(id.upper(),)
)
row = _c.fetchone()
_conn.close()
return result
if row is None:
return False
return row[0] == hashlib.sha256(pw.encode()).hexdigest()
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.execute("DELETE FROM users WHERE id = ?;", (id))
_c.execute(
f"DELETE FROM users WHERE id = {placeholder()};",
(user_id,)
)
_conn.commit()
_conn.close()
# when we delete a user FROM database USERS, we also need to delete all his or her notes data FROM database NOTES
_conn = sqlite3.connect(note_db_file_location)
# when we delete a user from USERS, delete all notes owned by the user
_conn = get_connection(note_db_file_location)
_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.close()
# when we delete a user FROM database USERS, we also need to
# [1] delete all his or her images FROM image pool (done in app.py)
# [2] delete all his or her images records FROM database IMAGES
_conn = sqlite3.connect(image_db_file_location)
# when we delete a user from USERS, delete all image records owned by the user
_conn = get_connection(image_db_file_location)
_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.close()
def add_user(id, pw):
_conn = sqlite3.connect(user_db_file_location)
_conn = get_connection(user_db_file_location)
_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.close()
def read_note_from_db(id):
_conn = sqlite3.connect(note_db_file_location)
_conn = get_connection(note_db_file_location)
_c = _conn.cursor()
command = "SELECT note_id, timestamp, note FROM notes WHERE user = '" + id.upper() + "';"
_c.execute(command)
_c.execute(
f'SELECT note_id, timestamp, note FROM notes WHERE "user" = {placeholder()};',
(id.upper(),)
)
result = _c.fetchall()
_conn.commit()
_conn.close()
return result
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.
_conn = sqlite3.connect(note_db_file_location)
# Given the note id, confirm if the current user is the owner of the note being operated.
_conn = get_connection(note_db_file_location)
_c = _conn.cursor()
command = "SELECT user FROM notes WHERE note_id = '" + note_id + "';"
_c.execute(command)
result = _c.fetchone()[0]
_c.execute(
f'SELECT "user" FROM notes WHERE note_id = {placeholder()};',
(note_id,)
)
_conn.commit()
row = _c.fetchone()
_conn.close()
return result
if row is None:
return None
return row[0]
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()
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.close()
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.execute("DELETE FROM notes WHERE note_id = ?;", (note_id))
_c.execute(
f"DELETE FROM notes WHERE note_id = {placeholder()};",
(note_id,)
)
_conn.commit()
_conn.close()
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.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.close()
def list_images_for_user(owner):
_conn = sqlite3.connect(image_db_file_location)
_conn = get_connection(image_db_file_location)
_c = _conn.cursor()
command = "SELECT uid, timestamp, name FROM images WHERE owner = '{0}'".format(owner)
_c.execute(command)
_c.execute(
f"SELECT uid, timestamp, name FROM images WHERE owner = {placeholder()};",
(owner,)
)
result = _c.fetchall()
_conn.commit()
_conn.close()
return result
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.
_conn = sqlite3.connect(image_db_file_location)
# Given the image uid, confirm if the current user is the owner of the image being operated.
_conn = get_connection(image_db_file_location)
_c = _conn.cursor()
command = "SELECT owner FROM images WHERE uid = '" + image_uid + "';"
_c.execute(command)
result = _c.fetchone()[0]
_c.execute(
f"SELECT owner FROM images WHERE uid = {placeholder()};",
(image_uid,)
)
_conn.commit()
row = _c.fetchone()
_conn.close()
return result
if row is None:
return None
return row[0]
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.execute("DELETE FROM images WHERE uid = ?;", (image_uid))
_c.execute(
f"DELETE FROM images WHERE uid = {placeholder()};",
(image_uid,)
)
_conn.commit()
_conn.close()
if __name__ == "__main__":
print(list_users())

View 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

View 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

View File

@@ -2,7 +2,7 @@ apiVersion: v1
kind: Service
metadata:
name: flask-service
namespace: production
namespace: prod
spec:
selector:
app: flask-app

View File

@@ -18,5 +18,11 @@ spec:
image: registry.internal.uia.no/ikt206-g-26v-devops/group23/flask:latest
ports:
- containerPort: 5000
env:
- name: FLASK_ENV
value: staging
- name: DATABASE_URL
value: postgresql://postgres:password@postgres-service:5432/appdb
imagePullSecrets:
- name: gitlab-registry-secret

View 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

View 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

View File

@@ -5,3 +5,6 @@ itsdangerous==2.2.0
Jinja2==3.1.6
MarkupSafe==3.0.3
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
View 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