Plane.so, Open-Source Pjm: Part 3: Dockerfile and Kubernetes

Published: Jan 4, 2024 by Isaac Johnson

At the end of Plane.so Part 2 we started to cover using the REST API to fetch issues and create them in Github Workflows. I wanted to keep building on the last article and show user issues in a more straightforward way.

Today we’ll cover building a containerized Python app that can use the Plane.so REST endpoint to report back on our issues. We’ll start simple and build from there to the point we have a solid app that can run either in Docker or in Kubernetes.

Let’s get started!

Dockerfile

There is a risk, of course, of spammers using a public report for negative ends. I’ll need to keep an eye on that.

I’m going to start simple and pack the plane API key in the container. This certainly would not be a thing I would push to a public CR

import os
import subprocess
import json
from flask import Flask, render_template_string

app = Flask(__name__)

@app.route('/')
def home():
    cmd = '''curl -X GET -H "Content-Type: application/json" -H "X-API-Key: plane_api_aabbccddeeffaabbccddeeffaabb" "https://api.plane.so/api/v1/workspaces/tpk/projects/9ca799e6-52c4-4a9e-8b40-461eef4f57e9/issues/"'''
    output = subprocess.check_output(cmd, shell=True)
    data = json.loads(output)

    table_data = ""
    for item in data:
        table_data += f"<tr><td>{item['name']}</td><td>{item['description_html']}</td></tr>"

    html = f"""
    <table>
        <thead>
            <tr>
                <th colspan="2">User Requests</th>
            </tr>
        </thead>
        <tbody>
            {table_data}
        </tbody>
    </table>
    """
    return render_template_string(html)

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=80)

This then needs a requirements.txt for Flask

$ cat requirements.txt
Flask==1.1.2

Lastly, a Dockerfile to run it

# Use an official Python runtime as a parent image
FROM python:3.7-slim

# Set the working directory in the container to /app
WORKDIR /app

# Add the current directory contents into the container at /app
ADD . /app

# Add curl
RUN apt update && apt install -y curl

# Install any needed packages specified in requirements.txt
RUN pip install --trusted-host pypi.python.org -r requirements.txt

# Make port 80 available to the world outside this container
EXPOSE 80

# Run app.py when the container launches
CMD ["python", "app.py"]

I’ll then do a docker build

$ docker build -t pyplanelist:0.0.1 .
[+] Building 12.8s (10/10) FINISHED
 => [internal] load build definition from Dockerfile                                                                                                                              0.0s
 => => transferring dockerfile: 578B                                                                                                                                              0.0s
 => [internal] load .dockerignore                                                                                                                                                 0.0s
 => => transferring context: 2B                                                                                                                                                   0.0s
 => [internal] load metadata for docker.io/library/python:3.7-slim                                                                                                                1.8s
 => [1/5] FROM docker.io/library/python:3.7-slim@sha256:b53f496ca43e5af6994f8e316cf03af31050bf7944e0e4a308ad86c001cf028b                                                          2.3s
 => => resolve docker.io/library/python:3.7-slim@sha256:b53f496ca43e5af6994f8e316cf03af31050bf7944e0e4a308ad86c001cf028b                                                          0.0s
 => => sha256:b53f496ca43e5af6994f8e316cf03af31050bf7944e0e4a308ad86c001cf028b 1.86kB / 1.86kB                                                                                    0.0s
 => => sha256:ffd28e36ef37b3a4a24f6a771a48d7c5499ea42d6309ac911a3f699e122060be 1.37kB / 1.37kB                                                                                    0.0s
 => => sha256:a255ffcb469f2ec40f2958a76beb0c2bbebfe92ce9af67a9b48d84b4cb695ac8 7.54kB / 7.54kB                                                                                    0.0s
 => => sha256:a803e7c4b030119420574a882a52b6431e160fceb7620f61b525d49bc2d58886 29.12MB / 29.12MB                                                                                  0.8s
 => => sha256:bf3336e84c8e00632cdea35b18fec9a5691711bdc8ac885e3ef54a3d5ff500ba 3.50MB / 3.50MB                                                                                    0.5s
 => => sha256:8973eb85275f19b8d72c6047560629116ad902397e5c1885b2508788197de28b 11.38MB / 11.38MB                                                                                  0.9s
 => => sha256:f9afc3cc0135aad884dad502f28a5b3d8cd32565116131da818ebf2ea6d46095 244B / 244B                                                                                        0.6s
 => => sha256:39312d8b4ab77de264678427265a2668073675bb8666caf723da18c9e4b7e3fc 3.13MB / 3.13MB                                                                                    0.8s
 => => extracting sha256:a803e7c4b030119420574a882a52b6431e160fceb7620f61b525d49bc2d58886                                                                                         0.7s
 => => extracting sha256:bf3336e84c8e00632cdea35b18fec9a5691711bdc8ac885e3ef54a3d5ff500ba                                                                                         0.1s
 => => extracting sha256:8973eb85275f19b8d72c6047560629116ad902397e5c1885b2508788197de28b                                                                                         0.3s
 => => extracting sha256:f9afc3cc0135aad884dad502f28a5b3d8cd32565116131da818ebf2ea6d46095                                                                                         0.0s
 => => extracting sha256:39312d8b4ab77de264678427265a2668073675bb8666caf723da18c9e4b7e3fc                                                                                         0.1s
 => [internal] load build context                                                                                                                                                 0.2s
 => => transferring context: 1.58kB                                                                                                                                               0.0s
 => [2/5] WORKDIR /app                                                                                                                                                            0.2s
 => [3/5] ADD . /app                                                                                                                                                              0.0s
 => [4/5] RUN apt update && apt install -y curl                                                                                                                                   4.6s
 => [5/5] RUN pip install --trusted-host pypi.python.org -r requirements.txt                                                                                                      3.7s
 => exporting to image                                                                                                                                                            0.1s
 => => exporting layers                                                                                                                                                           0.1s
 => => writing image sha256:03982c031d5436d8a31ead24acce2407519e791b849c8de7fdb0037690e29e54                                                                                      0.0s
 => => naming to docker.io/library/pyplanelist:0.0.1                                                                                                                              0.0s

