From 2d1f6a21b7d05e7cba9c76510c6b602bd8fb099b Mon Sep 17 00:00:00 2001 From: Isaac Johnson Date: Sat, 19 Sep 2026 15:49:53 -0500 Subject: [PATCH] first --- .dockerignore | 5 + .gitea/workflows/cicd.yaml | 89 ++++++ .gitignore | 191 +++++++++++++ Dockerfile | 16 ++ app.py | 390 +++++++++++++++++++++++++++ cicd.yaml | 0 helm-chart/.helmignore | 5 + helm-chart/Chart.yaml | 6 + helm-chart/templates/_helpers.tpl | 51 ++++ helm-chart/templates/deployment.yaml | 73 +++++ helm-chart/templates/ingress.yaml | 41 +++ helm-chart/templates/service.yaml | 15 ++ helm-chart/values.yaml | 63 +++++ requirements.txt | 2 + version.ini | 2 + 15 files changed, 949 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitea/workflows/cicd.yaml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 app.py create mode 100644 cicd.yaml create mode 100644 helm-chart/.helmignore create mode 100644 helm-chart/Chart.yaml create mode 100644 helm-chart/templates/_helpers.tpl create mode 100644 helm-chart/templates/deployment.yaml create mode 100644 helm-chart/templates/ingress.yaml create mode 100644 helm-chart/templates/service.yaml create mode 100644 helm-chart/values.yaml create mode 100644 requirements.txt create mode 100644 version.ini diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..4f98889 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +__pycache__ +*.pyc +*.pyo +*.pyd +.git diff --git a/.gitea/workflows/cicd.yaml b/.gitea/workflows/cicd.yaml new file mode 100644 index 0000000..cb875e4 --- /dev/null +++ b/.gitea/workflows/cicd.yaml @@ -0,0 +1,89 @@ +name: Build and Publish Docker Image + +on: + push: + branches: + - main + +jobs: + build-and-push: + name: Build and Push Docker Image + runs-on: my_custom_label + container: node:22 + steps: + - name: Checkout Code + uses: actions/checkout@v3 + + - name: Prepare Env for Docker + run: | + whoami + which docker || true + apt update + cat /etc/os-release + apt install -y ca-certificates curl gnupg + mkdir -p /etc/apt/keyrings + curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg + echo \ + "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \ + focal stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null + apt update + DEBIAN_FRONTEND=noninteractive apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin + + - name: Get Version from version.ini + id: get_version + run: | + # Extract version value from version.ini + VERSION=$(awk -F'=[ \t]*' '/^version[ \t]*=/ {gsub(/[ \t"'\''\r]/, "", $2); print $2}' version.ini) + echo "VERSION=$VERSION" >> $GITHUB_ENV + echo "version=$VERSION" >> $GITHUB_OUTPUT + + - name: Build Dockerfile + run: | + export BUILDIMGTAG="`cat Dockerfile | tail -n1 | sed 's/^.*\///g'`" + docker build -t $BUILDIMGTAG:${{ env.VERSION }} . + docker images + + - name: Tag and Push (Harbor) + run: | + export BUILDIMGTAG="`cat Dockerfile | tail -n1 | sed 's/^.*\///g'`" + export FINALBUILDTAG="`cat Dockerfile | tail -n1 | sed 's/^#//g'`" + docker tag $BUILDIMGTAG:${{ env.VERSION }} $FINALBUILDTAG:${{ env.VERSION }} + docker images + echo $CR_PAT | docker login harbor.freshbrewed.science -u $CR_USER --password-stdin + docker push $FINALBUILDTAG:${{ env.VERSION }} + env: # Or as an environment variable + CR_PAT: ${{ secrets.CR_PAT }} + CR_USER: ${{ secrets.CR_USER }} + + - name: Tag and Push (Dockerhub) + run: | + export BUILDIMGTAG="`cat Dockerfile | tail -n1 | sed 's/^.*\///g'`" + docker tag $BUILDIMGTAG:${{ env.VERSION }} $DHUSER/$BUILDIMGTAG:${{ env.VERSION }} + docker images + echo $DHPAT | docker login -u $DHUSER --password-stdin + docker push $DHUSER/$BUILDIMGTAG:${{ env.VERSION }} + env: # Or as an environment variable + DHPAT: ${{ secrets.DHPAT }} + DHUSER: ${{ secrets.DHUSER }} + + - name: Prepare Env for Helm OCI Push (Helm v3 reference only) + run: | + if [ ! -f /tmp/linux-amd64/helm ]; then + apt update + DEBIAN_FRONTEND=noninteractive apt-get install -y unzip curl || true + wget https://get.helm.sh/helm-v3.14.0-linux-amd64.tar.gz -O /tmp/helm.tar.gz || true + cd /tmp + tar xzvf helm.tar.gz + chmod +x linux-amd64/helm + fi + + - name: Package Helm Chart + run: | + set -x + /tmp/linux-amd64/helm package ./helm-chart + export HLMPKG=`ls -tr *.tgz | tail -n1 | tr -d '\n'` + /tmp/linux-amd64/helm registry login harbor.freshbrewed.science -u $CR_USER -p $CR_PAT + /tmp/linux-amd64/helm push ./$HLMPKG oci://harbor.freshbrewed.science/chartrepo/botwebwars + env: # Or as an environment variable + CR_PAT: ${{ secrets.CR_PAT }} + CR_USER: ${{ secrets.CR_USER }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..600a4e9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,191 @@ +# Created by https://www.toptal.com/developers/gitignore/api/python,linux +# Edit at https://www.toptal.com/developers/gitignore?templates=python,linux + +### Linux ### +*~ + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + +### Python ### +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +### Python Patch ### +# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration +poetry.toml + +# ruff +.ruff_cache/ + +# LSP config files +pyrightconfig.json + +# End of https://www.toptal.com/developers/gitignore/api/python,linux diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..596af8a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +FROM cgr.dev/chainguard/python:latest-dev AS builder +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir --user -r requirements.txt + +FROM cgr.dev/chainguard/python:latest +WORKDIR /app + +COPY --from=builder /home/nonroot/.local /home/nonroot/.local +COPY . . + +ENV PATH="/home/nonroot/.local/bin:$PATH" +ENV PYTHONUNBUFFERED=1 + +CMD ["app.py"] diff --git a/app.py b/app.py new file mode 100644 index 0000000..59699ba --- /dev/null +++ b/app.py @@ -0,0 +1,390 @@ +import os +import configparser +import requests +from flask import Flask, render_template_string, jsonify + +app = Flask(__name__) + +OLLAMA_HOST = os.environ.get("OLLAMA_HOST", "http://harbor.freshbrewed.science:11444") +OLLAMA_MODEL = os.environ.get("OLLAMA_MODEL", "gemma4:e4b") +PROMPT = os.environ.get("PROMPT", "tell me a dad joke about programming") +PORT = int(os.environ.get("PORT", "80")) + +def get_app_version(): + config = configparser.ConfigParser() + version_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), "version.ini") + if os.path.exists(version_file): + try: + config.read(version_file) + return config.get("metadata", "version", fallback="unknown") + except Exception: + return "unknown" + return "unknown" + +HTML_TEMPLATE = """ + + + + + Programming Dad Jokes + + + +
+
+
+ + Ollama • Gemma 4 + • + v{{ version }} +
+

