├── .flake8 ├── .dockerignore ├── .github ├── act │ ├── workflow_dispatch.json │ ├── .secrets.sample │ └── readme.md ├── scripts │ ├── release.sh │ └── url-check.sh └── workflows │ ├── publish.yaml │ ├── cd-release-aks.yaml │ ├── cd-release-webapp.yaml │ └── ci-build.yaml ├── src ├── app │ ├── static │ │ ├── img │ │ │ ├── favicon.ico │ │ │ ├── flask.png │ │ │ ├── github-2.svg │ │ │ ├── python.svg │ │ │ └── docker-whale.svg │ │ ├── css │ │ │ └── main.css │ │ └── js │ │ │ ├── monitor.js │ │ │ └── sorttable.js │ ├── conftest.py │ ├── __init__.py │ ├── tests │ │ ├── test_views.py │ │ └── test_api.py │ ├── views.py │ ├── templates │ │ ├── monitor.html │ │ ├── index.html │ │ ├── info.html │ │ └── base.html │ └── apis.py ├── requirements.txt └── run.py ├── .prettierrc.yaml ├── deploy ├── kubernetes │ ├── aks-live.yaml │ ├── app.sample.yaml │ └── readme.md └── webapp.bicep ├── .devcontainer └── devcontainer.json ├── .vscode └── settings.json ├── LICENSE ├── .gitignore ├── CONTRIBUTING.md ├── makefile ├── tests └── postman_collection.json └── README.md /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length=125 -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | *.pyc 3 | __pycache__ 4 | -------------------------------------------------------------------------------- /.github/act/workflow_dispatch.json: -------------------------------------------------------------------------------- 1 | { 2 | "inputs": { 3 | "IMAGE_TAG": "26-03-2021.1610" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/app/static/img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benc-uk/python-demoapp/HEAD/src/app/static/img/favicon.ico -------------------------------------------------------------------------------- /src/app/static/img/flask.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benc-uk/python-demoapp/HEAD/src/app/static/img/flask.png -------------------------------------------------------------------------------- /src/requirements.txt: -------------------------------------------------------------------------------- 1 | Flask==1.1.2 2 | py-cpuinfo==7.0.0 3 | psutil==5.8.0 4 | gunicorn==20.1.0 5 | black==20.8b1 6 | flake8==3.9.0 7 | pytest==6.2.2 -------------------------------------------------------------------------------- /.prettierrc.yaml: -------------------------------------------------------------------------------- 1 | semi: false 2 | singleQuote: true 3 | printWidth: 120 4 | tabWidth: 2 5 | arrowParens: always 6 | bracketSpacing: true 7 | useTabs: false 8 | -------------------------------------------------------------------------------- /src/app/conftest.py: -------------------------------------------------------------------------------- 1 | from . import create_app 2 | import pytest 3 | 4 | app = create_app() 5 | 6 | 7 | @pytest.fixture 8 | def client(): 9 | with app.test_client() as client: 10 | yield client 11 | -------------------------------------------------------------------------------- /.github/act/.secrets.sample: -------------------------------------------------------------------------------- 1 | GH_PAT=__CHANGE_ME__ 2 | GITHUB_TOKEN=__SAME_AS_GH_PAT__ 3 | AZURE_CREDENTIALS={"clientId": "__CHANGE_ME__", "clientSecret": "__CHANGE_ME__", "subscriptionId": "__CHANGE_ME__", "tenantId": "__CHANGE_ME__"} 4 | -------------------------------------------------------------------------------- /src/app/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Flask 2 | 3 | 4 | def create_app(): 5 | app = Flask(__name__) 6 | with app.app_context(): 7 | from . import views # noqa: E402,F401 8 | from . import apis # noqa: E402,F401 9 | return app 10 | -------------------------------------------------------------------------------- /src/run.py: -------------------------------------------------------------------------------- 1 | import os 2 | from app import create_app 3 | 4 | app = create_app() 5 | 6 | if __name__ == "__main__": 7 | port = int(os.environ.get("PORT", 5000)) 8 | app.jinja_env.auto_reload = True 9 | app.config["TEMPLATES_AUTO_RELOAD"] = True 10 | app.run(host="0.0.0.0", port=port) 11 | -------------------------------------------------------------------------------- /deploy/kubernetes/aks-live.yaml: -------------------------------------------------------------------------------- 1 | image: 2 | repository: ghcr.io/benc-uk/python-demoapp 3 | pullPolicy: Always 4 | 5 | service: 6 | targetPort: 5000 7 | 8 | ingress: 9 | enabled: true 10 | host: python-demoapp.kube.benco.io 11 | annotations: 12 | nginx.ingress.kubernetes.io/ssl-redirect: "true" 13 | tls: 14 | enabled: true 15 | secretName: kube-benco-io-cert 16 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "image": "ghcr.io/benc-uk/devcontainers/python:latest", 3 | "remoteUser": "vscode", 4 | "forwardPorts": [5000], 5 | "extensions": [ 6 | "ms-python.python", 7 | "mikestead.dotenv", 8 | "esbenp.prettier-vscode", 9 | "ms-azuretools.vscode-bicep", 10 | "cschleiden.vscode-github-actions", 11 | "github.vscode-pull-request-github" 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.pythonPath": "src/.venv/bin/python", 3 | "editor.semanticHighlighting.enabled": true, 4 | "editor.formatOnSave": true, 5 | "[python]": { 6 | "editor.defaultFormatter": "ms-python.python" 7 | }, 8 | "python.formatting.provider": "black", 9 | "python.linting.pylintEnabled": false, 10 | "python.linting.flake8Enabled": true, 11 | "python.linting.enabled": true 12 | } 13 | -------------------------------------------------------------------------------- /.github/scripts/release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | VER=$1 4 | 5 | if [[ -z "$VER" ]]; then 6 | echo "Error! Supply version tag!" 7 | exit 1 8 | fi 9 | 10 | read -r -d '' NOTES << EOM 11 | \`\`\` 12 | docker pull ghcr.io/benc-uk/python-demoapp:$VER 13 | \`\`\` 14 | 15 | \`\`\` 16 | docker run --rm -it -p 5000:5000 ghcr.io/benc-uk/python-demoapp:$VER 17 | \`\`\` 18 | EOM 19 | 20 | gh release create $VER --title "Release v$VER" -n "$NOTES" 21 | -------------------------------------------------------------------------------- /src/app/tests/test_views.py: -------------------------------------------------------------------------------- 1 | def test_home(client): 2 | resp = client.get("/") 3 | 4 | assert resp.status_code == 200 5 | assert b"Python" in resp.data 6 | 7 | 8 | def test_page_content(client): 9 | resp = client.get("/") 10 | 11 | assert resp.status_code == 200 12 | assert b"Coleman" in resp.data 13 | 14 | 15 | def test_info(client): 16 | resp = client.get("/info") 17 | 18 | assert resp.status_code == 200 19 | assert b"Hostname" in resp.data 20 | -------------------------------------------------------------------------------- /deploy/kubernetes/app.sample.yaml: -------------------------------------------------------------------------------- 1 | # 2 | # See this Helm chart for all options 3 | # https://github.com/benc-uk/helm-charts/tree/master/webapp 4 | # 5 | 6 | image: 7 | repository: ghcr.io/benc-uk/python-demoapp 8 | tag: latest 9 | pullPolicy: Always 10 | 11 | service: 12 | targetPort: 5000 13 | type: LoadBalancer 14 | 15 | # 16 | # If you have an ingress controller set up 17 | # 18 | # ingress: 19 | # enabled: true 20 | # host: changeme.example.net 21 | # tls: 22 | # enabled: true 23 | # secretName: changeme-cert-secret 24 | -------------------------------------------------------------------------------- /deploy/kubernetes/readme.md: -------------------------------------------------------------------------------- 1 | # Kubernetes 2 | 3 | Deployment into Kubernetes is simple using a [generic Helm chart for deploying web apps](https://github.com/benc-uk/helm-charts/tree/master/webapp) 4 | 5 | Make sure you have [Helm installed first](https://helm.sh/docs/intro/install/) 6 | 7 | First add the Helm repo 8 | ```bash 9 | helm repo add benc-uk https://benc-uk.github.io/helm-charts 10 | ``` 11 | 12 | Make a copy of `app.sample.yaml` to `myapp.yaml` and modify the values to suit your environment. If you're in a real hurry you can use the file as is and make no changes. 13 | ```bash 14 | helm install demo benc-uk/webapp --values myapp.yaml 15 | ``` -------------------------------------------------------------------------------- /src/app/views.py: -------------------------------------------------------------------------------- 1 | from flask import render_template, current_app as app 2 | 3 | import cpuinfo 4 | import psutil 5 | import platform 6 | import datetime 7 | 8 | 9 | @app.route("/") 10 | def index(): 11 | return render_template("index.html") 12 | 13 | 14 | @app.route("/info") 15 | def info(): 16 | osinfo = {} 17 | osinfo["plat"] = platform 18 | osinfo["cpu"] = cpuinfo.get_cpu_info() 19 | osinfo["mem"] = psutil.virtual_memory() 20 | osinfo["net"] = psutil.net_if_addrs() 21 | osinfo["boottime"] = datetime.datetime.fromtimestamp(psutil.boot_time()).strftime( 22 | "%Y-%m-%d %H:%M:%S" 23 | ) 24 | 25 | return render_template("info.html", info=osinfo) 26 | 27 | 28 | @app.route("/monitor") 29 | def monitor(): 30 | return render_template("monitor.html") 31 | -------------------------------------------------------------------------------- /.github/act/readme.md: -------------------------------------------------------------------------------- 1 | # Act 2 | 3 | Act is an amazing command line local runner for GitHub Actions 4 | https://github.com/nektos/act 5 | 6 | Install with 7 | 8 | ```bash 9 | curl https://raw.githubusercontent.com/nektos/act/master/install.sh | sudo bash 10 | ``` 11 | 12 | To run the workflows for this repo, example commands are given below 13 | 14 | The `.secrets` file must be created first, see the sample file for a reference. 15 | 16 | ### Run CI 17 | 18 | ```bash 19 | act push --secret-file .github/act/.secrets --platform ubuntu-latest=ghcr.io/benc-uk/devcontainers/python:root 20 | ``` 21 | 22 | ### Run a deployment 23 | 24 | ```bash 25 | act workflow_dispatch --eventpath .github/act/workflow_dispatch.json --secret-file .github/act/.secrets --platform ubuntu-latest=ghcr.io/benc-uk/devcontainers/python:root 26 | ``` 27 | -------------------------------------------------------------------------------- /src/app/static/css/main.css: -------------------------------------------------------------------------------- 1 | 2 | h1 { 3 | padding-top: 1rem; 4 | } 5 | 6 | .logotext { 7 | font-size: 1.5em !important; 8 | } 9 | .jumbotron { 10 | padding: 2rem !important; 11 | } 12 | .gauges { 13 | width: 100%; 14 | height: 25vw; 15 | margin: 0 auto; 16 | } 17 | 18 | /* Sortable tables */ 19 | table.sortable th { 20 | background-color: #eeeeee; 21 | color:#666666; 22 | font-weight: bold; 23 | cursor: pointer; 24 | padding: 10px; 25 | } 26 | 27 | td { 28 | padding-right: 50px !important; 29 | font-size: 18px; 30 | border-bottom: 1px solid #dddddd; 31 | cursor: default; 32 | } 33 | 34 | .icon { 35 | width: 40px; 36 | } 37 | 38 | .dimmed-box { 39 | background-color: rgba(0,0,0,0.2); 40 | padding: 1rem; 41 | border-radius: 0.3rem; 42 | } -------------------------------------------------------------------------------- /.github/workflows/publish.yaml: -------------------------------------------------------------------------------- 1 | name: Release Versioned Image 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | env: 8 | IMAGE_REG: ghcr.io 9 | IMAGE_REPO: benc-uk/python-demoapp 10 | 11 | jobs: 12 | publish-image: 13 | name: "Build & Publish" 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: "Checkout" 17 | uses: actions/checkout@v2 18 | 19 | - name: "Docker build image with version tag" 20 | run: | 21 | make image IMAGE_TAG=${{ github.event.release.tag_name }} 22 | make image IMAGE_TAG=latest 23 | 24 | - name: "Push to container registry" 25 | run: | 26 | echo ${{ secrets.GITHUB_TOKEN }} | docker login $IMAGE_REG -u $GITHUB_ACTOR --password-stdin 27 | make push IMAGE_TAG=${{ github.event.release.tag_name }} 28 | make push IMAGE_TAG=latest 29 | -------------------------------------------------------------------------------- /deploy/webapp.bicep: -------------------------------------------------------------------------------- 1 | param location string = resourceGroup().location 2 | 3 | param planName string = 'app-plan-linux' 4 | param planTier string = 'P1v2' 5 | 6 | param webappName string = 'python-demoapp' 7 | param webappImage string = 'ghcr.io/benc-uk/python-demoapp:latest' 8 | param weatherKey string = '' 9 | param releaseInfo string = 'Released on ${utcNow('f')}' 10 | 11 | resource appServicePlan 'Microsoft.Web/serverfarms@2020-10-01' = { 12 | name: planName 13 | location: location 14 | kind: 'linux' 15 | sku: { 16 | name: planTier 17 | } 18 | properties: { 19 | reserved: true 20 | } 21 | } 22 | 23 | resource webApp 'Microsoft.Web/sites@2020-10-01' = { 24 | name: webappName 25 | location: location 26 | properties: { 27 | serverFarmId: appServicePlan.id 28 | siteConfig: { 29 | appSettings: [] 30 | linuxFxVersion: 'DOCKER|${webappImage}' 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/app/static/img/github-2.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2021 Ben Coleman 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /src/app/tests/test_api.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | 4 | # Test the process API returns JSON results we expect 5 | def test_api_process(client): 6 | resp = client.get("/api/process") 7 | 8 | assert resp.status_code == 200 9 | assert resp.headers["Content-Type"] == "application/json" 10 | resp_payload = json.loads(resp.data) 11 | assert len(resp_payload["processes"]) > 0 12 | assert resp_payload["processes"][0]["memory_percent"] > 0 13 | assert len(resp_payload["processes"][0]["name"]) > 0 14 | 15 | 16 | # Test the monitor API returns JSON results we expect 17 | def test_api_monitor(client): 18 | resp = client.get("/api/monitor") 19 | 20 | assert resp.status_code == 200 21 | assert resp.headers["Content-Type"] == "application/json" 22 | resp_payload = json.loads(resp.data) 23 | assert resp_payload["cpu"] >= 0 24 | assert resp_payload["disk"] >= 0 25 | assert resp_payload["disk_read"] >= 0 26 | assert resp_payload["disk_write"] >= 0 27 | assert resp_payload["mem"] >= 0 28 | assert resp_payload["net_recv"] >= 0 29 | assert resp_payload["net_sent"] >= 0 30 | -------------------------------------------------------------------------------- /src/app/templates/monitor.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} {% block content %} 2 | 3 | 4 | 5 | 6 | 10 | 11 | 12 | 13 | Refresh Rate: secs 14 | 15 |
16 |

👓 Running Processes ()

17 |
18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 |
PIDNameMemCPU TimeThreads
30 |
31 | 32 |
33 | 34 |

🌡 Performance Monitor

35 |
36 |
37 |
38 | 39 | 40 | {% endblock %} 41 | -------------------------------------------------------------------------------- /src/app/templates/index.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} {% block content %} 2 |
3 |
4 |

Python & Flask Demo App

5 | 6 |
7 | This is a simple web application written in Python and using Flask. It has been designed with cloud demos & 8 | containers in mind. Demonstrating capabilities such as auto scaling, deployment to Azure or Kubernetes, or anytime 9 | you want something quick and lightweight to run & deploy. 10 |
11 |
12 | 13 |
14 |

15 | 16 | GitHub Project 17 | 18 |     19 | 20 | 21 | Docker Images 22 | 23 |

24 |
25 |

26 | 27 | 28 | Get started with Azure & Python 29 | 30 |

31 | 32 |
33 |

Microsoft ❤ Open Source

34 |
35 |
36 | {% endblock %} 37 | -------------------------------------------------------------------------------- /src/app/static/img/python.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/templates/info.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} {% block content %} 2 | 3 |
4 |