Let’s give it a test

$ docker run -d -p 8999:80 --name pyplanetest pyplanelist:0.0.1
06c119f1c845cc9108bd247d34cf01fac7f434972602a345feff8a3f0d38ff3f

I can see it crashed right away

$ docker logs 06c119f1c845cc9108bd247d34cf01fac7f434972602a345feff8a3f0d38ff3f
Traceback (most recent call last):
  File "app.py", line 4, in <module>
    from flask import Flask, render_template_string
  File "/usr/local/lib/python3.7/site-packages/flask/__init__.py", line 14, in <module>
    from jinja2 import escape
ImportError: cannot import name 'escape' from 'jinja2' (/usr/local/lib/python3.7/site-packages/jinja2/__init__.py)

A post suggested moving to a newer version of Flask that doesn’t require Jinja

$ cat requirements.txt
Flask>=2.2.2
$ docker build -t pyplanelist:0.0.2 .
[+] Building 10.3s (10/10) FINISHED
 => [internal] load build definition from Dockerfile                                                                                                                              0.0s
 => => transferring dockerfile: 38B                                                                                                                                               0.0s
 => [internal] load .dockerignore                                                                                                                                                 0.0s
 => => transferring context: 2B                                                                                                                                                   0.0s
 => [internal] load metadata for docker.io/library/python:3.7-slim                                                                                                                1.5s
 => [1/5] FROM docker.io/library/python:3.7-slim@sha256:b53f496ca43e5af6994f8e316cf03af31050bf7944e0e4a308ad86c001cf028b                                                          0.0s
 => [internal] load build context                                                                                                                                                 0.0s
 => => transferring context: 116B                                                                                                                                                 0.0s
 => CACHED [2/5] WORKDIR /app                                                                                                                                                     0.0s
 => [3/5] ADD . /app                                                                                                                                                              0.0s
 => [4/5] RUN apt update && apt install -y curl                                                                                                                                   4.6s
 => [5/5] RUN pip install --trusted-host pypi.python.org -r requirements.txt                                                                                                      3.9s
 => exporting to image                                                                                                                                                            0.1s
 => => exporting layers                                                                                                                                                           0.1s
 => => writing image sha256:3dffb2d9f14c0eab13685211568492f01884a7e08c4a89cf69f1a7d821ec5f44                                                                                      0.0s
 => => naming to docker.io/library/pyplanelist:0.0.2

Now to test

$ docker rm pyplanetest
pyplanetest
$ docker run -d -p 8999:80 --name pyplanetest pyplanelist:0.0.2
94b7c1ef6022e7f3feedf047acdebd1430d8bf95e08367898e209d582c484320

A little better - I can see it running in Docker

/content/images/2023/12/planeso2-40.png

but still an error

/content/images/2023/12/planeso3-01.png

Seems the response wasn’t quite right

