A New Project: Part 6: Passwords and Release

Published: Jan 13, 2024 by Isaac Johnson

We’re finally ready to get Release 1.0 ready. Let’s finish the Password setup, forms and cleanup. If we really want to use this app, we can’t just hope people don’t find the public endpoint.

Passwords

I want to tackle one more gotcha before I would host this live. That is some form of basic login.

/content/images/2024/01/newapp5-17.png

I’ll fire a new branch locally

$ git checkout -b feature-8-login-and-password
Switched to a new branch 'feature-8-login-and-password'

My first change is to create a password mounted RO on the pod with a secret and a secretmount on the app deployment

/content/images/2024/01/newapp5-18.png

I updated the chart with a default value

/content/images/2024/01/newapp5-19.png

Then did a local helm upgrade to verify it mounted

builder@DESKTOP-QADGF36:~/Workspaces/pyK8sService$ kubectl exec -it `kubectl get pods -l app.kubernetes.io/instance=mytest-app -n disabledtest -o=jsonpath='{.items[0].metadata.name}'` -n disabledtest -- /bin/bash
root@mytest-pyk8sservice-app-7db4748cf6-kwq7b:/app# ls /config
mytest-pyk8sservice  mytest-pyk8sservice.yaml  mytest-pyk8sservice2.yaml
root@mytest-pyk8sservice-app-7db4748cf6-kwq7b:/app# ls /settings
password
root@mytest-pyk8sservice-app-7db4748cf6-kwq7b:/app# cat /settings/password && echo
notsosecret

The app now needs to check if the user logged in at the start and if not, prompt for a password

# Load the password from the file
with open('/settings/password', 'r') as file:
    correct_password = file.read().strip()

@app.route('/', methods=['GET', 'POST'])
def home():
    # If the request method is POST
    if request.method == 'POST':
        # Get the password from the form data
        password = request.form.get('password')

        # If the password is incorrect, return a 403 error
        print(f"Login will compare {password} with {correct_password}", file=sys.stderr)
        if password != correct_password:
            abort(403)

        # If the password is correct, return a welcome message
        ingresses, ingress_count = get_kubernetes_resources()
        print(f"Number of Ingress resources found: {ingress_count}", file=sys.stderr)
        return render_template('index.html', resources=ingresses, count=ingress_count)
    # If the request method is GET, show an HTML page prompting for a password
    else:
        print(f"Login page", file=sys.stderr)
        return render_template('login.html')

And a pretty basic login form

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Kubernetes Ingresses Login</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>
    <form method="POST">
    <table class="styled-table">
        <caption>Please login</caption>
        <thead>
            <tr>
                <th>Password</th>
            </tr>
        </thead>
        <tbody>
            <tr>
                <td><input name="password" type="password">
                    <input type="submit" value="Submit"></td>
            </tr>
        </tbody>
    </table>
    </form>
</body>
</html>

A quick test shows a login page now comes up

/content/images/2024/01/newapp5-20.png

A bad password doesn’t look too hot

/content/images/2024/01/newapp5-21.png

But the real value looks okay

/content/images/2024/01/newapp5-22.png

At this point it is still security through obscurity in that the rest of the app is just serving GET endpoints without checking on that password

/content/images/2024/01/newapp5-23.png

I’ll now need to change all the routines to deal with POST variables.

This essentially means changing the blocks that pulled a GET var

@app.route('/restore', methods=['GET'])
def restore_ingress():
    ingress_name = request.args.get('thisIngress')

To check the password and then get the Ingress from a POSTed form far

@app.route('/restore', methods=['POST'])
def restore_ingress():
    # Get the password from the form data
    password = request.form.get('password')

    # If the password is incorrect, return a 403 error
    print(f"Login will compare {password} with {correct_password}", file=sys.stderr)
    if password != correct_password:
        abort(403)

    ingress_name = request.form.get('thisIngress')
    

The app.py now looks like:

from flask import Flask, render_template, request
from kubernetes import client, config, utils
import sys
import os
import subprocess

app = Flask(__name__)

# Load the password from the file
with open('/settings/password', 'r') as file:
    correct_password = file.read().strip()

@app.route('/', methods=['GET', 'POST'])
def home():
    # If the request method is POST
    if request.method == 'POST':
        # Get the password from the form data
        password = request.form.get('password')

        # If the password is incorrect, return a 403 error
        print(f"Login will compare {password} with {correct_password}", file=sys.stderr)
        if password != correct_password:
            abort(403)

        # If the password is correct, return a welcome message
        ingresses, ingress_count = get_kubernetes_resources()
        print(f"Number of Ingress resources found: {ingress_count}", file=sys.stderr)
        return render_template('index.html', resources=ingresses, count=ingress_count, password=password)
    # If the request method is GET, show an HTML page prompting for a password
    else:
        print(f"Login page", file=sys.stderr)
        return render_template('login.html')

