diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1fdcf56 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +.venv/ +.venv-ci/ +__pycache__/ +.pytest_cache/ +.sonar/ +instance/ +coverage.xml +.git +.gitignore +.vscode/ +README.md \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..69fc9e2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,78 @@ +name: Flask Todo CI + +on: + pull_request: + branches: [ "master" ] + push: + branches: [ "master" ] + +permissions: + contents: read + id-token: write + +env: + AWS_REGION: ap-southeast-2 + ECR_REPOSITORY: flask-todo + +jobs: + test-build-push: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install pytest pytest-cov + + - name: Run tests with coverage + run: | + pytest -v --cov=app --cov-report=xml + + - name: SonarQube Cloud Scan + uses: SonarSource/sonarqube-scan-action@v7.1.0 + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + with: + args: > + -Dsonar.qualitygate.wait=true + + - name: Build Docker image + run: | + docker build -t flask-todo:ci . + + - name: Set image tag + run: | + SHORT_SHA=$(echo "${GITHUB_SHA}" | cut -c1-7) + echo "IMAGE_TAG=${GITHUB_RUN_NUMBER}-${SHORT_SHA}" >> $GITHUB_ENV + + - name: Configure AWS credentials + if: github.event_name == 'push' && github.ref == 'refs/heads/master' + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: arn:aws:iam::730335329548:role/github-actions-ecr-role + aws-region: ${{ env.AWS_REGION }} + + - name: Login to Amazon ECR + if: github.event_name == 'push' && github.ref == 'refs/heads/master' + id: login-ecr + uses: aws-actions/amazon-ecr-login@v2 + + - name: Tag and push image to ECR + if: github.event_name == 'push' && github.ref == 'refs/heads/master' + env: + ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} + run: | + docker tag flask-todo:ci $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG + docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG + echo "Pushed image: $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" \ No newline at end of file diff --git a/.gitignore b/.gitignore index f8647d7..2cf533c 100644 --- a/.gitignore +++ b/.gitignore @@ -137,4 +137,12 @@ dmypy.json .pytype/ # Cython debug symbols -cython_debug/ \ No newline at end of file +cython_debug/ + +# VS Code / macOS / JetBrains +.vscode/ +.DS_Store +.idea/ + +coverage.xml +.sonar/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..46f7c59 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY requirements.txt . + +RUN pip install --no-cache-dir --upgrade pip \ + && pip install --no-cache-dir -r requirements.txt + +COPY . . + +RUN mkdir -p instance + +ENV APP_HOST=0.0.0.0 +ENV PORT=5000 + +EXPOSE 5000 + +CMD ["python", "app.py"] \ No newline at end of file diff --git a/Jenkinsfile b/Jenkinsfile new file mode 100644 index 0000000..49645a0 --- /dev/null +++ b/Jenkinsfile @@ -0,0 +1,87 @@ +pipeline { + agent any + + options { + timestamps() + } + + environment { + SONAR_HOST_URL = 'http://172.31.14.190:9000' + AWS_REGION = 'ap-southeast-2' + ECR_REPO = 'flask-todo' + } + + stages { + stage('Checkout') { + steps { + checkout scm + } + } + + stage('Set up Python environment') { + steps { + sh ''' + rm -rf .venv-ci + python3.12 -m venv .venv-ci + . .venv-ci/bin/activate + python -m pip install --upgrade pip + pip install -r requirements.txt + ''' + } + } + + stage('Run tests') { + steps { + sh ''' + . .venv-ci/bin/activate + pytest -v --cov=app --cov-report=xml + ''' + } + } + + stage('Run SonarQube analysis') { + steps { + withCredentials([string(credentialsId: 'sonar-token-flask-todo', variable: 'SONAR_TOKEN')]) { + sh ''' + . .venv-ci/bin/activate + pysonar \ + --sonar-host-url="${SONAR_HOST_URL}" \ + --sonar-token="${SONAR_TOKEN}" + ''' + } + } + } + + stage('Build Docker image') { + steps { + sh ''' + docker build -t flask-todo:ci . + ''' + } + } + stage('Push Docker image to ECR') { + steps { + sh ''' + ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text) + ECR_REGISTRY=${ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com + SHORT_SHA=$(git rev-parse --short HEAD) + IMAGE_TAG=${BUILD_NUMBER}-${SHORT_SHA} + + aws ecr get-login-password --region ${AWS_REGION} | \ + docker login --username AWS --password-stdin ${ECR_REGISTRY} + + docker tag flask-todo:ci ${ECR_REGISTRY}/${ECR_REPO}:${IMAGE_TAG} + docker push ${ECR_REGISTRY}/${ECR_REPO}:${IMAGE_TAG} + + echo "Pushed image: ${ECR_REGISTRY}/${ECR_REPO}:${IMAGE_TAG}" + ''' + } + } + } + + post { + always { + archiveArtifacts artifacts: 'coverage.xml', allowEmptyArchive: true + } + } +} \ No newline at end of file diff --git a/README.md b/README.md index e2a98cb..d539cd8 100644 --- a/README.md +++ b/README.md @@ -43,3 +43,4 @@ Run the app ```console $ flask run ``` + diff --git a/app.py b/app.py index 4b6ccca..0fbeced 100644 --- a/app.py +++ b/app.py @@ -1,5 +1,6 @@ from flask import Flask, render_template, request, redirect, url_for from flask_sqlalchemy import SQLAlchemy +import os app = Flask(__name__) @@ -45,6 +46,11 @@ def delete(todo_id): db.session.commit() return redirect(url_for("home")) + if __name__ == "__main__": - db.create_all() - app.run(debug=True) + with app.app_context(): + db.create_all() + + host = os.getenv("APP_HOST", "127.0.0.1") + port = int(os.getenv("PORT", "5000")) + app.run(host=host, port=port) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..062f045 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,30 @@ +blinker==1.9.0 +certifi==2026.2.25 +charset-normalizer==3.4.7 +click==8.3.2 +coverage==7.13.5 +Flask==3.1.3 +Flask-SQLAlchemy==3.1.1 +greenlet==3.4.0 +idna==3.11 +iniconfig==2.3.0 +itsdangerous==2.2.0 +Jinja2==3.1.6 +jproperties==2.1.2 +MarkupSafe==3.0.3 +packaging==26.1 +pluggy==1.6.0 +pyfakefs==5.9.3 +Pygments==2.20.0 +pysonar==1.4.0.4676 +pytest==9.0.3 +pytest-cov==7.1.0 +PyYAML==6.0.3 +requests==2.32.5 +responses==0.25.8 +six==1.17.0 +SQLAlchemy==2.0.49 +tomli==2.2.1 +typing_extensions==4.15.0 +urllib3==2.6.3 +Werkzeug==3.1.8 diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 0000000..6c675b7 --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,13 @@ +sonar.projectKey=apeCry_flask-todo +sonar.organization=apecry + +sonar.sources=app.py +sonar.tests=tests + +sonar.python.coverage.reportPaths=coverage.xml + +sonar.exclusions=.venv/**,instance/**,__pycache__/**,.pytest_cache/**,.sonar/**,.git/** + +sonar.sourceEncoding=UTF-8 + +sonar.python.version=3.12 \ No newline at end of file diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..67fb1fb --- /dev/null +++ b/tests/test_app.py @@ -0,0 +1,47 @@ +import os +import sys +import tempfile +import pytest + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from app import app, db + + +@pytest.fixture +def client(): + db_fd, db_path = tempfile.mkstemp() + + app.config["TESTING"] = True + app.config["SQLALCHEMY_DATABASE_URI"] = f"sqlite:///{db_path}" + + with app.app_context(): + db.drop_all() + db.create_all() + + with app.test_client() as client: + yield client + + with app.app_context(): + db.session.remove() + db.drop_all() + + os.close(db_fd) + os.unlink(db_path) + + +def test_home_page_loads(client): + response = client.get("/") + assert response.status_code == 200 + assert b"To Do App" in response.data + + +def test_add_todo(client): + response = client.post( + "/add", + data={"title": "test task"}, + follow_redirects=True + ) + + assert response.status_code == 200 + assert b"test task" in response.data \ No newline at end of file