$ docker logs pyplanetest
 * Serving Flask app 'app'
 * Debug mode: off
WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
 * Running on all addresses (0.0.0.0)
 * Running on http://127.0.0.1:80
 * Running on http://172.17.0.5:80
Press CTRL+C to quit
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100 26686    0 26686    0     0  40216      0 --:--:-- --:--:-- --:--:-- 40189
[2023-12-22 13:33:31,025] ERROR in app: Exception on / [GET]
Traceback (most recent call last):
  File "/usr/local/lib/python3.7/site-packages/flask/app.py", line 2529, in wsgi_app
    response = self.full_dispatch_request()
  File "/usr/local/lib/python3.7/site-packages/flask/app.py", line 1825, in full_dispatch_request
    rv = self.handle_user_exception(e)
  File "/usr/local/lib/python3.7/site-packages/flask/app.py", line 1823, in full_dispatch_request
    rv = self.dispatch_request()
  File "/usr/local/lib/python3.7/site-packages/flask/app.py", line 1799, in dispatch_request
    return self.ensure_sync(self.view_functions[rule.endpoint])(**view_args)
  File "app.py", line 16, in home
    table_data += f"<tr><td>{item['name']}</td><td>{item['description_html']}</td></tr>"
TypeError: string indices must be integers
172.17.0.1 - - [22/Dec/2023 13:33:31] "GET / HTTP/1.1" 500 -
172.17.0.1 - - [22/Dec/2023 13:33:31] "GET /favicon.ico HTTP/1.1" 404 -

I just needed to fire the URL off by hand to see the mistake - the results are in ‘results’ not ‘items’