@app.route('/disable', methods=['POST'])
def disable_ingress():
    # Get the password from the form data
    password = request.form.get('password')

    # If the password is incorrect, return a 403 error
    print(f"Login will compare {password} with {correct_password}", file=sys.stderr)
    if password != correct_password:
        abort(403)

    ingress_name = request.form.get('thisIngress')
    print(f"Disable Ingress: {ingress_name}", file=sys.stderr)
    if ingress_name:
        update_ingress_service(ingress_name, 'disabledservice')
        return f"Ingress '{ingress_name}' updated to use service 'disabledservice'"
    else:
        return "Invalid request. Please provide 'thisIngress' as a GET parameter."

@app.route('/store', methods=['POST'])
def store_ingress():
    # Get the password from the form data
    password = request.form.get('password')

    # If the password is incorrect, return a 403 error
    print(f"Login will compare {password} with {correct_password}", file=sys.stderr)
    if password != correct_password:
        abort(403)

    ingress_name = request.form.get('thisIngress')
    print(f"Store Ingress: {ingress_name}", file=sys.stderr)
    if ingress_name:
        newstore_ingress_service(ingress_name)
        return f"Ingress '{ingress_name}' stored to /config"
    else:
        return "Invalid request. Please provide 'thisIngress' as a GET parameter."

@app.route('/saved', methods=['POST'])
def showsaved_ingress():
    # Get the password from the form data
    password = request.form.get('password')

    # If the password is incorrect, return a 403 error
    print(f"Login will compare {password} with {correct_password}", file=sys.stderr)
    if password != correct_password:
        abort(403)

    filenames = os.listdir('/config')
    ingress_count = len(filenames)
    print(f"1 show saved ingresses in /config: {ingress_count} total", file=sys.stderr)
    return render_template('restore.html', resources=filenames, count=ingress_count)


@app.route('/restore', methods=['POST'])
def restore_ingress():
    # Get the password from the form data
    password = request.form.get('password')

    # If the password is incorrect, return a 403 error
    print(f"Login will compare {password} with {correct_password}", file=sys.stderr)
    if password != correct_password:
        abort(403)

    ingress_name = request.form.get('thisIngress')
    print(f"Restore Ingress: {ingress_name}", file=sys.stderr)
    if ingress_name:
        newrestore_ingress_service(ingress_name)
        return f"Ingress '{ingress_name}' restored from /config"
    else:
        return "Invalid request. Please provide 'thisIngress' as a GET parameter."
    
#==================================================================
def newrestore_ingress_service(ingress_name):
    config.load_incluster_config()

    namespace = open("/var/run/secrets/kubernetes.io/serviceaccount/namespace").read()

    v1 = client.NetworkingV1Api()
    print(f"0 in restore ingress", file=sys.stderr)
    # Remove IFF exists
    try:
        v1.delete_namespaced_ingress(name=ingress_name, namespace=namespace)
    except client.exceptions.ApiException as e:
        if e.status != 404:  # Ignore error if the ingress does not exist
            raise

    file_path = f"/config/{ingress_name}.yaml"

    cmd = f"kubectl apply --validate='false' -f {file_path}"
    # Execute the command
    subprocess.run(cmd, shell=True, check=True)

    return f"Ingress '{ingress_name}' restored from /config '{ingress_name}'"

def get_kubernetes_resources():
    config.load_incluster_config()
    v1 = client.NetworkingV1Api()

    namespace = open("/var/run/secrets/kubernetes.io/serviceaccount/namespace").read()

    ingresses = v1.list_namespaced_ingress(namespace)
    ingress_list = [ingress.metadata.name for ingress in ingresses.items]
    ingress_count = len(ingress_list)

    return ingress_list, ingress_count

def newstore_ingress_service(ingress_name):
    config.load_incluster_config()
    v1 = client.NetworkingV1Api()

    namespace = open("/var/run/secrets/kubernetes.io/serviceaccount/namespace").read()

    file_path = f"/config/{ingress_name}.yaml"

    cmd = f"kubectl get ingress {ingress_name} -n {namespace} -o yaml > {file_path}"
    # Execute the command
    subprocess.run(cmd, shell=True, check=True)

    return f"Ingress '{ingress_name}' stored in /config '{ingress_name}'"

