├── .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 | PID
22 | Name
23 | Mem
24 | CPU Time
25 | Threads
26 |
27 |
28 |
29 |
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 |
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 | Hostname
9 | {{ info.plat.node() }}
10 |
11 |
12 | Boot Time
13 | {{ info.boottime }}
14 |
15 |
16 | OS Platform
17 | {{ info.plat.system() }}
18 |
19 |
20 | OS Version
21 | {{ info.plat.version() }}
22 |
23 |
24 | Python Version
25 | {{ info.plat.python_version() }}
26 |
27 |
28 | Processor & Cores
29 | {{ info.cpu.count }} x {{ info.cpu.brand }}
30 |
31 |
32 | System Memory
33 | {{ (info.mem.total / (1024*1024*1024)) | round(0,'ceil') |int }}GB ({{info.mem.percent}}% used)
34 |
35 |
36 | Network Interfaces
37 |
38 | {% for iface, snics in info.net.items() %} {% for snic in snics if (snic.family == 2) %}
39 | {{ iface }} - {{ snic.address }}
40 | {% endfor %} {% endfor %}
41 |
42 |
43 |
44 |
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 |
18 |
19 |
20 | Python Demo
21 |
22 |
23 |
32 |
33 |
34 |
35 |
46 |
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 | 
12 |
13 | # Status
14 |
15 |    
16 |
17 | Live instances:
18 |
19 | [](https://python-demoapp.azurewebsites.net/)
20 | [](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://github.com/benc-uk/python-demoapp/actions?query=workflow%3A%22CI+Build+App%22)
92 |
93 | [](https://github.com/benc-uk/python-demoapp/actions?query=workflow%3A%22CD+Release+-+AKS%22)
94 |
95 | [](https://github.com/benc-uk/python-demoapp/actions?query=workflow%3A%22CD+Release+-+Webapp%22)
96 |
97 | [](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("