{
  "next_cursor": "100:1:0",
  "prev_cursor": "100:-1:1",
  "next_page_results": false,
  "prev_page_results": false,
  "count": 31,
  "total_pages": 1,
  "extra_stats": null,
  "results": [
    {
      "id": "43f0fe47-f833-4d4c-b303-3afc60a6e286",
      "created_at": "2023-12-22T06:12:40.097549-06:00",
      "updated_at": "2023-12-22T06:12:40.328407-06:00",
      "estimate_point": null,
      "name": "Does Plane work?",
      "description_html": "<p>Hey man, how is that Plane.so doing? :: Requested by isaac.johnson@gmail.com</p>",
      "priority": "none",
      "start_date": null,
      "target_date": null,
      "sequence_id": 31,
      "sort_order": 365535,
      "completed_at": null,
      "archived_at": null,
      "is_draft": false,
      "created_by": "23ab4dc2-0c58-4183-a63a-1e8eab4c3e8b",
      "updated_by": "23ab4dc2-0c58-4183-a63a-1e8eab4c3e8b",
      "project": "9ca799e6-52c4-4a9e-8b40-461eef4f57e9",
      "workspace": "4dab1b78-d62a-481b-8c9e-f2af8648e1cd",
      "parent": null,
      "state": "6ad12d87-1cff-4544-9c58-cc0ca8489571",
      "assignees": [],
      "labels": [
        "93fcb710-94bf-4e8c-b419-9e6dfbee660f"
      ]
    },
    {
      "id": "9ddc84a8-cdd0-4370-9b5b-c81b80dee7b5",

I think I’ll also update the code to not only correct the location of issue blocks, but start to externalize the Template and setup variables for the replaceable parts, like workspace name, project ID and our key

$ cat app.py
import requests
from flask import Flask, render_template
import os
from jinja2 import Environment, FileSystemLoader

app = Flask(__name__)

projectid = "9ca799e6-52c4-4a9e-8b40-461eef4f57e9"
workspacename = "tpk"
planeapikey = "plane_api_aabbccddeeffaabbccddeeffaabb"

@app.route('/')
def index():
    url = f'https://api.plane.so/api/v1/workspaces/{workspacename}/projects/{projectid}/issues/'
    headers = {
        'X-API-Key': planeapikey,
        'Content-Type': 'application/json'
    }
    response = requests.get(url, headers=headers)
    data = response.json()
    results = data['results']
    table_data = []
    for result in results:
        table_data.append([result['name'], result['description_html']])
    env = Environment(loader=FileSystemLoader('/app'))
    template = env.get_template('table.html')
    return template.render(table_data=table_data)

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=80)

A requirements to pull in requests and Jinja2

$ cat requirements.txt
Flask>=2.2.2
requests>=2.26.0
Jinja2>=3.0.2

The app.py will now use the HTML template:

$ cat table.html
<!DOCTYPE html>
<html>
<head>
    <title>Table Data</title>
</head>
<body>
    <table>
        <thead>
            <tr>
                <th>Name</th>
                <th>Description</th>
            </tr>
        </thead>
        <tbody>
            
        </tbody>
    </table>
</body>
</html>

Let’s try running that

$ docker build -t pyplanelist:0.0.3 .
[+] Building 4.7s (9/9) FINISHED
 => [internal] load build definition from Dockerfile                                                                                                                              0.0s
 => => transferring dockerfile: 38B                                                                                                                                               0.0s
 => [internal] load .dockerignore                                                                                                                                                 0.0s
 => => transferring context: 2B                                                                                                                                                   0.0s
 => [internal] load metadata for docker.io/library/python:3.7-slim                                                                                                                0.8s
 => [internal] load build context                                                                                                                                                 0.0s
 => => transferring context: 2.86kB                                                                                                                                               0.0s
 => [1/4] FROM docker.io/library/python:3.7-slim@sha256:b53f496ca43e5af6994f8e316cf03af31050bf7944e0e4a308ad86c001cf028b                                                          0.0s
 => CACHED [2/4] WORKDIR /app                                                                                                                                                     0.0s
 => [3/4] ADD . /app                                                                                                                                                              0.0s
 => [4/4] RUN pip install --trusted-host pypi.python.org -r requirements.txt                                                                                                      3.6s
 => exporting to image                                                                                                                                                            0.1s
 => => exporting layers                                                                                                                                                           0.1s
 => => writing image sha256:8859ea70a8564798d0cc54878e2966992e1e147c0fb6d00c389c6ea485629043                                                                                      0.0s
 => => naming to docker.io/library/pyplanelist:0.0.3

 $ docker run -d -p 8999:80 --name pyplanetest pyplanelist:0.0.3
69ab9f377dfa8bebe6b1657b004503e568854015a62b3a55adc695d1561f0913

This works, however, we would not want to show people’s email address on a website.

/content/images/2023/12/planeso3-02.png

I can import re and just replace out that string

import requests
from flask import Flask, render_template
import os
from jinja2 import Environment, FileSystemLoader
import re

app = Flask(__name__)

projectid = "9ca799e6-52c4-4a9e-8b40-461eef4f57e9"
workspacename = "tpk"
planeapikey = "plane_api_aabbccddeeffaabbccddeeffaabb"

@app.route('/')
def index():
    url = f'https://api.plane.so/api/v1/workspaces/{workspacename}/projects/{projectid}/issues/'
    headers = {
        'X-API-Key': planeapikey,
        'Content-Type': 'application/json'
    }
    response = requests.get(url, headers=headers)
    data = response.json()
    results = data['results']
    table_data = []
    for result in results:
        description_html = re.sub(":: Requested by.*", "", result['description_html'])
        table_data.append([result['name'], description_html])
    env = Environment(loader=FileSystemLoader('/app'))
    template = env.get_template('table.html')
    return template.render(table_data=table_data)

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=80)

I also want to make the table just a bit nicer

$ cat table.html
<!DOCTYPE html>
<html>
<head>
    <title>Table Data</title>
    <style>
        .styled-table {
            border-collapse: collapse;
            margin: 25px 0;
            font-size: 0.9em;
            font-family: sans-serif;
            min-width: 400px;
            box-shadow: 0 0 20px rgba(0, 0, 0, 0.15);
        }

        .styled-table thead tr {
            background-color: #491ac9;
            color: #ffffff;
            text-align: left;
        }

        .styled-table th,
        .styled-table td {
            padding: 12px 15px;
        }

        .styled-table tbody tr {
            border-bottom: 1px solid #dddddd;
        }

        .styled-table tbody tr:nth-of-type(even) {
            background-color: #dadada;
        }

        .styled-table tbody tr:last-of-type {
            border-bottom: 2px solid #491ac9;
        }

        .styled-table tbody tr.active-row {
            font-weight: bold;
            color: #491ac9;
        }

        caption {
        font-weight: bold;
        font-size: 24px;
        text-align: left;
        color: #333;
        }
    </style>
</head>
<body>
    <table class="styled-table">
        <caption>Fresh/Brewed User Requests</caption>
        <thead>
            <tr>
                <th>Name</th>
                <th>Description</th>
            </tr>
        </thead>
        <tbody>
            
        </tbody>
    </table>
</body>
</html>

which looks much nicer

/content/images/2023/12/planeso3-03.png

We’re making headway, but we may want to expose just one more field - a note of when we will deliver the feature. I could see users being keen to know if we will actually do a writeup or respond.

I could show the state, but I then would need to dereference it from the states set for the project. It’s not elegant, but here is a first pass:

import requests
from flask import Flask, render_template
import os
from jinja2 import Environment, FileSystemLoader
import re

app = Flask(__name__)

projectid = "9ca799e6-52c4-4a9e-8b40-461eef4f57e9"
workspacename = "tpk"
planeapikey = "plane_api_aabbccddeeffaabbccddeeffaabb"

@app.route('/')
def index():
    url = f'https://api.plane.so/api/v1/workspaces/{workspacename}/projects/{projectid}/issues/'
    headers = {
        'X-API-Key': planeapikey,
        'Content-Type': 'application/json'
    }
    response = requests.get(url, headers=headers)
    data = response.json()
    results = data['results']


    urlstates = f'https://api.plane.so/api/v1/workspaces/{workspacename}/projects/{projectid}/states/'
    stateresponse = requests.get(urlstates, headers=headers)
    statedata = stateresponse.json()
    stateresults = statedata['results']
    
    table_data = []
    for result in results:
        description_html = re.sub(":: Requested by.*", "", result['description_html'])
        stateName = ""
        for state in stateresults:
           if state["id"] == result['state']:
               stateName = state["name"]
        table_data.append([result['name'], stateName, description_html, result['target_date']])
    env = Environment(loader=FileSystemLoader('/app'))
    template = env.get_template('table.html')
    return template.render(table_data=table_data)

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=80)

and of course, I need to expose that in the template HTML

<!DOCTYPE html>
<html>
<head>
    <title>Table Data</title>
    <style>
        .styled-table {
            border-collapse: collapse;
            margin: 25px 0;
            font-size: 0.9em;
            font-family: sans-serif;
            min-width: 400px;
            box-shadow: 0 0 20px rgba(0, 0, 0, 0.15);
        }

        .styled-table thead tr {
            background-color: #491ac9;
            color: #ffffff;
            text-align: left;
        }

        .styled-table th,
        .styled-table td {
            padding: 12px 15px;
        }

        .styled-table tbody tr {
            border-bottom: 1px solid #dddddd;
        }

        .styled-table tbody tr:nth-of-type(even) {
            background-color: #dadada;
        }

        .styled-table tbody tr:last-of-type {
            border-bottom: 2px solid #491ac9;
        }

        .styled-table tbody tr.active-row {
            font-weight: bold;
            color: #491ac9;
        }

        caption {
        font-weight: bold;
        font-size: 24px;
        text-align: left;
        color: #333;
        }
    </style>
</head>
<body>
    <table class="styled-table">
        <caption>Fresh/Brewed User Requests</caption>
        <thead>
            <tr>
                <th>Name</th>
                <th>State</th>
                <th>Description</th>
                <th>Target Date</th>
            </tr>
        </thead>
        <tbody>
            
        </tbody>
    </table>
</body>
</html>

Not too bad:

/content/images/2023/12/planeso3-04.png

Clearly, I’ll want to move this out of hardcoded variables and into Environment variables which we could call from Docker or Kubernetes.

import requests
from flask import Flask, render_template
import os
from jinja2 import Environment, FileSystemLoader
import re

app = Flask(__name__)

projectid = os.environ.get("PRJID")
workspacename = os.environ.get("WSNAME")
planeapikey = os.environ.get("APIKEY")

@app.route('/')
def index():
    url = f'https://api.plane.so/api/v1/workspaces/{workspacename}/projects/{projectid}/issues/'
    headers = {
        'X-API-Key': planeapikey,
        'Content-Type': 'application/json'
    }
    response = requests.get(url, headers=headers)
    data = response.json()
    results = data['results']


    urlstates = f'https://api.plane.so/api/v1/workspaces/{workspacename}/projects/{projectid}/states/'
    stateresponse = requests.get(urlstates, headers=headers)
    statedata = stateresponse.json()
    stateresults = statedata['results']
    
    table_data = []
    for result in results:
        description_html = re.sub(":: Requested by.*", "", result['description_html'])
        stateName = ""
        for state in stateresults:
           if state["id"] == result['state']:
               stateName = state["name"]
        table_data.append([result['name'], stateName, description_html, result['target_date']])
    env = Environment(loader=FileSystemLoader('/app'))
    template = env.get_template('table.html')
    return template.render(table_data=table_data)

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=80)


Which I’ll build