def update_ingress_service(ingress_name, new_service_name):
    config.load_incluster_config()
    v1 = client.NetworkingV1Api()

    namespace = open("/var/run/secrets/kubernetes.io/serviceaccount/namespace").read()

    print(f"in update_ingress_service", file=sys.stderr)
    try:
        print(f"0 in update_ingress_service", file=sys.stderr)
        ingress = v1.read_namespaced_ingress(name=ingress_name, namespace=namespace)
        # Update the Ingress backend service to 'new_service_name'
        #print(f"1 in update_ingress_service", file=sys.stderr)
        #print(f"Ingress first: {ingress}", file=sys.stderr)
        ingress.spec.rules[0].http.paths[0].backend.service.name = new_service_name
        #print(f"2 in update_ingress_service", file=sys.stderr)
        api_response = v1.patch_namespaced_ingress(
            name=ingress_name,
            namespace=namespace,  # replace with your namespace
            body=ingress
        )
        #print(f"3 in update_ingress_service", file=sys.stderr)
        #print(f"Ingress second: {ingress}", file=sys.stderr)
    except client.rest.ApiException as e:
        print(f"error in update_ingress_service: {e}", file=sys.stderr)
        return f"Error updating Ingress '{ingress_name}': {e}"

    return f"Ingress '{ingress_name}' updated to use service '{new_service_name}'"


if __name__ == '__main__':
    app.run(host='0.0.0.0', port=5000, debug=True)

This also meant a rewrite of index.html to switch to form variables

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Kubernetes Ingresses</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>
    <form id="myForm" method="POST">
    <input type="hidden" id="password" name="password" value="" />
    <input type="hidden" id="thisIngress" name="thisIngress" value="TBD" />
    </form>
    <script>
        function setAction(action, ingress) {
          // Get the form
          var form = document.getElementById('myForm');
        
          // Set the form action
          form.action = action;
        
          // Set the hidden input value
          document.getElementById('thisIngress').value = ingress;
        
          // Submit the form
          form.submit();
        }
    </script>

    <table class="styled-table">
        <caption>Kubernetes Ingresses in the Namespace: 8</caption>
        <thead>
            <tr>
                <th>Ingress</th>
                <th>Actions</th>
            </tr>
        </thead>
        <tbody>
            
        </tbody>
    </table>
    </form>
</body>
</html>

We can now see it in action

I wanted a better error page on wrong password.

I used this page to whip up a decent error page

$ cat ./templates/baddpass.html
<html>
    <head>
        <title>wrong</title>
    </head>
    <style>
        body {
            font-family: "Noto Sans", sans-serif;
        }

        [code], pre, tt, kbd, samp {
            font-family: "Noto Sans Mono", monospace;
        }
    </style>
<body>
<pre>

    .----------------.  .----------------.  .----------------.  .-----------------. .----------------.                                                             
    | .--------------. || .--------------. || .--------------. || .--------------. || .--------------. |                                                            
    | | _____  _____ | || |  _______     | || |     ____     | || | ____  _____  | || |    ______    | |                                                            
    | ||_   _||_   _|| || | |_   __ \    | || |   .'    `.   | || ||_   \|_   _| | || |  .' ___  |   | |                                                            
    | |  | | /\ | |  | || |   | |__) |   | || |  /  .--.  \  | || |  |   \ | |   | || | / .'   \_|   | |                                                            
    | |  | |/  \| |  | || |   |  __ /    | || |  | |    | |  | || |  | |\ \| |   | || | | |    ____  | |                                                            
    | |  |   /\   |  | || |  _| |  \ \_  | || |  \  `--'  /  | || | _| |_\   |_  | || | \ `.___]  _| | |                                                            
    | |  |__/  \__|  | || | |____| |___| | || |   `.____.'   | || ||_____|\____| | || |  `._____.'   | |                                                            
    | |              | || |              | || |              | || |              | || |              | |                                                            
    | '--------------' || '--------------' || '--------------' || '--------------' || '--------------' |                                                            
     '----------------'  '----------------'  '----------------'  '----------------'  '----------------'                                                             
     .----------------.  .----------------.  .----------------.  .----------------.  .----------------.  .----------------.  .----------------.  .----------------. 
    | .--------------. || .--------------. || .--------------. || .--------------. || .--------------. || .--------------. || .--------------. || .--------------. |
    | |   ______     | || |      __      | || |    _______   | || |    _______   | || | _____  _____ | || |     ____     | || |  _______     | || |  ________    | |
    | |  |_   __ \   | || |     /  \     | || |   /  ___  |  | || |   /  ___  |  | || ||_   _||_   _|| || |   .'    `.   | || | |_   __ \    | || | |_   ___ `.  | |
    | |    | |__) |  | || |    / /\ \    | || |  |  (__ \_|  | || |  |  (__ \_|  | || |  | | /\ | |  | || |  /  .--.  \  | || |   | |__) |   | || |   | |   `. \ | |
    | |    |  ___/   | || |   / ____ \   | || |   '.___`-.   | || |   '.___`-.   | || |  | |/  \| |  | || |  | |    | |  | || |   |  __ /    | || |   | |    | | | |
    | |   _| |_      | || | _/ /    \ \_ | || |  |`\____) |  | || |  |`\____) |  | || |  |   /\   |  | || |  \  `--'  /  | || |  _| |  \ \_  | || |  _| |___.' / | |
    | |  |_____|     | || ||____|  |____|| || |  |_______.'  | || |  |_______.'  | || |  |__/  \__|  | || |   `.____.'   | || | |____| |___| | || | |________.'  | |
    | |              | || |              | || |              | || |              | || |              | || |              | || |              | || |              | |
    | '--------------' || '--------------' || '--------------' || '--------------' || '--------------' || '--------------' || '--------------' || '--------------' |
     '----------------'  '----------------'  '----------------'  '----------------'  '----------------'  '----------------'  '----------------'  '----------------' 
    