Programming Dad Joke

+

Freshly compiled humor from our AI model

+ +
+
{{ joke }}
+
+ +
+ +
+ +
+ Host: {{ host }} + Model: {{ model }} + Prompt: "{{ prompt }}" + App Version: v{{ version }} +
+
+
+ + + + +""" + +def request_joke(): + url = f"{OLLAMA_HOST.rstrip('/')}/api/generate" + payload = { + "model": OLLAMA_MODEL, + "prompt": PROMPT, + "stream": False + } + try: + resp = requests.post(url, json=payload, timeout=60) + resp.raise_for_status() + data = resp.json() + return data.get("response", "No response received from Ollama.") + except Exception as e: + return f"Error contacting Ollama ({OLLAMA_HOST}): {e}" + +@app.route("/") +def index(): + joke = request_joke() + version = get_app_version() + return render_template_string( + HTML_TEMPLATE, + joke=joke, + host=OLLAMA_HOST, + model=OLLAMA_MODEL, + prompt=PROMPT, + version=version + ) + +@app.route("/api/joke") +def api_joke(): + joke = request_joke() + version = get_app_version() + return jsonify({ + "joke": joke, + "version": version + }) + +if __name__ == "__main__": + version = get_app_version() + print(f"Starting server v{version} on port {PORT} with model '{OLLAMA_MODEL}' at {OLLAMA_HOST}...") + app.run(host="0.0.0.0", port=PORT) diff --git a/cicd.yaml b/cicd.yaml new file mode 100644 index 0000000..e69de29 diff --git a/helm-chart/.helmignore b/helm-chart/.helmignore new file mode 100644 index 0000000..f00410a --- /dev/null +++ b/helm-chart/.helmignore @@ -0,0 +1,5 @@ +.DS_Store +*.tgz +.git/ +.gitignore +.dockerignore diff --git a/helm-chart/Chart.yaml b/helm-chart/Chart.yaml new file mode 100644 index 0000000..786aded --- /dev/null +++ b/helm-chart/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: dadjoke-app +description: A basic Helm chart for the Programming Dad Joke Flask application +type: application +version: 0.1.0 +appVersion: "0.1" diff --git a/helm-chart/templates/_helpers.tpl b/helm-chart/templates/_helpers.tpl new file mode 100644 index 0000000..96aad52 --- /dev/null +++ b/helm-chart/templates/_helpers.tpl @@ -0,0 +1,51 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "dadjoke-app.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). +If release name contains chart name it will be used as a full name. +*/}} +{{- define "dadjoke-app.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "dadjoke-app.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "dadjoke-app.labels" -}} +helm.sh/chart: {{ include "dadjoke-app.chart" . }} +{{ include "dadjoke-app.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "dadjoke-app.selectorLabels" -}} +app.kubernetes.io/name: {{ include "dadjoke-app.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} diff --git a/helm-chart/templates/deployment.yaml b/helm-chart/templates/deployment.yaml new file mode 100644 index 0000000..9c2f467 --- /dev/null +++ b/helm-chart/templates/deployment.yaml @@ -0,0 +1,73 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "dadjoke-app.fullname" . }} + labels: + {{- include "dadjoke-app.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + {{- include "dadjoke-app.selectorLabels" . | nindent 6 }} + template: + metadata: + {{- with .Values.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "dadjoke-app.selectorLabels" . | nindent 8 }} + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + containers: + - name: {{ .Chart.Name }} + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: http + containerPort: {{ .Values.service.port }} + protocol: TCP + env: + - name: OLLAMA_HOST + value: {{ .Values.env.OLLAMA_HOST | quote }} + - name: OLLAMA_MODEL + value: {{ .Values.env.OLLAMA_MODEL | quote }} + - name: PROMPT + value: {{ .Values.env.PROMPT | quote }} + - name: PORT + value: {{ .Values.env.PORT | quote }} + livenessProbe: + httpGet: + path: / + port: http + initialDelaySeconds: 10 + periodSeconds: 30 + timeoutSeconds: 5 + readinessProbe: + httpGet: + path: / + port: http + initialDelaySeconds: 5 + periodSeconds: 15 + timeoutSeconds: 5 + resources: + {{- toYaml .Values.resources | nindent 12 }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/helm-chart/templates/ingress.yaml b/helm-chart/templates/ingress.yaml new file mode 100644 index 0000000..98b8df8 --- /dev/null +++ b/helm-chart/templates/ingress.yaml @@ -0,0 +1,41 @@ +{{- if .Values.ingress.enabled -}} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "dadjoke-app.fullname" . }} + labels: + {{- include "dadjoke-app.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if .Values.ingress.className }} + 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 }} + pathType: {{ .pathType }} + backend: + service: + name: {{ include "dadjoke-app.fullname" $ }} + port: + number: {{ $.Values.service.port }} + {{- end }} + {{- end }} +{{- end }} diff --git a/helm-chart/templates/service.yaml b/helm-chart/templates/service.yaml new file mode 100644 index 0000000..f9c902b --- /dev/null +++ b/helm-chart/templates/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "dadjoke-app.fullname" . }} + labels: + {{- include "dadjoke-app.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: http + protocol: TCP + name: http + selector: + {{- include "dadjoke-app.selectorLabels" . | nindent 4 }} diff --git a/helm-chart/values.yaml b/helm-chart/values.yaml new file mode 100644 index 0000000..8e142a6 --- /dev/null +++ b/helm-chart/values.yaml @@ -0,0 +1,63 @@ +replicaCount: 1 + +image: + repository: dadjoke-app + pullPolicy: IfNotPresent + tag: "" + +imagePullSecrets: [] +nameOverride: "" +fullnameOverride: "" + +serviceAccount: + create: false + name: "" + +podAnnotations: {} + +podSecurityContext: {} + +securityContext: + runAsNonRoot: true + runAsUser: 65532 + runAsGroup: 65532 + +service: + type: ClusterIP + port: 80 + +env: + OLLAMA_HOST: "http://harbor.freshbrewed.science:11444" + OLLAMA_MODEL: "gemma4:e4b" + PROMPT: "tell me a dad joke about programming" + PORT: "80" + +ingress: + enabled: true + className: "nginx" + annotations: + kubernetes.io/ingress.class: nginx + nginx.ingress.kubernetes.io/ssl-redirect: "false" + nginx.ingress.kubernetes.io/proxy-connect-timeout: "60" + nginx.ingress.kubernetes.io/proxy-read-timeout: "60" + nginx.ingress.kubernetes.io/proxy-send-timeout: "60" + hosts: + - host: dadjoke.local + paths: + - path: / + pathType: Prefix + tls: [] + +resources: + limits: + cpu: 500m + memory: 256Mi + requests: + cpu: 100m + memory: 128Mi + +nodeSelector: {} + +tolerations: [] + +affinity: {} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..fc2fc81 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +Flask>=3.0.0 +requests>=2.31.0 diff --git a/version.ini b/version.ini new file mode 100644 index 0000000..232ddc5 --- /dev/null +++ b/version.ini @@ -0,0 +1,2 @@ +[metadata] +version = 0.1