$ docker build -t pyplanelist:0.0.6 .
[+] Building 6.1s (9/9) FINISHED
 => [internal] load build definition from Dockerfile                                                                                                                              0.0s
 => => transferring dockerfile: 38B                                                                                                                                               0.0s
 => [internal] load .dockerignore                                                                                                                                                 0.0s
 => => transferring context: 2B                                                                                                                                                   0.0s
 => [internal] load metadata for docker.io/library/python:3.7-slim                                                                                                                1.4s
 => [internal] load build context                                                                                                                                                 0.0s
 => => transferring context: 4.28kB                                                                                                                                               0.0s
 => [1/4] FROM docker.io/library/python:3.7-slim@sha256:b53f496ca43e5af6994f8e316cf03af31050bf7944e0e4a308ad86c001cf028b                                                          0.0s
 => CACHED [2/4] WORKDIR /app                                                                                                                                                     0.0s
 => [3/4] ADD . /app                                                                                                                                                              0.0s
 => [4/4] RUN pip install --trusted-host pypi.python.org -r requirements.txt                                                                                                      4.4s
 => exporting to image                                                                                                                                                            0.1s
 => => exporting layers                                                                                                                                                           0.1s
 => => writing image sha256:8201031eaefe6e25aa121d46c58a8f2d0dae7dbd7a503820627d257503892dc8                                                                                      0.0s
 => => naming to docker.io/library/pyplanelist:0.0.6

And this time, when I run it, I’ll pass in the environment variables

$ docker run -d -e PRJID="9ca799e6-52c4-4a9e-8b40-461eef4f57e9" -e WSNAME="tpk" -e APIKEY="plane_api_aabbccddeeffaabbccddeeffaabb" -p 8999:80 --name pyplanetest pyplanelist:0.0.6
08056dfc7bddaa9b2b70aac44abeb916cfc14b0df621c5faec531b47f6e1a734

In fact, I think I’ll make one more change. So that others can use this, I’ll make the report header customizable as well.

import requests
from flask import Flask, render_template
import os
from jinja2 import Environment, FileSystemLoader
import re

app = Flask(__name__)

projectid = os.environ.get("PRJID")
workspacename = os.environ.get("WSNAME")
planeapikey = os.environ.get("APIKEY")
tableTitle = os.environ.get("TABLETITLE")

@app.route('/')
def index():
    url = f'https://api.plane.so/api/v1/workspaces/{workspacename}/projects/{projectid}/issues/'
    headers = {
        'X-API-Key': planeapikey,
        'Content-Type': 'application/json'
    }
    response = requests.get(url, headers=headers)
    data = response.json()
    results = data['results']


    urlstates = f'https://api.plane.so/api/v1/workspaces/{workspacename}/projects/{projectid}/states/'
    stateresponse = requests.get(urlstates, headers=headers)
    statedata = stateresponse.json()
    stateresults = statedata['results']
    
    table_data = []
    for result in results:
        description_html = re.sub(":: Requested by.*", "", result['description_html'])
        stateName = ""
        for state in stateresults:
           if state["id"] == result['state']:
               stateName = state["name"]
        table_data.append([result['name'], stateName, description_html, result['target_date']])
    env = Environment(loader=FileSystemLoader('/app'))
    template = env.get_template('table.html')
    return template.render(table_data=table_data,title=tableTitle)

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=80)

with HTML template file as

<!DOCTYPE html>
<html>
<head>
    <title>Table Data</title>
    <style>
        .styled-table {
            border-collapse: collapse;
            margin: 25px 0;
            font-size: 0.9em;
            font-family: sans-serif;
            min-width: 400px;
            box-shadow: 0 0 20px rgba(0, 0, 0, 0.15);
        }

        .styled-table thead tr {
            background-color: #491ac9;
            color: #ffffff;
            text-align: left;
        }

        .styled-table th,
        .styled-table td {
            padding: 12px 15px;
        }

        .styled-table tbody tr {
            border-bottom: 1px solid #dddddd;
        }

        .styled-table tbody tr:nth-of-type(even) {
            background-color: #dadada;
        }

        .styled-table tbody tr:last-of-type {
            border-bottom: 2px solid #491ac9;
        }

        .styled-table tbody tr.active-row {
            font-weight: bold;
            color: #491ac9;
        }

        caption {
        font-weight: bold;
        font-size: 24px;
        text-align: left;
        color: #333;
        }
    </style>
</head>
<body>
    <table class="styled-table">
        <caption></caption>
        <thead>
            <tr>
                <th>Name</th>
                <th>State</th>
                <th>Description</th>
                <th>Target Date</th>
            </tr>
        </thead>
        <tbody>
            
        </tbody>
    </table>
</body>
</html>

Now when I run it, I can pass in a title:

$ docker run -d -e PRJID="9ca799e6-52c4-4a9e-8b40-461eef4f57e9" -e WSNAME="tpk" -e APIKEY="plane_api_aabbccddeeffaabbccddeeffaabb" -e TABLETITLE="Passed In Title" -p 8999:80 --name pyplanetest pyplanelist:0.0.6

which we can see reflected in the page:

/content/images/2023/12/planeso3-05.png

Kubernetes

Next, I’ll want to tag and push to my CR

$ docker tag pyplanelist:0.0.6 harbor.freshbrewed.science/freshbrewedprivate/pyplanereport:0.0.1
$ docker push harbor.freshbrewed.science/freshbrewedprivate/pyplanereport:0.0.1
The push refers to repository [harbor.freshbrewed.science/freshbrewedprivate/pyplanereport]
9a35fea8b8dc: Pushed
e36c93b5a9f5: Pushed
90794590e378: Pushed
b8594deafbe5: Pushed
8a55150afecc: Pushed
ad34ffec41dd: Pushed
f19cb1e4112d: Pushed
d310e774110a: Pushed
0.0.1: digest: sha256:672ae9f8f51dadcad257bb2fd8ede8cff2cc74f6eb9289e3b746799f91c8a746 size: 1996

The first idea I had was to just use a YAML manifest like

apiVersion: v1
kind: Service
metadata:
  name: pyplanereport
spec:
  selector:
    app: pyplanereport
  ports:
    - name: http
      protocol: TCP
      port: 80
      targetPort: 80
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: pyplanereport
spec:
  replicas: 1
  selector:
    matchLabels:
      app: pyplanereport
  template:
    metadata:
      labels:
        app: pyplanereport
    spec:
      containers:
        - name: pyplanereport
          image: harbor.freshbrewed.science/freshbrewedprivate/pyplanereport:0.0.1
          ports:
            - containerPort: 80
          env:
            - name: PRJID
              valueFrom:
                configMapKeyRef:
                  name: pyplanereport-config
                  key: PRJID
            - name: WSNAME
              valueFrom:
                configMapKeyRef:
                  name: pyplanereport-config
                  key: WSNAME
            - name: APIKEY
              valueFrom:
                configMapKeyRef:
                  name: pyplanereport-config
                  key: APIKEY
            - name: TABLETITLE
              valueFrom:
                configMapKeyRef:
                  name: pyplanereport-config
                  key: TABLETITLE
      imagePullSecrets:
        - name: regcred
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: pyplanereport-config
data:
  PRJID: "123"
  WSNAME: "myworkspace"
  APIKEY: "myapikey"
  TABLETITLE: "mytabletitle"

But then I built out a proper helm chart using helm create to layout the boilerplate.

Installing:

$ helm install myplane ./pyplanereport
NAME: myplane
LAST DEPLOYED: Fri Dec 22 18:41:15 2023
NAMESPACE: default
STATUS: deployed
REVISION: 1
NOTES:
1. Get the application URL by running these commands:
  export POD_NAME=$(kubectl get pods --namespace default -l "app.kubernetes.io/name=pyplanereport,app.kubernetes.io/instance=myplane" -o jsonpath="{.items[0].metadata.name}")
  export CONTAINER_PORT=$(kubectl get pod --namespace default $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}")
  echo "Visit http://127.0.0.1:8080 to use your application"
  kubectl --namespace default port-forward $POD_NAME 8080:$CONTAINER_PORT

And I can see the deployment is up:

$ kubectl get deployments myplane-pyplanereport
NAME                    READY   UP-TO-DATE   AVAILABLE   AGE
myplane-pyplanereport   1/1     1            1           48s

And it works!

Note, the values file used:

planeso:
  projectid: "9ca799e6-52c4-4a9e-8b40-461eef4f57e9" # e.g. 9ca799e6-52c4-4a9e-8b40-461eef4f57e9
  workspacename: "tpk" # e.g. tpk
  apikey: "plane_api_aabbccddeeffaabbccddeeffaabb" #e.g. plane_api_xxxxxxxxxx
  tabletitle: "Project Issue Report"

/content/images/2023/12/planeso3-06.png

I’m actually feeling like this is pretty close to being ready for release.

My gripe is just that there is a lot of garbage in the report. I’m thinking we should create the ability to ignore a state before wrap.

You can see how I added “ignoreState”

import requests
from flask import Flask, render_template
import os
from jinja2 import Environment, FileSystemLoader
import re