🛠 System Information

5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 42 | 43 | 44 |
Hostname{{ info.plat.node() }}
Boot Time{{ info.boottime }}
OS Platform{{ info.plat.system() }}
OS Version{{ info.plat.version() }}
Python Version{{ info.plat.python_version() }}
Processor & Cores{{ info.cpu.count }} x {{ info.cpu.brand }}
System Memory{{ (info.mem.total / (1024*1024*1024)) | round(0,'ceil') |int }}GB ({{info.mem.percent}}% used)
Network Interfaces 38 | {% for iface, snics in info.net.items() %} {% for snic in snics if (snic.family == 2) %} 39 |
  • {{ iface }} - {{ snic.address }}
  • 40 | {% endfor %} {% endfor %} 41 |
    45 |
    46 | 47 | {% endblock %} 48 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | develop-eggs/ 12 | dist/ 13 | downloads/ 14 | eggs/ 15 | .eggs/ 16 | lib/ 17 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | wheels/ 22 | *.egg-info/ 23 | .installed.cfg 24 | *.egg 25 | MANIFEST 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *.cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | local_settings.py 55 | 56 | # Flask stuff: 57 | instance/ 58 | .webassets-cache 59 | 60 | # Scrapy stuff: 61 | .scrapy 62 | 63 | # Sphinx documentation 64 | docs/_build/ 65 | 66 | # PyBuilder 67 | target/ 68 | 69 | # Jupyter Notebook 70 | .ipynb_checkpoints 71 | 72 | # pyenv 73 | .python-version 74 | 75 | # celery beat schedule file 76 | celerybeat-schedule 77 | 78 | # SageMath parsed files 79 | *.sage.py 80 | 81 | # Environments 82 | .env 83 | .venv 84 | env/ 85 | venv/ 86 | ENV/ 87 | env.bak/ 88 | venv.bak/ 89 | 90 | # Spyder project settings 91 | .spyderproject 92 | .spyproject 93 | 94 | # Rope project settings 95 | .ropeproject 96 | 97 | # mkdocs documentation 98 | /site 99 | 100 | # mypy 101 | .mypy_cache/ 102 | .secrets 103 | .env 104 | test-*.xml 105 | tests/node_modules 106 | tests/package*.json 107 | .pytest_cache -------------------------------------------------------------------------------- /.github/workflows/cd-release-aks.yaml: -------------------------------------------------------------------------------- 1 | # 2 | # Deploy to Azure Kubernetes Service 3 | # Using Helm for parameterized deployment 4 | # 5 | 6 | name: CD Release - AKS 7 | 8 | on: 9 | workflow_dispatch: 10 | inputs: 11 | IMAGE_TAG: 12 | description: 'Image tag to be deployed' 13 | required: true 14 | default: 'latest' 15 | 16 | # Note. Required secrets: AZURE_CREDENTIALS 17 | 18 | env: 19 | AKS_NAME: benc 20 | AKS_RES_GROUP: aks 21 | HELM_RELEASE: python 22 | HELM_NAMESPACE: demoapps 23 | INGRESS_DNS_HOST: python-demoapp.kube.benco.io 24 | 25 | jobs: 26 | # 27 | # Deploy to Kubernetes (AKS) 28 | # 29 | deploy-aks: 30 | name: Deploy to AKS with Helm 31 | runs-on: ubuntu-latest 32 | environment: 33 | name: AKS - python-demoapp 34 | url: https://${{ env.INGRESS_DNS_HOST }}/ 35 | 36 | steps: 37 | - name: 'Checkout' 38 | uses: actions/checkout@v2 39 | 40 | - name: 'Login to Azure' 41 | uses: azure/login@v1.3.0 42 | with: 43 | creds: ${{ secrets.AZURE_CREDENTIALS }} 44 | 45 | - name: 'Get AKS credentials' 46 | run: | 47 | az aks get-credentials -n $AKS_NAME -g $AKS_RES_GROUP 48 | 49 | - name: 'Helm release' 50 | run: | 51 | helm repo add benc-uk https://benc-uk.github.io/helm-charts 52 | helm upgrade ${{ env.HELM_RELEASE }} benc-uk/webapp \ 53 | --install \ 54 | --namespace ${{ env.HELM_NAMESPACE }} \ 55 | --values deploy/kubernetes/aks-live.yaml \ 56 | --set image.tag=${{ github.event.inputs.IMAGE_TAG }},ingress.host=${{ env.INGRESS_DNS_HOST }} 57 | 58 | # 59 | # Post deployment testing stage 60 | # 61 | validate-deployment: 62 | name: 'Run Deployment Tests' 63 | needs: deploy-aks 64 | runs-on: ubuntu-latest 65 | environment: 66 | name: AKS - python-demoapp 67 | url: https://${{ env.INGRESS_DNS_HOST }}/ 68 | 69 | steps: 70 | - name: 'Checkout' 71 | uses: actions/checkout@v2 72 | 73 | - name: 'Validate site is running' 74 | run: .github/scripts/url-check.sh -u https://${{ env.INGRESS_DNS_HOST }} -s "Flask" -t 200 75 | 76 | - name: 'Run API tests' 77 | run: | 78 | npm install newman --silent 79 | node_modules/newman/bin/newman.js run tests/postman_collection.json --global-var apphost=${{ env.INGRESS_DNS_HOST }} 80 | -------------------------------------------------------------------------------- /.github/workflows/cd-release-webapp.yaml: -------------------------------------------------------------------------------- 1 | # 2 | # Deploy to Azure App Service as a containerized Web App 3 | # Using Bicep for infrastructure as code 4 | # 5 | 6 | name: CD Release - Webapp 7 | 8 | on: 9 | workflow_dispatch: 10 | inputs: 11 | IMAGE_TAG: 12 | description: 'Image tag to be deployed' 13 | required: true 14 | default: 'latest' 15 | 16 | # Note. Required secrets: AZURE_CREDENTIALS 17 | 18 | env: 19 | IMAGE_REG: ghcr.io 20 | IMAGE_REPO: benc-uk/python-demoapp 21 | APP_NAME: python-demoapp 22 | ARM_SUB_ID: 52512f28-c6ed-403e-9569-82a9fb9fec91 23 | ARM_REGION: westeurope 24 | ARM_RES_GROUP: apps 25 | DOTNET_SYSTEM_GLOBALIZATION_INVARIANT: 1 # Fixes weird Azure CLI + Bicep + GHA bug 26 | 27 | jobs: 28 | # 29 | # Deploy Azure infra (App Service) using Bicep 30 | # 31 | deploy-infra: 32 | environment: 33 | name: App Service - python-demoapp 34 | url: https://${{ env.APP_NAME }}.azurewebsites.net/ 35 | name: 'Deploy Infra' 36 | runs-on: ubuntu-latest 37 | 38 | steps: 39 | - name: 'Checkout' 40 | uses: actions/checkout@v2 41 | 42 | - name: 'Login to Azure' 43 | uses: azure/login@v1.3.0 44 | with: 45 | creds: ${{ secrets.AZURE_CREDENTIALS }} 46 | 47 | - name: 'Create resource group' 48 | run: az group create --name $ARM_RES_GROUP --location $ARM_REGION 49 | 50 | - name: 'Deploy resources' 51 | run: | 52 | az deployment group create --template-file deploy/webapp.bicep -g $ARM_RES_GROUP -p webappName=$APP_NAME \ 53 | webappImage=$IMAGE_REG/$IMAGE_REPO:${{ github.event.inputs.IMAGE_TAG }} \ 54 | releaseInfo="Ref=${{ github.ref }} RunId=${{ github.run_id }}" 55 | 56 | # 57 | # Post deployment testing stage 58 | # 59 | validate-deployment: 60 | name: 'Run Deployment Tests' 61 | needs: deploy-infra 62 | environment: 63 | name: App Service - python-demoapp 64 | url: https://${{ env.APP_NAME }}.azurewebsites.net/ 65 | 66 | runs-on: ubuntu-latest 67 | steps: 68 | - name: 'Checkout' 69 | uses: actions/checkout@v2 70 | 71 | - name: 'Validate site is running' 72 | run: .github/scripts/url-check.sh -u https://${APP_NAME}.azurewebsites.net/ -s "Flask" -t 200 73 | 74 | - name: 'Run API tests' 75 | run: | 76 | npm install newman --silent 77 | node_modules/newman/bin/newman.js run tests/postman_collection.json --global-var apphost=${APP_NAME}.azurewebsites.net 78 | -------------------------------------------------------------------------------- /src/app/apis.py: -------------------------------------------------------------------------------- 1 | from flask import jsonify, current_app as app 2 | import psutil 3 | 4 | olddata = {} 5 | olddata["disk_write"] = 0 6 | olddata["disk_read"] = 0 7 | olddata["net_sent"] = 0 8 | olddata["net_recv"] = 0 9 | 10 | 11 | # 12 | # This route returns real time process information as a REST API 13 | # 14 | @app.route("/api/process") 15 | def api_process(): 16 | apidata = {} 17 | try: 18 | apidata["processes"] = [] 19 | for proc in psutil.process_iter(): 20 | try: 21 | # pinfo = proc.as_dict(attrs=['pid', 'name', 'num_handles', 'num_threads', 'memory_percent', 'cpu_times']) 22 | pinfo = proc.as_dict( 23 | attrs=["pid", "name", "memory_percent", "num_threads", "cpu_times"] 24 | ) 25 | except psutil.NoSuchProcess: 26 | pass 27 | else: 28 | apidata["processes"].append(pinfo) 29 | except Exception: 30 | pass 31 | 32 | return jsonify(apidata) 33 | 34 | 35 | # 36 | # This route returns real time system metrics as a REST API 37 | # 38 | @app.route("/api/monitor") 39 | def api_monitor(): 40 | apidata = {} 41 | apidata["cpu"] = psutil.cpu_percent(interval=0.9) 42 | apidata["mem"] = psutil.virtual_memory().percent 43 | apidata["disk"] = psutil.disk_usage("/").percent 44 | 45 | try: 46 | netio = psutil.net_io_counters() 47 | apidata["net_sent"] = ( 48 | 0 if olddata["net_sent"] == 0 else netio.bytes_sent - olddata["net_sent"] 49 | ) 50 | olddata["net_sent"] = netio.bytes_sent 51 | apidata["net_recv"] = ( 52 | 0 if olddata["net_recv"] == 0 else netio.bytes_recv - olddata["net_recv"] 53 | ) 54 | olddata["net_recv"] = netio.bytes_recv 55 | except Exception: 56 | apidata["net_sent"] = -1 57 | apidata["net_recv"] = -1 58 | 59 | try: 60 | diskio = psutil.disk_io_counters() 61 | apidata["disk_write"] = ( 62 | 0 63 | if olddata["disk_write"] == 0 64 | else diskio.write_bytes - olddata["disk_write"] 65 | ) 66 | olddata["disk_write"] = diskio.write_bytes 67 | apidata["disk_read"] = ( 68 | 0 if olddata["disk_read"] == 0 else diskio.read_bytes - olddata["disk_read"] 69 | ) 70 | olddata["disk_read"] = diskio.read_bytes 71 | except Exception: 72 | apidata["disk_write"] = -1 73 | apidata["disk_read"] = -1 74 | 75 | return jsonify(apidata) 76 | -------------------------------------------------------------------------------- /.github/workflows/ci-build.yaml: -------------------------------------------------------------------------------- 1 | name: CI Build App 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | paths: 7 | - 'src/**' 8 | pull_request: 9 | 10 | env: 11 | IMAGE_REG: ghcr.io 12 | IMAGE_REPO: benc-uk/python-demoapp 13 | 14 | jobs: 15 | test: 16 | name: 'Tests & Linting' 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: 'Checkout' 20 | uses: actions/checkout@v2 21 | 22 | - name: 'Run linting' 23 | run: make lint 24 | 25 | - name: 'Run tests' 26 | run: make test-report 27 | 28 | - name: 'Upload test results' 29 | uses: actions/upload-artifact@v2 30 | # Disabled when running locally with the nektos/act tool 31 | if: ${{ always() && !env.ACT }} 32 | with: 33 | name: test-results 34 | path: ./test-results.xml 35 | 36 | - name: 'Publish test results' 37 | uses: EnricoMi/publish-unit-test-result-action@v1 38 | if: ${{ always() && !env.ACT }} 39 | with: 40 | files: test-results.xml 41 | 42 | build: 43 | name: 'Build & Push Image' 44 | needs: test 45 | runs-on: ubuntu-latest 46 | steps: 47 | - name: 'Checkout' 48 | uses: actions/checkout@v2 49 | 50 | # Nicer than using github runid, I think, will be picked up automatically by make 51 | - name: 'Create datestamp image tag' 52 | run: sudo echo "IMAGE_TAG=$(date +%d-%m-%Y.%H%M)" >> $GITHUB_ENV 53 | 54 | - name: 'Docker build image' 55 | run: make image 56 | 57 | # Only when pushing to default branch (e.g. master or main), then push image to registry 58 | - name: 'Push to container registry' 59 | if: github.ref == 'refs/heads/master' && github.event_name == 'push' 60 | run: | 61 | echo ${{ secrets.GITHUB_TOKEN }} | docker login $IMAGE_REG -u $GITHUB_ACTOR --password-stdin 62 | make push 63 | 64 | - name: 'Trigger AKS release pipeline' 65 | if: github.ref == 'refs/heads/master' 66 | uses: benc-uk/workflow-dispatch@v1 67 | with: 68 | workflow: 'CD Release - AKS' 69 | token: ${{ secrets.GH_PAT }} 70 | inputs: '{ "IMAGE_TAG": "${{ env.IMAGE_TAG }}" }' 71 | 72 | - name: 'Trigger Azure web app release pipeline' 73 | if: github.ref == 'refs/heads/master' 74 | uses: benc-uk/workflow-dispatch@v1 75 | with: 76 | workflow: 'CD Release - Webapp' 77 | token: ${{ secrets.GH_PAT }} 78 | inputs: '{ "IMAGE_TAG": "${{ env.IMAGE_TAG }}" }' 79 | -------------------------------------------------------------------------------- /src/app/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {% block title %}Python DemoApp{% endblock %} 5 | 6 | 7 | 8 | 14 | 15 | 16 | 17 | 47 | 48 | 53 | 54 | 59 | 64 | 65 |
    {% block content %}{% endblock %}
    66 | v1.4.2 [Ben Coleman, 2018-2021]     67 | 68 | 69 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Welcome 2 | 3 | Hello! Thanks for taking an interest in this project and code :) 4 | 5 | Contributions to this project are welcome of course, otherwise it wouldn't reside on GitHub 😃 however there's a few things to be aware of: 6 | 7 | - This is a personal project, it is not maintained by a team or group. 8 | - It might take a long time for the maintainer(s) to reply to issues or review PRs, they will have have a day jobs & might not have looked at the code for a while. 9 | - The code here is likely to not be bullet proof & production grade, there might be a lack of unit tests or other practices missing from the code base. 10 | 11 | # Contributing 12 | 13 | There's several ways of contributing to this project, and effort has been made to make this as easy and transparent as possible, whether it's: 14 | 15 | - Reporting a bug 16 | - Discussing the current state of the code 17 | - Submitting a fix 18 | - Proposing new features 19 | - Becoming a maintainer 20 | 21 | ## All code changes happen though pull requests (PRs) 22 | 23 | Pull requests are the best way to propose changes to the codebase (using the standard [Github Flow](https://guides.github.com/introduction/flow/index.html)). 24 | 25 | Some PR guidance: 26 | 27 | - Please keep PRs small and focused on a single feature or change, with discreet commits. Use multiple PRs if need be. 28 | - If you're thinking of adding a feature via a PR please create an issue first where it can be discussed. 29 | 30 | High level steps: 31 | 32 | 1. Fork the repo and create your branch from `master` or `main`. 33 | 2. If you've changed APIs, update the documentation. 34 | 3. Ensure the test suite (if any) passes (run `make lint`). 35 | 4. Make sure your code lints (run `make lint`). 36 | 5. Issue that pull request! 37 | 38 | ## Any contributions you make will be under the MIT Software License 39 | 40 | In short, when you submit code changes, your submissions are understood to be under the same [MIT License](http://choosealicense.com/licenses/mit/) that covers the project. 41 | 42 | ## Report bugs using Github's issues 43 | 44 | This project uses GitHub issues to track public bugs. Report a bug by [opening a new issue](./issues/new/choose) 45 | 46 | ## Write bug reports with detail, background, and sample code 47 | 48 | **Great Bug Reports** tend to have: 49 | 50 | - A quick summary and/or background 51 | - Steps to reproduce 52 | - Be specific! 53 | - Give sample code if you can. Even if it's a snippet 54 | - What you expected would happen 55 | - What actually happens 56 | - Notes (possibly including why you think this might be happening, or stuff you tried that didn't work) 57 | 58 | ## Use a consistent coding style 59 | 60 | Run `make lint-fix` in order to format the code fix any formatting & linting issues that might be present. A [Prettier](https://prettier.io/) configuration file is included 61 | 62 | # References 63 | 64 | This document was heavily adapted from the open-source contribution guidelines found in [this gist](https://gist.github.com/briandk/3d2e8b3ec8daf5a27a62) 65 | -------------------------------------------------------------------------------- /makefile: -------------------------------------------------------------------------------- 1 | # Used by `image`, `push` & `deploy` targets, override as required 2 | IMAGE_REG ?= ghcr.io 3 | IMAGE_REPO ?= benc-uk/python-demoapp 4 | IMAGE_TAG ?= latest 5 | 6 | # Used by `deploy` target, sets Azure webap defaults, override as required 7 | AZURE_RES_GROUP ?= temp-demoapps 8 | AZURE_REGION ?= uksouth 9 | AZURE_SITE_NAME ?= pythonapp-$(shell git rev-parse --short HEAD) 10 | 11 | # Used by `test-api` target 12 | TEST_HOST ?= localhost:5000 13 | 14 | # Don't change 15 | SRC_DIR := src 16 | 17 | .PHONY: help lint lint-fix image push run deploy undeploy clean test-api .EXPORT_ALL_VARIABLES 18 | .DEFAULT_GOAL := help 19 | 20 | help: ## 💬 This help message 21 | @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' 22 | 23 | lint: venv ## 🔎 Lint & format, will not fix but sets exit code on error 24 | . $(SRC_DIR)/.venv/bin/activate \ 25 | && black --check $(SRC_DIR) \ 26 | && flake8 src/app/ && flake8 src/run.py 27 | 28 | lint-fix: venv ## 📜 Lint & format, will try to fix errors and modify code 29 | . $(SRC_DIR)/.venv/bin/activate \ 30 | && black $(SRC_DIR) 31 | 32 | image: ## 🔨 Build container image from Dockerfile 33 | docker build . --file build/Dockerfile \ 34 | --tag $(IMAGE_REG)/$(IMAGE_REPO):$(IMAGE_TAG) 35 | 36 | push: ## 📤 Push container image to registry 37 | docker push $(IMAGE_REG)/$(IMAGE_REPO):$(IMAGE_TAG) 38 | 39 | run: venv ## 🏃 Run the server locally using Python & Flask 40 | . $(SRC_DIR)/.venv/bin/activate \ 41 | && python src/run.py 42 | 43 | deploy: ## 🚀 Deploy to Azure Web App 44 | az group create --resource-group $(AZURE_RES_GROUP) --location $(AZURE_REGION) -o table 45 | az deployment group create --template-file deploy/webapp.bicep \ 46 | --resource-group $(AZURE_RES_GROUP) \ 47 | --parameters webappName=$(AZURE_SITE_NAME) \ 48 | --parameters webappImage=$(IMAGE_REG)/$(IMAGE_REPO):$(IMAGE_TAG) -o table 49 | @echo "### 🚀 Web app deployed to https://$(AZURE_SITE_NAME).azurewebsites.net/" 50 | 51 | undeploy: ## 💀 Remove from Azure 52 | @echo "### WARNING! Going to delete $(AZURE_RES_GROUP) 😲" 53 | az group delete -n $(AZURE_RES_GROUP) -o table --no-wait 54 | 55 | test: venv ## 🎯 Unit tests for Flask app 56 | . $(SRC_DIR)/.venv/bin/activate \ 57 | && pytest -v 58 | 59 | test-report: venv ## 🎯 Unit tests for Flask app (with report output) 60 | . $(SRC_DIR)/.venv/bin/activate \ 61 | && pytest -v --junitxml=test-results.xml 62 | 63 | test-api: .EXPORT_ALL_VARIABLES ## 🚦 Run integration API tests, server must be running 64 | cd tests \ 65 | && npm install newman \ 66 | && ./node_modules/.bin/newman run ./postman_collection.json --env-var apphost=$(TEST_HOST) 67 | 68 | clean: ## 🧹 Clean up project 69 | rm -rf $(SRC_DIR)/.venv 70 | rm -rf tests/node_modules 71 | rm -rf tests/package* 72 | rm -rf test-results.xml 73 | rm -rf $(SRC_DIR)/app/__pycache__ 74 | rm -rf $(SRC_DIR)/app/tests/__pycache__ 75 | rm -rf .pytest_cache 76 | rm -rf $(SRC_DIR)/.pytest_cache 77 | 78 | # ============================================================================ 79 | 80 | venv: $(SRC_DIR)/.venv/touchfile 81 | 82 | $(SRC_DIR)/.venv/touchfile: $(SRC_DIR)/requirements.txt 83 | python3 -m venv $(SRC_DIR)/.venv 84 | . $(SRC_DIR)/.venv/bin/activate; pip install -Ur $(SRC_DIR)/requirements.txt 85 | touch $(SRC_DIR)/.venv/touchfile 86 | -------------------------------------------------------------------------------- /.github/scripts/url-check.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | declare -i time=60 4 | declare -i delay=5 5 | declare -i count=5 6 | declare -i okCount=0 7 | declare -i elapsed=0 8 | declare isUp="false" 9 | 10 | # Display usage 11 | usage(){ 12 | echo -e "\e[32m╭──────────────────────────────────────────────────────────────╮" 13 | echo -e "│ 🌍 \e[94murl-check.sh \e[96mCheck URL endpoint for HTTP responses 🚀\e[32m │" 14 | echo -e "╰──────────────────────────────────────────────────────────────╯" 15 | echo -e "\n\e[95mParameters:\e[37m" 16 | echo -e " -u, --url \e[33mURL to check (required)\e[37m" 17 | echo -e " [-t, --time] \e[33mMaximum number of seconds to poll for \e[92m(default: 60)\e[37m" 18 | echo -e " [-d, --delay] \e[33mDelay in seconds between requests \e[92m(default: 5)\e[37m" 19 | echo -e " [-c, --count] \e[33mHow many successes to receive before exiting \e[92m(default: 5)\e[37m" 20 | echo -e " [-s, --search] \e[33mOptional content check, grep for this string in HTTP body \e[92m(default: none)\e[37m" 21 | echo -e " [-h, --help] \e[33mShow this help text\e[37m" 22 | } 23 | 24 | OPTS=`getopt -o u:t:d:c:s:h --long url:,time:,delay:,count:,search:,help -n 'parse-options' -- "$@"` 25 | if [ $? != 0 ] ; then echo "Failed parsing options." >&2 ; usage; exit 1 ; fi 26 | eval set -- "$OPTS" 27 | 28 | while true; do 29 | case "$1" in 30 | -u | --url ) url="$2"; shift; shift;; 31 | -t | --time ) time="$2"; shift; shift;; 32 | -d | --delay ) delay="$2"; shift; shift;; 33 | -c | --count ) count="$2"; shift; shift;; 34 | -s | --search ) search="$2"; shift; shift;; 35 | -h | --help ) HELP=true; shift ;; 36 | -- ) shift; break ;; 37 | * ) break ;; 38 | esac 39 | done 40 | 41 | if [[ ${HELP} = true ]] || [ -z ${url} ]; then 42 | usage 43 | exit 0 44 | fi 45 | 46 | # Check for impossible parameter combination ie. too many checks and delays in given time limit 47 | if (( $delay * $count > $time)); then 48 | echo -e "\e[31m### Error! The time ($time) provided is too short given the delay ($delay) and count ($count)\e[0m" 49 | exit 1 50 | fi 51 | 52 | echo -e "\n\e[36m### Polling \e[33m$url\e[36m for ${time}s, to get $count OK results, with a ${delay}s delay\e[0m\n" 53 | 54 | # Generate tmp filename 55 | tmpfile=$(echo $url | md5sum) 56 | 57 | # Main loop 58 | while [ "$isUp" != "true" ] 59 | do 60 | # Break out of loop if max time has elapsed 61 | if (( $elapsed >= $time )); then break; fi 62 | timestamp=$(date "+%Y/%m/%d %H:%M:%S") 63 | 64 | # Main CURL test, output to file and return http_code 65 | urlstatus=$(curl -o "/tmp/$tmpfile" --silent --write-out '%{http_code}' "$url") 66 | 67 | if [ $urlstatus -eq 000 ]; then 68 | # Code 000 means DNS, network error or malformed URL 69 | msg="\e[95mSite not found or other error" 70 | else 71 | if (( $urlstatus >= 200 )) && (( $urlstatus < 300 )); then 72 | # Check returned content with grep if check specified 73 | if [ ! -z "$search" ]; then 74 | grep -q "$search" "/tmp/$tmpfile" 75 | # Only count as a success if string grep passed 76 | if (( $? == 0)); then 77 | ((okCount=okCount + 1)) 78 | msg="✅ \e[32m$urlstatus 🔍 Content check for '$search' passed" 79 | else 80 | msg="❌ \e[91m$urlstatus 🔍 Content check for '$search' failed" 81 | fi 82 | else 83 | # Good status code 84 | ((okCount=okCount + 1)) 85 | msg="✅ \e[32m$urlstatus " 86 | fi 87 | 88 | if (( $okCount >= $count )); then isUp="true"; fi 89 | else 90 | # Bad status code 91 | msg="❌ \e[91m$urlstatus " 92 | fi 93 | fi 94 | 95 | # Output message + timestamp then delay 96 | echo -e "### $timestamp: $msg\e[0m" 97 | sleep $delay 98 | ((elapsed=elapsed + delay)) 99 | done 100 | 101 | rm "/tmp/$tmpfile" 102 | # Final result check 103 | if [ "$isUp" == "true" ]; then 104 | echo -e "\n\e[32m### Result: $url is UP! 🤩\e[0m" 105 | exit 0 106 | else 107 | echo -e "\n\e[91m### Result: $url is DOWN! 😢\e[0m" 108 | exit 1 109 | fi 110 | -------------------------------------------------------------------------------- /src/app/static/js/monitor.js: -------------------------------------------------------------------------------- 1 | var data_memcpu; 2 | var data_disk; 3 | var data_net; 4 | var chart_memcpu; 5 | var chart_disk; 6 | var chart_net; 7 | var options_percent; 8 | var options_io; 9 | 10 | var refresh_sec = 3.0; 11 | 12 | function initCharts() { 13 | data_memcpu = google.visualization.arrayToDataTable([ 14 | ['Label', 'Value'], 15 | ['CPU', 0], 16 | ['Memory', 0], 17 | ]); 18 | data_disk = google.visualization.arrayToDataTable([ 19 | ['Label', 'Value'], 20 | ['Disk read', 0], 21 | ['Disk write', 0], 22 | ]); 23 | data_net = google.visualization.arrayToDataTable([ 24 | ['Label', 'Value'], 25 | ['Net sent', 0], 26 | ['Net recv', 0], 27 | ]); 28 | 29 | options_percent = { 30 | //width: 1200, height: 600, 31 | redFrom: 90, redTo: 100, 32 | yellowFrom: 75, yellowTo: 90, 33 | greenFrom: 0, greenTo: 75, 34 | minorTicks: 5, animation:{ duration: 950, easing: 'inAndOut' } 35 | }; 36 | options_io = { 37 | max: 200, 38 | minorTicks: 10, animation:{ duration: 950, easing: 'inAndOut' } 39 | }; 40 | 41 | chart_memcpu = new google.visualization.Gauge(document.getElementById('chart1')); 42 | chart_disk = new google.visualization.Gauge(document.getElementById('chart2')); 43 | chart_net = new google.visualization.Gauge(document.getElementById('chart3')); 44 | 45 | refreshCharts(); 46 | refreshProcesses(); 47 | setRefresh(refresh_sec); 48 | 49 | $('#refrate').text(refresh_sec); 50 | $('#refslider').val(refresh_sec); 51 | $(document).on('input', '#refslider', function() { 52 | setRefresh($(this).val()) 53 | }); 54 | } 55 | 56 | var proc_timer; 57 | var chart_timer; 58 | function setRefresh(new_secs) { 59 | refresh_sec = parseFloat(new_secs); 60 | $('#refrate').text(refresh_sec); 61 | clearInterval(proc_timer); 62 | clearInterval(chart_timer); 63 | proc_timer = setInterval(function () { 64 | refreshProcesses(); 65 | }, refresh_sec * 1000); 66 | chart_timer = setInterval(function () { 67 | refreshCharts(); 68 | }, refresh_sec * 1000); 69 | } 70 | function refreshCharts() { 71 | $.ajax({ 72 | url: '/api/monitor', 73 | type: 'GET', 74 | dataType: 'json', 75 | success: function (apidata) { 76 | //console.dir(apidata); 77 | data_memcpu.setValue(0, 1, apidata.cpu); 78 | data_memcpu.setValue(1, 1, apidata.mem); 79 | data_disk.setValue(0, 1, apidata.disk_read / (1024000*refresh_sec)); 80 | data_disk.setValue(1, 1, apidata.disk_write / (1024000*refresh_sec)); 81 | data_net.setValue(0, 1, apidata.net_sent / (1024000*refresh_sec)); 82 | data_net.setValue(1, 1, apidata.net_recv / (1024000*refresh_sec)); 83 | 84 | chart_memcpu.draw(data_memcpu, options_percent); 85 | chart_disk.draw(data_disk, options_io); 86 | chart_net.draw(data_net, options_io); 87 | }, 88 | error: function (request, error) { 89 | console.log("API Request: " + JSON.stringify(request)); 90 | } 91 | }); 92 | } 93 | 94 | function refreshProcesses() { 95 | $.ajax({ 96 | url: '/api/process', 97 | type: 'GET', 98 | dataType: 'json', 99 | success: function (apidata) { 100 | $('#process_tab').empty(); 101 | $('#proc_count').text(apidata.processes.length); 102 | for(var p = 0; p < apidata.processes.length; p++) { 103 | $('#process_tab').append(''+apidata.processes[p].pid+''+ 104 | ''+apidata.processes[p].name+''+ 105 | ''+apidata.processes[p].memory_percent.toFixed(2)+''+ 106 | ''+(apidata.processes[p].cpu_times[0]+apidata.processes[p].cpu_times[1]).toFixed(2)+''+ 107 | ''+apidata.processes[p].num_threads+''+ 108 | '') 109 | } 110 | var myTH = document.getElementsByTagName("th")[2]; 111 | sorttable.innerSortFunction.apply(myTH, []); 112 | }, 113 | error: function (request, error) { 114 | console.log("API Request: " + JSON.stringify(request)); 115 | } 116 | }); 117 | } -------------------------------------------------------------------------------- /tests/postman_collection.json: -------------------------------------------------------------------------------- 1 | { 2 | "info": { 3 | "_postman_id": "d758a295-d6e7-40ac-a6ba-d9312cf9bbe5", 4 | "name": "Python Demoapp", 5 | "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" 6 | }, 7 | "item": [ 8 | { 9 | "name": "Check Home Page", 10 | "event": [ 11 | { 12 | "listen": "test", 13 | "script": { 14 | "exec": [ 15 | "pm.test(\"Home Page: Successful GET request\", function () {", 16 | " pm.response.to.be.ok;", 17 | "});", 18 | "", 19 | "pm.test(\"Home Page: Response valid & HTML body\", function () {", 20 | " pm.response.to.be.withBody;", 21 | " pm.expect(pm.response.headers.get('Content-Type')).to.contain('text/html');", 22 | "});", 23 | "", 24 | "pm.test(\"Home Page: Check content\", function () {", 25 | " pm.expect(pm.response.text()).to.include('Python');", 26 | "});", 27 | "" 28 | ], 29 | "type": "text/javascript" 30 | } 31 | } 32 | ], 33 | "request": { 34 | "method": "GET", 35 | "header": [], 36 | "url": { 37 | "raw": "http://{{apphost}}/", 38 | "protocol": "http", 39 | "host": ["{{apphost}}"], 40 | "path": [""] 41 | } 42 | }, 43 | "response": [] 44 | }, 45 | { 46 | "name": "Check Info Page", 47 | "event": [ 48 | { 49 | "listen": "test", 50 | "script": { 51 | "exec": [ 52 | "pm.test(\"Info Page: Successful GET request\", function () {", 53 | " pm.response.to.be.ok;", 54 | "});", 55 | "", 56 | "pm.test(\"Info Page: Response valid & HTML body\", function () {", 57 | " pm.response.to.be.withBody;", 58 | " pm.expect(pm.response.headers.get('Content-Type')).to.contain('text/html');", 59 | "});", 60 | "", 61 | "pm.test(\"Info Page: Check content\", function () {", 62 | " pm.expect(pm.response.text()).to.include('Network Interfaces');", 63 | "});", 64 | "" 65 | ], 66 | "type": "text/javascript" 67 | } 68 | } 69 | ], 70 | "request": { 71 | "method": "GET", 72 | "header": [], 73 | "url": { 74 | "raw": "http://{{apphost}}/info", 75 | "protocol": "http", 76 | "host": ["{{apphost}}"], 77 | "path": ["info"] 78 | } 79 | }, 80 | "response": [] 81 | }, 82 | { 83 | "name": "Check Process API", 84 | "event": [ 85 | { 86 | "listen": "test", 87 | "script": { 88 | "exec": [ 89 | "pm.test(\"Process API: Successful GET request\", function () {", 90 | " pm.response.to.be.ok;", 91 | "});", 92 | "", 93 | "pm.test(\"Process API: Response valid & JSON body\", function () {", 94 | " pm.response.to.be.withBody;", 95 | " pm.response.to.be.json;", 96 | "});", 97 | "", 98 | "pm.test(\"Process API: Check API response\", function () {", 99 | " var processData = pm.response.json();", 100 | " pm.expect(processData.processes).to.be.an('array')", 101 | " pm.expect(processData.processes[0].name).to.be.an('string')", 102 | " pm.expect(processData.processes[0].memory_percent).to.be.an('number')", 103 | " pm.expect(processData.processes[0].pid).to.be.an('number')", 104 | "});", 105 | "" 106 | ], 107 | "type": "text/javascript" 108 | } 109 | } 110 | ], 111 | "request": { 112 | "method": "GET", 113 | "header": [], 114 | "url": { 115 | "raw": "http://{{apphost}}/api/process", 116 | "protocol": "http", 117 | "host": ["{{apphost}}"], 118 | "path": ["api", "process"] 119 | } 120 | }, 121 | "response": [] 122 | } 123 | ], 124 | "event": [ 125 | { 126 | "listen": "prerequest", 127 | "script": { 128 | "type": "text/javascript", 129 | "exec": [""] 130 | } 131 | }, 132 | { 133 | "listen": "test", 134 | "script": { 135 | "type": "text/javascript", 136 | "exec": [""] 137 | } 138 | } 139 | ] 140 | } 141 | -------------------------------------------------------------------------------- /src/app/static/img/docker-whale.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Python Flask - Demo Web Application 2 | 3 | This is a simple Python Flask web application. The app provides system information and a realtime monitoring screen with dials showing CPU, memory, IO and process information. 4 | 5 | The app has been designed with cloud native demos & containers in mind, in order to provide a real working application for deployment, something more than "hello-world" but with the minimum of pre-reqs. It is not intended as a complete example of a fully functioning architecture or complex software design. 6 | 7 | Typical uses would be deployment to Kubernetes, demos of Docker, CI/CD (build pipelines are provided), deployment to cloud (Azure) monitoring, auto-scaling 8 | 9 | ## Screenshot 10 | 11 | ![screen](https://user-images.githubusercontent.com/14982936/30533171-db17fccc-9c4f-11e7-8862-eb8c148fedea.png) 12 | 13 | # Status 14 | 15 | ![](https://img.shields.io/github/last-commit/benc-uk/python-demoapp) ![](https://img.shields.io/github/release-date/benc-uk/python-demoapp) ![](https://img.shields.io/github/v/release/benc-uk/python-demoapp) ![](https://img.shields.io/github/commit-activity/y/benc-uk/python-demoapp) 16 | 17 | Live instances: 18 | 19 | [![](https://img.shields.io/website?label=Hosted%3A%20Azure%20App%20Service&up_message=online&url=https%3A%2F%2Fpython-demoapp.azurewebsites.net%2F)](https://python-demoapp.azurewebsites.net/) 20 | [![](https://img.shields.io/website?label=Hosted%3A%20Kubernetes&up_message=online&url=https%3A%2F%2Fpython-demoapp.kube.benco.io%2F)](https://python-demoapp.kube.benco.io/) 21 | 22 | ## Building & Running Locally 23 | 24 | ### Pre-reqs 25 | 26 | - Be using Linux, WSL or MacOS, with bash, make etc 27 | - [Python 3.8+](https://www.python.org/downloads/) - for running locally, linting, running tests etc 28 | - [Docker](https://docs.docker.com/get-docker/) - for running as a container, or image build and push 29 | - [Azure CLI](https://docs.microsoft.com/en-us/cli/azure/install-azure-cli-linux) - for deployment to Azure 30 | 31 | Clone the project to any directory where you do development work 32 | 33 | ``` 34 | git clone https://github.com/benc-uk/python-demoapp.git 35 | ``` 36 | 37 | ### Makefile 38 | 39 | A standard GNU Make file is provided to help with running and building locally. 40 | 41 | ```text 42 | help 💬 This help message 43 | lint 🔎 Lint & format, will not fix but sets exit code on error 44 | lint-fix 📜 Lint & format, will try to fix errors and modify code 45 | image 🔨 Build container image from Dockerfile 46 | push 📤 Push container image to registry 47 | run 🏃 Run the server locally using Python & Flask 48 | deploy 🚀 Deploy to Azure Web App 49 | undeploy 💀 Remove from Azure 50 | test 🎯 Unit tests for Flask app 51 | test-report 🎯 Unit tests for Flask app (with report output) 52 | test-api 🚦 Run integration API tests, server must be running 53 | clean 🧹 Clean up project 54 | ``` 55 | 56 | Make file variables and default values, pass these in when calling `make`, e.g. `make image IMAGE_REPO=blah/foo` 57 | 58 | | Makefile Variable | Default | 59 | | ----------------- | ---------------------- | 60 | | IMAGE_REG | ghcr.io | 61 | | IMAGE_REPO | benc-uk/python-demoapp | 62 | | IMAGE_TAG | latest | 63 | | AZURE_RES_GROUP | temp-demoapps | 64 | | AZURE_REGION | uksouth | 65 | | AZURE_SITE_NAME | pythonapp-{git-sha} | 66 | 67 | The app runs under Flask and listens on port 5000 by default, this can be changed with the `PORT` environmental variable. 68 | 69 | # Containers 70 | 71 | Public container image is [available on GitHub Container Registry](https://github.com/users/benc-uk/packages/container/package/python-demoapp) 72 | 73 | Run in a container with: 74 | 75 | ```bash 76 | docker run --rm -it -p 5000:5000 ghcr.io/benc-uk/python-demoapp:latest 77 | ``` 78 | 79 | Should you want to build your own container, use `make image` and the above variables to customise the name & tag. 80 | 81 | ## Kubernetes 82 | 83 | The app can easily be deployed to Kubernetes using Helm, see [deploy/kubernetes/readme.md](deploy/kubernetes/readme.md) for details 84 | 85 | # GitHub Actions CI/CD 86 | 87 | A working set of CI and CD release GitHub Actions workflows are provided `.github/workflows/`, automated builds are run in GitHub hosted runners 88 | 89 | ### [GitHub Actions](https://github.com/benc-uk/python-demoapp/actions) 90 | 91 | [![](https://img.shields.io/github/workflow/status/benc-uk/python-demoapp/CI%20Build%20App)](https://github.com/benc-uk/python-demoapp/actions?query=workflow%3A%22CI+Build+App%22) 92 | 93 | [![](https://img.shields.io/github/workflow/status/benc-uk/python-demoapp/CD%20Release%20-%20AKS?label=release-kubernetes)](https://github.com/benc-uk/python-demoapp/actions?query=workflow%3A%22CD+Release+-+AKS%22) 94 | 95 | [![](https://img.shields.io/github/workflow/status/benc-uk/python-demoapp/CD%20Release%20-%20Webapp?label=release-azure)](https://github.com/benc-uk/python-demoapp/actions?query=workflow%3A%22CD+Release+-+Webapp%22) 96 | 97 | [![](https://img.shields.io/github/last-commit/benc-uk/python-demoapp)](https://github.com/benc-uk/python-demoapp/commits/master) 98 | 99 | ## Running in Azure App Service (Linux) 100 | 101 | If you want to deploy to an Azure Web App as a container (aka Linux Web App), a Bicep template is provided in the [deploy](deploy/) directory 102 | 103 | For a super quick deployment, use `make deploy` which will deploy to a resource group, temp-demoapps and use the git ref to create a unique site name 104 | 105 | ```bash 106 | make deploy 107 | ``` 108 | 109 | ## Running in Azure App Service (Windows) 110 | 111 | Just don't, it's awful 112 | -------------------------------------------------------------------------------- /src/app/static/js/sorttable.js: -------------------------------------------------------------------------------- 1 | /* 2 | SortTable 3 | version 2 4 | 7th April 2007 5 | Stuart Langridge, http://www.kryogenix.org/code/browser/sorttable/ 6 | 7 | Instructions: 8 | Download this file 9 | Add to your HTML 10 | Add class="sortable" to any table you'd like to make sortable 11 | Click on the headers to sort 12 | 13 | Thanks to many, many people for contributions and suggestions. 14 | Licenced as X11: http://www.kryogenix.org/code/browser/licence.html 15 | This basically means: do what you want with it. 16 | */ 17 | 18 | 19 | var stIsIE = /*@cc_on!@*/false; 20 | 21 | sorttable = { 22 | init: function() { 23 | // quit if this function has already been called 24 | if (arguments.callee.done) return; 25 | // flag this function so we don't do the same thing twice 26 | arguments.callee.done = true; 27 | // kill the timer 28 | if (_timer) clearInterval(_timer); 29 | 30 | if (!document.createElement || !document.getElementsByTagName) return; 31 | 32 | sorttable.DATE_RE = /^(\d\d?)[\/\.-](\d\d?)[\/\.-]((\d\d)?\d\d)$/; 33 | 34 | forEach(document.getElementsByTagName('table'), function(table) { 35 | if (table.className.search(/\bsortable\b/) != -1) { 36 | sorttable.makeSortable(table); 37 | } 38 | }); 39 | 40 | }, 41 | 42 | makeSortable: function(table) { 43 | if (table.getElementsByTagName('thead').length == 0) { 44 | // table doesn't have a tHead. Since it should have, create one and 45 | // put the first table row in it. 46 | the = document.createElement('thead'); 47 | the.appendChild(table.rows[0]); 48 | table.insertBefore(the,table.firstChild); 49 | } 50 | // Safari doesn't support table.tHead, sigh 51 | if (table.tHead == null) table.tHead = table.getElementsByTagName('thead')[0]; 52 | 53 | if (table.tHead.rows.length != 1) return; // can't cope with two header rows 54 | 55 | // Sorttable v1 put rows with a class of "sortbottom" at the bottom (as 56 | // "total" rows, for example). This is B&R, since what you're supposed 57 | // to do is put them in a tfoot. So, if there are sortbottom rows, 58 | // for backwards compatibility, move them to tfoot (creating it if needed). 59 | sortbottomrows = []; 60 | for (var i=0; i5' : ' ▴'; 104 | this.appendChild(sortrevind); 105 | return; 106 | } 107 | if (this.className.search(/\bsorttable_sorted_reverse\b/) != -1) { 108 | // if we're already sorted by this column in reverse, just 109 | // re-reverse the table, which is quicker 110 | sorttable.reverse(this.sorttable_tbody); 111 | this.className = this.className.replace('sorttable_sorted_reverse', 112 | 'sorttable_sorted'); 113 | this.removeChild(document.getElementById('sorttable_sortrevind')); 114 | sortfwdind = document.createElement('span'); 115 | sortfwdind.id = "sorttable_sortfwdind"; 116 | sortfwdind.innerHTML = stIsIE ? ' 6' : ' ▾'; 117 | this.appendChild(sortfwdind); 118 | return; 119 | } 120 | 121 | // remove sorttable_sorted classes 122 | theadrow = this.parentNode; 123 | forEach(theadrow.childNodes, function(cell) { 124 | if (cell.nodeType == 1) { // an element 125 | cell.className = cell.className.replace('sorttable_sorted_reverse',''); 126 | cell.className = cell.className.replace('sorttable_sorted',''); 127 | } 128 | }); 129 | sortfwdind = document.getElementById('sorttable_sortfwdind'); 130 | if (sortfwdind) { sortfwdind.parentNode.removeChild(sortfwdind); } 131 | sortrevind = document.getElementById('sorttable_sortrevind'); 132 | if (sortrevind) { sortrevind.parentNode.removeChild(sortrevind); } 133 | 134 | this.className += ' sorttable_sorted'; 135 | sortfwdind = document.createElement('span'); 136 | sortfwdind.id = "sorttable_sortfwdind"; 137 | sortfwdind.innerHTML = stIsIE ? ' 6' : ' ▾'; 138 | this.appendChild(sortfwdind); 139 | 140 | // build an array to sort. This is a Schwartzian transform thing, 141 | // i.e., we "decorate" each row with the actual sort key, 142 | // sort based on the sort keys, and then put the rows back in order 143 | // which is a lot faster because you only do getInnerText once per row 144 | row_array = []; 145 | col = this.sorttable_columnindex; 146 | rows = this.sorttable_tbody.rows; 147 | for (var j=0; j 12) { 185 | // definitely dd/mm 186 | return sorttable.sort_ddmm; 187 | } else if (second > 12) { 188 | return sorttable.sort_mmdd; 189 | } else { 190 | // looks like a date, but we can't tell which, so assume 191 | // that it's dd/mm (English imperialism!) and keep looking 192 | sortfn = sorttable.sort_ddmm; 193 | } 194 | } 195 | } 196 | } 197 | return sortfn; 198 | }, 199 | 200 | getInnerText: function(node) { 201 | // gets the text we want to use for sorting for a cell. 202 | // strips leading and trailing whitespace. 203 | // this is *not* a generic getInnerText function; it's special to sorttable. 204 | // for example, you can override the cell text with a customkey attribute. 205 | // it also gets .value for fields. 206 | 207 | if (!node) return ""; 208 | 209 | hasInputs = (typeof node.getElementsByTagName == 'function') && 210 | node.getElementsByTagName('input').length; 211 | 212 | if (node.getAttribute("sorttable_customkey") != null) { 213 | return node.getAttribute("sorttable_customkey"); 214 | } 215 | else if (typeof node.textContent != 'undefined' && !hasInputs) { 216 | return node.textContent.replace(/^\s+|\s+$/g, ''); 217 | } 218 | else if (typeof node.innerText != 'undefined' && !hasInputs) { 219 | return node.innerText.replace(/^\s+|\s+$/g, ''); 220 | } 221 | else if (typeof node.text != 'undefined' && !hasInputs) { 222 | return node.text.replace(/^\s+|\s+$/g, ''); 223 | } 224 | else { 225 | switch (node.nodeType) { 226 | case 3: 227 | if (node.nodeName.toLowerCase() == 'input') { 228 | return node.value.replace(/^\s+|\s+$/g, ''); 229 | } 230 | case 4: 231 | return node.nodeValue.replace(/^\s+|\s+$/g, ''); 232 | break; 233 | case 1: 234 | case 11: 235 | var innerText = ''; 236 | for (var i = 0; i < node.childNodes.length; i++) { 237 | innerText += sorttable.getInnerText(node.childNodes[i]); 238 | } 239 | return innerText.replace(/^\s+|\s+$/g, ''); 240 | break; 241 | default: 242 | return ''; 243 | } 244 | } 245 | }, 246 | 247 | reverse: function(tbody) { 248 | // reverse the rows in a tbody 249 | newrows = []; 250 | for (var i=0; i=0; i--) { 254 | tbody.appendChild(newrows[i]); 255 | } 256 | delete newrows; 257 | }, 258 | 259 | /* sort functions 260 | each sort function takes two parameters, a and b 261 | you are comparing a[0] and b[0] */ 262 | sort_numeric: function(a,b) { 263 | aa = parseFloat(a[0].replace(/[^0-9.-]/g,'')); 264 | if (isNaN(aa)) aa = 0; 265 | bb = parseFloat(b[0].replace(/[^0-9.-]/g,'')); 266 | if (isNaN(bb)) bb = 0; 267 | return aa-bb; 268 | }, 269 | sort_alpha: function(a,b) { 270 | if (a[0]==b[0]) return 0; 271 | if (a[0] 0 ) { 317 | var q = list[i]; list[i] = list[i+1]; list[i+1] = q; 318 | swap = true; 319 | } 320 | } // for 321 | t--; 322 | 323 | if (!swap) break; 324 | 325 | for(var i = t; i > b; --i) { 326 | if ( comp_func(list[i], list[i-1]) < 0 ) { 327 | var q = list[i]; list[i] = list[i-1]; list[i-1] = q; 328 | swap = true; 329 | } 330 | } // for 331 | b++; 332 | 333 | } // while(swap) 334 | } 335 | } 336 | 337 | /* ****************************************************************** 338 | Supporting functions: bundled here to avoid depending on a library 339 | ****************************************************************** */ 340 | 341 | // Dean Edwards/Matthias Miller/John Resig 342 | 343 | /* for Mozilla/Opera9 */ 344 | if (document.addEventListener) { 345 | document.addEventListener("DOMContentLoaded", sorttable.init, false); 346 | } 347 | 348 | /* for Internet Explorer */ 349 | /*@cc_on @*/ 350 | /*@if (@_win32) 351 | document.write("