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:
|
||||
- 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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM python:3.11-slim
|
||||
FROM python:3.11-slim
|
||||
|
||||
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
|
||||
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.
|
||||
|
||||
221
database.py
221
database.py
@@ -1,161 +1,272 @@
|
||||
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;")
|
||||
result = [x[0] for x in _c.fetchall()]
|
||||
|
||||
_conn.close()
|
||||
|
||||
|
||||
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())
|
||||
|
||||
|
||||
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
|
||||
metadata:
|
||||
name: flask-service
|
||||
namespace: production
|
||||
namespace: prod
|
||||
spec:
|
||||
selector:
|
||||
app: flask-app
|
||||
|
||||
@@ -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
|
||||
|
||||
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
|
||||
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
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