</pre>
</body>
</html>

which looks like

/content/images/2024/01/newapp5-26.png

then changed the routines to use that instead of abort(403)

    if password != correct_password:
        return render_template('badpass.html')

Let’s give it a try.

/content/images/2024/01/newapp5-27.png

The next area to clean up is to create decent result pages with back buttons

I created results pages with the same styling as our index. They each have a history.back() button to return to the prior page

/content/images/2024/01/newapp5-28.png

I also wanted to fix up the “saved” page which loads “restore.html”. That just listed the files in /config prior. To do that, I used the same HTML styling but gave it an action.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Kubernetes Saved Ingresses</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>
    <form id="myForm" method="POST">
    <input type="hidden" id="password" name="password" value="{{ password }}" />
    <input type="hidden" id="thisIngress" name="thisIngress" value="TBD" />
    </form>
    <script>
        function setAction(action, ingress) {
          // Get the form
          var form = document.getElementById('myForm');
        
          // Set the form action
          form.action = action;
        
          // Set the hidden input value
          document.getElementById('thisIngress').value = ingress;
        
          // Submit the form
          form.submit();
        }
    </script>

    <table class="styled-table">
        <caption>Kubernetes Ingresses Saved from the Namespace: {{ count }}</caption>
        <thead>
            <tr>
                <th>Ingress</th>
                <th>Actions</th>
            </tr>
        </thead>
        <tbody>
            {% for resource in resources %}
            <tr>
                <td>{{ resource }}</td>
                <td><button onclick="setAction('restore', '{{ resource }}')">Restore</button></td>
            </tr>
            {% endfor %}
        </tbody>
    </table>
    <button onclick="history.back()">Go Back</button>
</body>
</html>

This did mean I would need to pass the password to the jinja2 template routine

@app.route('/saved', methods=['POST'])
def showsaved_ingress():
    # Get the password from the form data
    password = request.form.get('password')

    # If the password is incorrect, return a 403 error
    print(f"Login will compare {password} with {correct_password}", file=sys.stderr)
    if password != correct_password:
        return render_template('badpass.html')

    filenames = os.listdir('/config')
    ingress_count = len(filenames)
    print(f"1 show saved ingresses in /config: {ingress_count} total", file=sys.stderr)
    return render_template('restore.html', resources=filenames, count=ingress_count, password=password)

We can see it now looks a lot cleaner:

/content/images/2024/01/newapp5-29.png

The store option now looks much better

/content/images/2024/01/newapp5-30.png

as does disable

/content/images/2024/01/newapp5-31.png

Note: I did quick fix that typo on any page that had an extra }

/content/images/2024/01/newapp5-32.png

You may notice a bit of an improvement. My service, that we launch with Nginx is not “disabledservice”. That was always meant to be a placeholder.

To fix it, I added the chart fullname to the helm template ‘appsecret.yaml’

apiVersion: v1
kind: Secret
metadata:
  name: {{ include "pyK8sService.fullname" . }}-password
type: Opaque
data:
  password: {{ .Values.password | b64enc | quote }}
  disabledsvc: {{ include "pyK8sService.fullname" . | b64enc | quote }}

Since I used that for the password, I knew it would get mounted on the pod

... snip ...
           - mountPath: /settings
             name: settings
             readOnly: true
           {{- if .Values.persistence.additionalMounts }}
             {{- .Values.persistence.additionalMounts | toYaml | nindent 12 }}
           {{- end }}
          resources:
            {{- toYaml .Values.resources | nindent 12 }}
      volumes:
      - name: settings
        secret:
          secretName: {{ include "pyK8sService.fullname" . }}-password
