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.
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
I updated the chart with a default value
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
A bad password doesn’t look too hot
But the real value looks okay
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
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
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.
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
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:
The store option now looks much better
as does disable
Note: I did quick fix that typo on any page that had an extra }
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
The action completed without issue which shows me I didn’t miss any files when I staged
We can also see this PR is linked to the issue because of the “Fixes #8” line
I merged and deleted the branch
I’ll then want a tagged release.
I’ll create a new 1.0.0 tag from main
which created
Then ran the tag build
I can see the new chart in Harbor
$ 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
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.
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
I also made a new release 1.0.1 tag after merging to main
When the build completed
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
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
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
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
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!
When I did a wrong password I saw my error page
With the right password, I saw my huge list of services
I’ll store an Ingress
I’ll disable the service and try to reach it
Then I restored it and tested
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.
Thanks for reading along and if interested, here are links to all the posts: