9 Commits

13 changed files with 69 additions and 280 deletions

View File

@ -1,5 +1,6 @@
FROM python:3.11.7-slim-bookworm
FROM python:3.11.7-alpine
WORKDIR /app
COPY api .
RUN apk add --no-cache curl
RUN pip install -r requirements.txt
CMD python3 app.py

72
Jenkinsfile vendored
View File

@ -1,72 +0,0 @@
pipeline {
agent any
environment {
DOCKER_REGISTRY_URL = 'marcin00.azurecr.io'
DOCKER_IMAGE = "${DOCKER_REGISTRY_URL}/user-microservice:${GIT_COMMIT}"
ACR_NAME = 'marcin00'
}
stages {
stage('Checkout') {
steps {
checkout scm
}
}
stage('Test python app') {
steps {
script {
dir('api') {
sh '''
python3 -m venv env
source env/bin/activate
pip install -r requirements.txt pytest
python3 -m pytest --junit-xml=pytest_junit.xml
'''
}
}
}
post {
always {
junit testResults: '**/*pytest_junit.xml'
}
}
}
stage('Build & test docker image') {
steps {
script {
appImage = docker.build("${DOCKER_IMAGE}")
sh label: 'Install dgoss', script: '''
curl -s -L https://github.com/aelsabbahy/goss/releases/latest/download/goss-linux-amd64 -o goss
curl -s -L https://github.com/aelsabbahy/goss/releases/latest/download/dgoss -o dgoss
chmod +rx *goss
'''
withEnv(['GOSS_OPTS=-f junit', 'GOSS_PATH=./goss', 'GOSS_SLEEP=3', 'SQLALCHEMY_DATABASE_URI=sqlite:///:memory:']) {
sh label: 'run image tests', script: './dgoss run -e SQLALCHEMY_DATABASE_URI=sqlite:///:memory: ${DOCKER_IMAGE} > goss_junit.xml'
}
}
}
post {
always {
junit testResults: '**/*goss_junit.xml'
}
}
}
stage('Deploy') {
steps {
script {
sh '''
az login --identity
az acr login --name ${ACR_NAME}
docker push ${DOCKER_IMAGE}
'''
}
}
}
}
post {
cleanup {
script { cleanWs() }
}
}
}

View File

@ -4,7 +4,8 @@ from flask_jwt_extended import JWTManager
from jwt import ExpiredSignatureError
from models import db, RevokedToken
import os
from utils import init_db
from tech_views import tech_bp
from utils import init_db, wait_for_db
from views import user_bp
from werkzeug.exceptions import HTTPException
@ -26,6 +27,7 @@ def create_app(config_name="default"):
# Blueprints registration
app.register_blueprint(user_bp)
app.register_blueprint(tech_bp)
# Database and JWT initialization
db.init_app(app)
@ -53,6 +55,7 @@ def create_app(config_name="default"):
# Fill database by initial values (only if we are not testing)
with app.app_context():
wait_for_db(max_retries=100)
db.create_all()
if config_name != "testing":
init_db()

20
api/tech_views.py Normal file
View File

@ -0,0 +1,20 @@
from flask import Blueprint, jsonify
from models import db
from sqlalchemy import text
from utils import db_ready
# Blueprint with technical endpoints
tech_bp = Blueprint('tech_bp', __name__)
@tech_bp.route('/health', methods=['GET'])
def health_check():
"Check if service works and database is functional"
try:
with db.engine.connect() as connection:
connection.execute(text("SELECT 1"))
return jsonify(status="healthy"), 200
except Exception:
if db_ready:
return jsonify(status="unhealthy"), 500
else:
return jsonify(status="starting"), 503

View File

@ -2,17 +2,22 @@ from flask import abort
from flask_jwt_extended import get_jwt_identity
from models import User, db
import os
from sqlalchemy import text
from sqlalchemy.exc import DatabaseError, InterfaceError
import time
from werkzeug.security import generate_password_hash
db_ready = False
def admin_required(user_id, message='Access denied.'):
"Check if common user try to make administrative action."
user = db.session.get(User, user_id)
if user is None or user.role != "Administrator":
abort(403, message)
def validate_access(owner_id, message='Access denied.'):
# Check if user try to access or edit resource that does not belong to them
"Check if user try to access or edit resource that does not belong to them."
logged_user_id = int(get_jwt_identity())
logged_user_role = db.session.get(User, logged_user_id).role
if logged_user_role != "Administrator" and logged_user_id != owner_id:
@ -27,6 +32,20 @@ def get_user_or_404(user_id):
return user
def wait_for_db(max_retries):
"Try to connect with database <max_retries> times."
global db_ready
for _ in range(max_retries):
try:
with db.engine.connect() as connection:
connection.execute(text("SELECT 1"))
db_ready = True
return
except DatabaseError | InterfaceError:
time.sleep(3)
raise Exception("Failed to connect to database.")
def init_db():
"""Create default admin account if database is empty"""
with db.session.begin():

View File

@ -2,6 +2,7 @@ from flask import Blueprint, jsonify, request, abort
from flask_jwt_extended import create_access_token, set_access_cookies, jwt_required, \
verify_jwt_in_request, get_jwt_identity, unset_jwt_cookies, get_jwt
from models import db, RevokedToken, User
import os
from utils import admin_required, validate_access, get_user_or_404
from werkzeug.security import check_password_hash, generate_password_hash
@ -110,3 +111,10 @@ def user_logout():
response = jsonify({"msg": "User logged out successfully."})
unset_jwt_cookies(response)
return response
@user_bp.route('/version', methods=['GET'])
def version():
return jsonify({
"version": os.getenv("APP_VERSION", "unknown"),
"build_time": os.getenv("BUILD_DATE", "unknown")
})

View File

@ -1,5 +0,0 @@
apiVersion: v1
kind: ServiceAccount
metadata:
name: edu-agentpool
namespace: argo

View File

@ -1,12 +0,0 @@
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
namespace: argo
name: argo-workflow-manager
rules:
- apiGroups: ["argoproj.io"]
resources: ["workflowtaskresults"]
verbs: ["create", "get", "list", "update", "patch", "delete"]
- apiGroups: ["argoproj.io"]
resources: ["workflows"]
verbs: ["create", "get", "list", "update", "patch", "delete"]

View File