... snip ...

I then just loaded it from the settings a the start of the app


# Load the disabled service name from the file
with open('/settings/disabledsvc', 'r') as file2:
    disabledSerivceName = file2.read().strip()

So much is done now, let’s save our work and create a PR.

I’ll also up the chart and app versions to 0.2.2 and 1.17.1 respectively

apiVersion: v2
name: pyk8sservice
description: A Helm chart for Kubernetes

# A chart can be either an 'application' or a 'library' chart.
#
# Application charts are a collection of templates that can be packaged into versioned archives
# to be deployed.
#
# Library charts provide useful utilities or functions for the chart developer. They're included as
# a dependency of application charts to inject those utilities and functions into the rendering
# pipeline. Library charts do not define any templates and therefore cannot be deployed.
type: application

# This is the chart version. This version number should be incremented each time you make changes
# to the chart and its templates, including the app version.
# Versions are expected to follow Semantic Versioning (https://semver.org/)
version: 0.2.2

# This is the version number of the application being deployed. This version number should be
# incremented each time you make changes to the application. Versions are not expected to
# follow Semantic Versioning. They should reflect the version the application is using.
# It is recommended to use it with quotes.
appVersion: "1.17.1"

So much has happened, I’m making this the 1.0 release

That means updating the README.md to include some notes:

Release Number Chart Version App Version Notes
1.0.0 0.2.2 1.17.1 Features up through 8 including password
  0.2.0 1.16.0  
  0.1.0 1.16.0  

And some useful Kubectl commands as well.

The PR 9 will cover all these changes

/content/images/2024/01/newapp5-33.png

The action completed without issue which shows me I didn’t miss any files when I staged

/content/images/2024/01/newapp5-34.png

We can also see this PR is linked to the issue because of the “Fixes #8” line

/content/images/2024/01/newapp5-35.png

I merged and deleted the branch

/content/images/2024/01/newapp5-36.png

I’ll then want a tagged release.

I’ll create a new 1.0.0 tag from main

/content/images/2024/01/newapp5-37.png

which created

/content/images/2024/01/newapp5-38.png

Then ran the tag build

/content/images/2024/01/newapp5-39.png

I can see the new chart in Harbor

/content/images/2024/01/newapp5-40.png

$ helm pull oci://harbor.freshbrewed.science/chartrepo/pyk8sservice --version 0.2.2

And the new release in Dockerhub

$ docker pull idjohnson/pyk8singresssvc:release.1.0.0

Cleanup

I really want to include cleanup as well. I already have some bogus saves in the /config directory.

I created Feature #10 to tackle cleanups

/content/images/2024/01/newapp6-01.png

That means adding an action in the HTML to execute an ‘rmsaved’ action we’ll define in a moment

    <table class="styled-table">
        <caption>Kubernetes Ingresses Saved from the Namespace: 8</caption>
        <thead>
            <tr>
                <th>Ingress</th>
                <th>Actions</th>
            </tr>
        </thead>
        <tbody>
            
        </tbody>
    </table>

In the python app, I’ll add a rmsaved route

@app.route('/rmsaved', methods=['POST'])
def rmsaved_ingress():
    # Get the password from the form data
    password = request.form.get('password')

    # If the password is incorrect, return a 403 error
    print(f"Login will compare {password} with {correct_password}", file=sys.stderr)
    if password != correct_password:
        return render_template('badpass.html')

    ingress_name = request.form.get('thisIngress')
    # yaml in the name in this case
    file_path = f"/config/{ingress_name}"
    print(f"0 remove: {ingress_name} in {file_path}", file=sys.stderr)
    rmsaved_ingress_service(ingress_name,file_path)

    # Load saved again
    filenames = os.listdir('/config')
    ingress_count = len(filenames)
    print(f"1 show saved ingresses in /config: {ingress_count} total", file=sys.stderr)
    return render_template('restore.html', resources=filenames, count=ingress_count, password=password)

which simply removes the file and returns the saved list to the user

def rmsaved_ingress_service(ingress_name,file_path):
    cmd = f"rm -f {file_path}"
    # Execute the command
    subprocess.run(cmd, shell=True, check=True)

    return f"Ingress '{ingress_name}' removed in '{file_path}'"

Let’s give it a try.

/content/images/2024/01/newapp6-02.png

I want the go-back in this case to return to the main page, not try to re-delete.

We’ll update the HTML to use a setAction to ‘/’

    <table class="styled-table">
        <caption>Kubernetes Ingresses Saved from the Namespace: 8</caption>
        <thead>
            <tr>
                <th>Ingress</th>
                <th>Actions</th>
            </tr>
        </thead>
        <tbody>
            
        </tbody>
    </table>
    <button onclick="setAction('/', '')">Go Back To Main</button>

One more fix I want - I would like a simple “Store and Disable” as that is a nice 1-2 punch.

@app.route('/storeanddisable', methods=['POST'])
def storeanddisable_ingress():
    # Get the password from the form data
    password = request.form.get('password')

    # If the password is incorrect, return a 403 error
    print(f"Login will compare {password} with {correct_password}", file=sys.stderr)
    if password != correct_password:
        return render_template('badpass.html')

    ingress_name = request.form.get('thisIngress')
    print(f"Disable Ingress: {ingress_name}", file=sys.stderr)
    if ingress_name:
        # store it first
        file_path = f"/config/{ingress_name}.yaml"
        newstore_ingress_service(ingress_name,file_path)

        # now disable
        update_ingress_service(ingress_name, disabledServiceName)
        return render_template('disable.html', ingress_name=ingress_name, disabledservice=disabledServiceName)
    else:
        return "Invalid request. Please provide 'thisIngress'."

This means that we could errantly blast our backup. I added a note on the main page to this effect:

    <hr/>
    <i>Note: the Store and Disable will store whatever you have there presently.  So if you already disabled the service, you will overwrite your saved Ingress with the "Disabled" one and not be able to restore in the future.</i>

I also noted one more minor mistake - I routed the Ingress for this to the Nginx “disabled” pod, not to the real “app”.

I just changed the helm ingress.yaml to use “-app” for the service

{{- if .Values.ingress.enabled -}}
{{- $fullName := include "pyK8sService.fullname" . -}}
{{- $svcPort := .Values.service.port -}}
{{- if and .Values.ingress.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }}
  {{- if not (hasKey .Values.ingress.annotations "kubernetes.io/ingress.class") }}
  {{- $_ := set .Values.ingress.annotations "kubernetes.io/ingress.class" .Values.ingress.className}}
  {{- end }}
{{- end }}
{{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}}
apiVersion: networking.k8s.io/v1
{{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}}
apiVersion: networking.k8s.io/v1beta1
{{- else -}}
apiVersion: extensions/v1beta1
{{- end }}
kind: Ingress
metadata:
  name: {{ $fullName }}
  labels:
    {{- include "pyK8sService.labels" . | nindent 4 }}
  {{- with .Values.ingress.annotations }}
  annotations:
    {{- toYaml . | nindent 4 }}
  {{- end }}
spec:
  {{- if and .Values.ingress.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }}
  ingressClassName: {{ .Values.ingress.className }}
  {{- end }}
  {{- if .Values.ingress.tls }}
  tls:
    {{- range .Values.ingress.tls }}
    - hosts:
        {{- range .hosts }}
        - {{ . | quote }}
        {{- end }}
      secretName: {{ .secretName }}
    {{- end }}
  {{- end }}
  rules:
    {{- range .Values.ingress.hosts }}
    - host: {{ .host | quote }}
      http:
        paths:
          {{- range .paths }}
          - path: {{ .path }}
            {{- if and .pathType (semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion) }}
            pathType: {{ .pathType }}
            {{- end }}
            backend:
              {{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }}
              service:
                name: {{ $fullName }}-app
                port:
                  number: {{ $svcPort }}
              {{- else }}
              serviceName: {{ $fullName }}
              servicePort: {{ $svcPort }}
              {{- end }}
          {{- end }}
    {{- end }}
{{- end }}

Then upped the chart version and app to match

version: 0.2.3
appVersion: "1.17.2"

I put notes in the README.md

Release Number Chart Version App Version Notes
1.0.1 0.2.3 1.17.2 Features through 10, fix cleanup, add save and restore, back button on bad pass
1.0.0 0.2.2 1.17.1 Features up through 8 including password
  0.2.0 1.16.0  
  0.1.0 1.16.0  

I created a new PR 11

/content/images/2024/01/newapp6-03.png

I also made a new release 1.0.1 tag after merging to main

/content/images/2024/01/newapp6-04.png

When the build completed

/content/images/2024/01/newapp6-05.png

I could see a new tagged container in dockerhub, release 1.0.1

$ docker pull idjohnson/pyk8singresssvc:release.1.0.1

and lastly, a new chart in Harbor

$ helm pull oci://harbor.freshbrewed.science/chartrepo/pyk8sservice --version 0.2.3

/content/images/2024/01/newapp6-06.png

Real Test

This is now where the rubber meets the road. So far, all of my testing has been in a dummy test cluster. If I really think this thing is worthy of release, I need to test it on a live system.

I’ll set the tag to be the latest and set my own password

$ helm install disableservice --set appimage.tag="release.1.0.1" --set password=NotTheRealPassword oci://harbor.freshbrewed.science/chartrepo/pyk8sservice --version 0.2.3
Pulled: harbor.freshbrewed.science/chartrepo/pyk8sservice:0.2.3
Digest: sha256:e0b4cc7cff794968c58316da22212e2b95fa1b880f1786eb43f968cc025174e9
NAME: disableservice
LAST DEPLOYED: Fri Dec 29 09:11:27 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=pyk8sservice,app.kubernetes.io/instance=disableservice" -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

I saw an issue

  Warning  Unhealthy  9s (x11 over 55s)  kubelet            Readiness probe failed: Get "http://10.42.3.203:3000/": dial tcp 10.42.3.203:3000: connect: connection refused
  Warning  Unhealthy  9s (x5 over 49s)   kubelet            Liveness probe failed: Get "http://10.42.3.203:3000/": dial tcp 10.42.3.203:3000: connect: connection refused

I really need to fix the main chart

/content/images/2024/01/newapp6-07.png

This is one of those cases I’m just going to update main

builder@DESKTOP-QADGF36:~/Workspaces/pyK8sService$ git commit -m 'minor fix on chart 0.2.3 for app port and default tag'
[main 73c3dd3] minor fix on chart 0.2.3 for app port and default tag
 2 files changed, 3 insertions(+), 3 deletions(-)
builder@DESKTOP-QADGF36:~/Workspaces/pyK8sService$ git push
Enumerating objects: 9, done.
Counting objects: 100% (9/9), done.
Delta compression using up to 16 threads
Compressing objects: 100% (5/5), done.
Writing objects: 100% (5/5), 459 bytes | 459.00 KiB/s, done.
Total 5 (delta 4), reused 0 (delta 0)
remote: . Processing 1 references
remote: Processed 1 references in total
To https://forgejo.freshbrewed.science/isaac/pyK8sService.git
   745b267..73c3dd3  main -> main
builder@DESKTOP-QADGF36:~/Workspaces/pyK8sService$ 

which pushed to Harbor

/content/images/2024/01/newapp6-08.png

I “Upgraded” my install to use it (note; it gets grumpy if you omit persistence.storageClass after an install. so i added that to the set)

$ helm upgrade disableservice --set appimage.tag="release.1.0.1" --set password=NotTheRealPassword --set persistence.storageClass='local-path' oci://harbor.freshbrewed.science/chartrepo/pyk8sservice --version 0.2.4
Pulled: harbor.freshbrewed.science/chartrepo/pyk8sservice:0.2.4
Digest: sha256:a711cd7427505cb1dbc76ed0102c4126af4d988e63e5c42b0cc45ffbac9a7c63
Release "disableservice" has been upgraded. Happy Helming!
NAME: disableservice
LAST DEPLOYED: Fri Dec 29 09:23:07 2023
NAMESPACE: default
STATUS: deployed
REVISION: 3
NOTES:
1. Get the application URL by running these commands:
  export POD_NAME=$(kubectl get pods --namespace default -l "app.kubernetes.io/name=pyk8sservice,app.kubernetes.io/instance=disableservice" -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

Now it’s running

$ kubectl get pods | grep disable
disabled-nginx-deployment-b49d6cc4c-v65f8                1/1     Running   0                 22d
disableservice-pyk8sservice-7774bcd4c8-jht8n             1/1     Running   0                 12m
disableservice-pyk8sservice-app-5655bc68d5-zw7tx         1/1     Running   0                 2m11s

The whole point, however, is that I really want a real test

I’ll create a quick A record

$ cat r53-disabledsvc.json
{
    "Comment": "CREATE disabledsvc fb.s A record ",
    "Changes": [
      {
        "Action": "CREATE",
        "ResourceRecordSet": {
          "Name": "disabledsvc.freshbrewed.science",
          "Type": "A",
          "TTL": 300,
          "ResourceRecords": [
            {
              "Value": "75.73.224.240"
            }
          ]
        }
      }
    ]
  }

$ aws route53 change-resource-record-sets --hosted-zone-id Z39E8QFU0F9PZP --change-batch file://r53-disabledsvc.json
{
    "ChangeInfo": {
        "Id": "/change/C01612762F0D85UD28N49",
        "Status": "PENDING",
        "SubmittedAt": "2023-12-29T15:30:52.944Z",
        "Comment": "CREATE disabledsvc fb.s A record "
    }
}

I’ll create a local helm values file to create a Nginx Ingress

appimage:
  tag: release.1.0.1
password: NotTheRealPassword
persistence:
  storageClass: local-path

ingress:
  enabled: true
  className: "nginx"
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-prod
    ingress.kubernetes.io/ssl-redirect: "true"
  hosts:
    - host: disabledsvc.freshbrewed.science
      paths:
        - path: /
          pathType: ImplementationSpecific
  tls:
   - secretName: disabledsvc-tls
     hosts:
       - disabledsvc.freshbrewed.science

Then an upgrade

$ helm upgrade disableservice -f dsvalues.yaml oci://harbor.freshbrewed.science/chartrepo/pyk8sservice --version 0.2.4
Pulled: harbor.freshbrewed.science/chartrepo/pyk8sservice:0.2.4
Digest: sha256:a711cd7427505cb1dbc76ed0102c4126af4d988e63e5c42b0cc45ffbac9a7c63
Release "disableservice" has been upgraded. Happy Helming!
NAME: disableservice
LAST DEPLOYED: Fri Dec 29 09:36:06 2023
NAMESPACE: default
STATUS: deployed
REVISION: 5
NOTES:
1. Get the application URL by running these commands:
  https://disabledsvc.freshbrewed.science/

My first test isn’t looking too good

/content/images/2024/01/newapp6-09.png

Oops - the Ingress yaml need to use “appservice” port, not “service” port

{{- $svcPort := .Values.appservice.port -}}

I fixed and set the chart version to

version: 0.2.5

I pushed to main

builder@DESKTOP-QADGF36:~/Workspaces/pyK8sService$ git commit -m 'fix ingress, chart version to 0.2.5'
[main 8f83d18] fix ingress, chart version to 0.2.5
 3 files changed, 3 insertions(+), 3 deletions(-)
builder@DESKTOP-QADGF36:~/Workspaces/pyK8sService$ git push
Enumerating objects: 13, done.
Counting objects: 100% (13/13), done.
Delta compression using up to 16 threads
Compressing objects: 100% (7/7), done.
Writing objects: 100% (7/7), 640 bytes | 640.00 KiB/s, done.
Total 7 (delta 5), reused 0 (delta 0)
remote: . Processing 1 references
remote: Processed 1 references in total
To https://forgejo.freshbrewed.science/isaac/pyK8sService.git
   73c3dd3..8f83d18  main -> main

Then upgraded my chart

$ helm upgrade disableservice -f dsvalues.yaml oci://harbor.freshbrewed.science/chartrepo/pyk8sservice --version 0.2.5
Pulled: harbor.freshbrewed.science/chartrepo/pyk8sservice:0.2.5
Digest: sha256:1358f677b5b8c0b2706b843c9559985f57cb9206cd7e36a65a15d41f4d65aa50
Release "disableservice" has been upgraded. Happy Helming!
NAME: disableservice
LAST DEPLOYED: Fri Dec 29 09:46:55 2023
NAMESPACE: default
STATUS: deployed
REVISION: 6
NOTES:
1. Get the application URL by running these commands:
  https://disabledsvc.freshbrewed.science/

Now it works!

/content/images/2024/01/newapp6-10.png

When I did a wrong password I saw my error page

/content/images/2024/01/newapp6-11.png

With the right password, I saw my huge list of services

/content/images/2024/01/newapp6-12.png

I’ll store an Ingress

/content/images/2024/01/newapp6-13.png

I’ll disable the service and try to reach it

/content/images/2024/01/newapp6-14.png

Then I restored it and tested

/content/images/2024/01/newapp6-15.png

Because I wanted this safely shared, I synced it to Codeberg here

Summary

With six total posts we finally wrapped up this series. We started just with an idea, “Something to disable services safely”. From there we created User Stories in Forgejo PjM. We created some basic Node and Python apps (sticking with python), then iteratively bit-by-bit developed a functional working shareable app.

The code is live and I hope it finds use with others. I mostly developed it out of my own needs. I can see room for improvements such as handling VirtualServices instead of Ingresses, handling complicated ingress definitions and storing and showing date/time of files. I could see handling multiple namespaces (which would complicate the RBAC a bit).

My goal, in the end, was to show how to get going with making an Open-Source app and sharing it. I am not a great Python programmer. A far amount was coached along by Copilot and OpenAI queries. There was a lot of in-between slop too - I checked Harbor and I ultimately stored 91 total images developing this out.

/content/images/2024/01/newapp6-16.png

Thanks for reading along and if interested, here are links to all the posts:

  1. Part 1 - Moving Past the Blank Page
  2. Part 2 - NodeJS and Python
  3. Part 3 - Working disable action, DR and CICD
  4. Part 4 - Pivots, Releases and Helm
  5. Part 5 - Store, Restore
  6. Part 6 - Passwords and Release this one
Kubernetes App Container Docker Helm Python Harbor Opensource

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