app = Flask(__name__)

projectid = os.environ.get("PRJID")
workspacename = os.environ.get("WSNAME")
planeapikey = os.environ.get("APIKEY")
tableTitle = os.environ.get("TABLETITLE")
ignoreState = os.environ.get("IGNORESTATE")

@app.route('/')
def index():
    url = f'https://api.plane.so/api/v1/workspaces/{workspacename}/projects/{projectid}/issues/'
    headers = {
        'X-API-Key': planeapikey,
        'Content-Type': 'application/json'
    }
    response = requests.get(url, headers=headers)
    data = response.json()
    results = data['results']


    urlstates = f'https://api.plane.so/api/v1/workspaces/{workspacename}/projects/{projectid}/states/'
    stateresponse = requests.get(urlstates, headers=headers)
    statedata = stateresponse.json()
    stateresults = statedata['results']
    
    table_data = []
    for result in results:
        description_html = re.sub(":: Requested by.*", "", result['description_html'])
        stateName = ""
        for state in stateresults:
           if state["id"] == result['state']:
               stateName = state["name"]
        if ignoreState != stateName:
           table_data.append([result['name'], stateName, description_html, result['target_date']])
    env = Environment(loader=FileSystemLoader('/app'))
    template = env.get_template('table.html')
    return template.render(table_data=table_data,title=tableTitle)

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=80)

Then I built and ran

$ docker build -t pyplanelist:0.0.7 .
[+] Building 6.4s (9/9) FINISHED
 => [internal] load build definition from Dockerfile                                                                                                                                                                                                                                0.0s
 => => transferring dockerfile: 38B                                                                                                                                                                                                                                                 0.0s
 => [internal] load .dockerignore                                                                                                                                                                                                                                                   0.0s
 => => transferring context: 2B                                                                                                                                                                                                                                                     0.0s
 => [internal] load metadata for docker.io/library/python:3.7-slim                                                                                                                                                                                                                  1.4s
 => [internal] load build context                                                                                                                                                                                                                                                   0.0s
 => => transferring context: 23.96kB                                                                                                                                                                                                                                                0.0s
 => [1/4] FROM docker.io/library/python:3.7-slim@sha256:b53f496ca43e5af6994f8e316cf03af31050bf7944e0e4a308ad86c001cf028b                                                                                                                                                            0.0s
 => CACHED [2/4] WORKDIR /app                                                                                                                                                                                                                                                       0.0s
 => [3/4] ADD . /app                                                                                                                                                                                                                                                                0.0s
 => [4/4] RUN pip install --trusted-host pypi.python.org -r requirements.txt                                                                                                                                                                                                        4.7s
 => exporting to image                                                                                                                                                                                                                                                              0.1s
 => => exporting layers                                                                                                                                                                                                                                                             0.1s
 => => writing image sha256:e4717a44ec24d91c013eb1a525181d0d03cfe9edd903d210a28ee81c86e70706                                                                                                                                                                                        0.0s
 => => naming to docker.io/library/pyplanelist:0.0.7
 
 
 $ docker run -d -e PRJID="9ca799e6-52c4-4a9e-8b40-461eef4f57e9" -e WSNAME="tpk" -e APIKEY="plane_api_aabbccddeeffaabbccddeeffaabb" -e TABLETITLE="Passed In Title" -e IGNORESTATE="Cancelled"  -p 8999:80 --name pyplanetest pyplanelist:0.0.7
20903d6f949e0ebb150f0d5c54825350cc521af4194b039e75a0f6fa7bafcb4e

I could then see the “Cancelled” entry was skipped

/content/images/2023/12/planeso3-07.png

/content/images/2023/12/planeso3-08.png

Summary

In Part 2 we touched on the REST API and getting issues from SaaS Plane.so. In today’s post we used Python to build out a proper Issue Report with Flask and Jinja2 templating. I fixed up the styling of the HTML report and then updated it to run in Docker. With a Dockerized app we updated it to use Helm to deploy to Kubernetes. Lastly, I updated it to hide a specific state like “Cancelled”.

Next time, we’ll wrap this 4-part series by Open-sourcing it.

Agile OpenSource Kubernetes Docker Plane

Have something to add? Feedback? You can use the feedback form

Isaac Johnson

Isaac Johnson

Cloud Solutions Architect

Isaac is a CSA and DevOps engineer who focuses on cloud migrations and devops processes. He also is a dad to three wonderful daughters (hence the references to Princess King sprinkled throughout the blog).

Theme built by C.S. Rhymes