@ -1,142 +0,0 @@
apiVersion: argoproj.io/v1alpha1
kind: Workflow
metadata:
generateName: build-workflow-
spec:
entrypoint: main
arguments:
parameters:
- name: repo
value: https://gitea.marcin00.pl/pikram/user-microservice.git
- name: branch
value: main
- name: image
value: marcin00.azurecr.io/user-microservice
- name: registry_server
value: marcin00.azurecr.io
serviceAccountName: edu-agentpool
volumeClaimTemplates:
- metadata:
name: workspace
spec:
accessModes: [ "ReadWriteOnce" ]
resources:
requests:
storage: 128Mi
volumes:
- name: secrets-store
csi:
driver: secrets-store.csi.k8s.io
readOnly: true
volumeAttributes:
secretProviderClass: azure-keyvault
templates:
# 🔁 Main steps sequence
- name: main
steps:
- - name: checkout
template: checkout
arguments:
parameters:
- name: repo
value: "{{workflow.parameters.repo}}"
- name: branch
value: "{{workflow.parameters.branch}}"
- - name: tests
template: tests
- - name: build-test-and-push-image
template: build-test-and-push-image
arguments:
parameters:
- name: git-sha
value: "{{steps.checkout.outputs.parameters.git-sha}}"
# 📦 GIT CHECKOUT
- name: checkout
inputs:
parameters:
- name: repo
- name: branch
container:
image: alpine/git
command: [sh,-c]
workingDir: /workspace
args:
- |
git clone --depth 1 --branch "{{inputs.parameters.branch}}" --single-branch "{{inputs.parameters.repo}}" repo
cd repo
git rev-parse HEAD > /tmp/gitsha.txt
volumeMounts:
- name: workspace
mountPath: /workspace
outputs:
parameters:
- name: git-sha
valueFrom:
path: /tmp/gitsha.txt
# 🧪 PYTHON TESTS
- name: tests
script:
image: python:3.11.7-alpine
command: [sh]
workingDir: /workspace/repo/api
source: |
python3 -m venv env
. env/bin/activate
pip install -r requirements.txt pytest
python3 -m pytest --junit-xml=pytest_junit.xml
volumeMounts:
- name: workspace
mountPath: /workspace
# 🐳 BUILDS AND GOSS TESTS
- name: build-test-and-push-image
inputs:
parameters:
- name: git-sha
container:
image: docker:dind
command: [sh, -c]
workingDir: /workspace/repo
args:
- |
dockerd-entrypoint.sh &
sleep 3
DOCKER_IMAGE={{workflow.parameters.image}}:{{inputs.parameters.git-sha}}
docker build -t $DOCKER_IMAGE .
apk add --no-cache bash
wget https://github.com/aelsabbahy/goss/releases/latest/download/goss-linux-amd64 -O goss
wget https://github.com/aelsabbahy/goss/releases/latest/download/dgoss -O dgoss
chmod +rx *goss
export GOSS_OPTS="-f junit"
export GOSS_PATH=./goss
export GOSS_SLEEP=3
./dgoss run -e SQLALCHEMY_DATABASE_URI=sqlite:///:memory: $DOCKER_IMAGE > /workspace/goss_junit.xml
echo "===> Logging into ACR"
ACR_PASSWORD=$(cat /mnt/secrets/acr-password)
echo "$ACR_PASSWORD" | docker login {{workflow.parameters.registry_server}} -u $ACR_USERNAME --password-stdin
echo "===> Pushing image to ACR"
docker push $DOCKER_IMAGE
env:
- name: ACR_USERNAME
value: marcin00
securityContext:
privileged: true
volumeMounts:
- name: workspace
mountPath: /workspace
- name: docker-library
mountPath: /var/lib/docker
- name: secrets-store
mountPath: "/mnt/secrets"
readOnly: true
volumes:
- name: docker-library
emptyDir: {}

View File

@ -1,13 +0,0 @@
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: argo-edu-agentpool-binding
namespace: argo
subjects:
- kind: ServiceAccount
name: edu-agentpool
namespace: argo
roleRef:
kind: Role
name: argo-workflow-manager
apiGroup: rbac.authorization.k8s.io

View File

@ -1,25 +0,0 @@
apiVersion: secrets-store.csi.x-k8s.io/v1
kind: SecretProviderClass
metadata:
name: azure-keyvault
namespace: argo
spec:
provider: azure
secretObjects:
- secretName: acr-creds
type: Opaque
data:
- objectName: acr-password
- key: password
parameters:
usePodIdentity: "false"
useVMManagedIdentity: "true"
userAssignedIdentityID: "0c2780e4-8594-4aab-8f1a-8a19f71924bd" # client_id of the user-assigned managed identity
clientID: "0c2780e4-8594-4aab-8f1a-8a19f71924bd" # client_id of the user-assigned managed identity
keyvaultName: "dev-aks"
objects: |
array:
- |
objectName: acr-password
objectType: secret
tenantID: "f4e3e6f7-d21c-460e-b201-2192174e7f41"

View File

@ -7,9 +7,24 @@ services:
build: .
env_file:
- api/.env
ports:
- 80:80
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost/health"]
interval: 10s
timeout: 5s
retries: 5
start_period: 15s
db:
container_name: db
hostname: db
image: mysql:latest
env_file:
- db/.env
ports:
- 3306:3306
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s
timeout: 5s
retries: 5

View File

@ -1,8 +0,0 @@
port:
tcp:80:
listening: true
ip:
- 0.0.0.0
process:
python3:
running: true