From b4f111149b385fcaf4810946727e5a14af1cbeba Mon Sep 17 00:00:00 2001 From: feedc0de Date: Sun, 18 Oct 2026 18:57:35 +0200 Subject: [PATCH] Add maintained vectorcraft Helm chart --- .gitea/workflows/ci.yml | 102 ++++++++++++++ README.md | 21 +++ chart/vectorcraft/Chart.yaml | 6 + chart/vectorcraft/templates/resources.yaml | 147 +++++++++++++++++++++ chart/vectorcraft/values.yaml | 24 ++++ image/Dockerfile | 13 ++ image/download.py | 34 +++++ image/nginx.conf | 28 ++++ install.sh | 10 ++ scripts/chart-version.sh | 9 ++ test.sh | 10 ++ 11 files changed, 404 insertions(+) create mode 100644 .gitea/workflows/ci.yml create mode 100644 README.md create mode 100644 chart/vectorcraft/Chart.yaml create mode 100644 chart/vectorcraft/templates/resources.yaml create mode 100644 chart/vectorcraft/values.yaml create mode 100644 image/Dockerfile create mode 100755 image/download.py create mode 100644 image/nginx.conf create mode 100755 install.sh create mode 100755 scripts/chart-version.sh create mode 100755 test.sh diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..9d9c4a8 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,102 @@ +name: Validate, publish and promote vectorcraft + +on: + push: + branches: [main] + pull_request: + +env: + HELM_VERSION: v4.2.2 + CHART_NAME: vectorcraft + GITOPS_REPOSITORY: feedc0de/homelab-gitops + IMAGE_REPOSITORY: registry.brunner.ninja/feedc0de/vectorcraft + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: azure/setup-helm@v4 + with: + version: ${{ env.HELM_VERSION }} + - run: ./test.sh + + publish: + if: gitea.ref == 'refs/heads/main' + needs: validate + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: azure/setup-helm@v4 + with: + version: ${{ env.HELM_VERSION }} + - uses: docker/login-action@v3 + with: + registry: registry.brunner.ninja + username: ${{ secrets.QUAY_USERNAME }} + password: ${{ secrets.QUAY_TOKEN }} + - name: Build immutable image and publish chart + env: + COMMIT_SHA: ${{ gitea.sha }} + PACKAGE_USERNAME: ${{ secrets.PACKAGE_USERNAME }} + PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }} + run: | + set -euo pipefail + CHART_VERSION=$(./scripts/chart-version.sh) + APP_VERSION=$(sed -n 's/^appVersion: *//p' "chart/${CHART_NAME}/Chart.yaml" | tr -d '"') + test -n "${APP_VERSION}" + docker build --pull --load \ + --build-arg "APP_NAME=${CHART_NAME}" \ + --build-arg "APP_VERSION=${APP_VERSION}" \ + --label "org.opencontainers.image.version=${APP_VERSION}" \ + --label "org.opencontainers.image.revision=${COMMIT_SHA}" \ + --tag "${IMAGE_REPOSITORY}:${CHART_VERSION}" image + docker push "${IMAGE_REPOSITORY}:${CHART_VERSION}" + install -d dist + helm package "chart/${CHART_NAME}" --destination dist --version "${CHART_VERSION}" + curl --fail-with-body \ + --user "${PACKAGE_USERNAME}:${PACKAGE_TOKEN}" \ + --request POST \ + --upload-file "dist/${CHART_NAME}-${CHART_VERSION}.tgz" \ + https://code.brunner.ninja/api/packages/feedc0de/helm/api/charts + printf 'CHART_VERSION=%s\n' "${CHART_VERSION}" >> "${GITHUB_ENV}" + - name: Check that this is still current main + id: current-main + env: + COMMIT_SHA: ${{ gitea.sha }} + run: | + git fetch --no-tags origin main + if [ "$(git rev-parse FETCH_HEAD)" = "${COMMIT_SHA}" ]; then + echo 'promote=true' >> "${GITEA_OUTPUT}" + else + echo 'promote=false' >> "${GITEA_OUTPUT}" + fi + - name: Check out GitOps + if: steps.current-main.outputs.promote == 'true' + uses: actions/checkout@v4 + with: + repository: ${{ env.GITOPS_REPOSITORY }} + ref: main + token: ${{ secrets.GITOPS_TOKEN }} + path: homelab-gitops + - name: Promote exact chart version + if: steps.current-main.outputs.promote == 'true' + working-directory: homelab-gitops + run: | + set -euo pipefail + ./scripts/update-chart-version.sh "${CHART_NAME}" "${CHART_VERSION}" + git config user.name feedc0de + git config user.email feedc0de@users.noreply.code.brunner.ninja + git add "bootstrap/${CHART_NAME}.yaml" + if git diff --cached --quiet; then exit 0; fi + git commit -m "Deploy ${CHART_NAME} chart ${CHART_VERSION}" + for attempt in 1 2 3; do + if git push origin HEAD:main; then exit 0; fi + test "${attempt}" != 3 + git fetch origin main + git rebase origin/main + done diff --git a/README.md b/README.md new file mode 100644 index 0000000..5852815 --- /dev/null +++ b/README.md @@ -0,0 +1,21 @@ +# vectorcraft + +This repository maintains the container image and Helm chart for the +Storytold **vectorcraft** web application. The image contains the versioned upstream +bundle; pods do not download application code at startup. + +Install the published chart: + +```sh +helm repo add brunner.ninja https://brunner.ninja/charts +helm repo update brunner.ninja +helm upgrade --install vectorcraft brunner.ninja/vectorcraft +``` + +`Chart.yaml.appVersion` records the upstream application version. Git tags +version the chart independently. A `v1.0.0` tag publishes chart `1.0.0`; later +commits publish immutable versions such as `1.0.0-1-gabcdef0` using +`git describe --tags --long`. + +The homelab-specific hostname and scheduling values live in +[`homelab-gitops/apps/vectorcraft`](https://code.brunner.ninja/feedc0de/homelab-gitops/src/branch/main/apps/vectorcraft). diff --git a/chart/vectorcraft/Chart.yaml b/chart/vectorcraft/Chart.yaml new file mode 100644 index 0000000..f2a4025 --- /dev/null +++ b/chart/vectorcraft/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: vectorcraft +description: Community Kubernetes deployment for Storytold vectorcraft +type: application +version: 0.1.0 +appVersion: v0.12.0 diff --git a/chart/vectorcraft/templates/resources.yaml b/chart/vectorcraft/templates/resources.yaml new file mode 100644 index 0000000..7bbb815 --- /dev/null +++ b/chart/vectorcraft/templates/resources.yaml @@ -0,0 +1,147 @@ +{{- $name := .Chart.Name -}} +{{- $repository := default (printf "registry.brunner.ninja/feedc0de/%s" $name) .Values.image.repository -}} +{{- $tag := default .Chart.Version .Values.image.tag -}} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ $name }} + labels: + app.kubernetes.io/name: {{ $name }} + app.kubernetes.io/instance: {{ .Release.Name }} + app.kubernetes.io/part-of: artcraft + app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +spec: + replicas: {{ .Values.replicaCount }} + revisionHistoryLimit: 3 + strategy: + type: RollingUpdate + rollingUpdate: {maxSurge: 1, maxUnavailable: 0} + selector: + matchLabels: + app.kubernetes.io/name: {{ $name }} + app.kubernetes.io/part-of: artcraft + template: + metadata: + labels: + app.kubernetes.io/name: {{ $name }} + app.kubernetes.io/instance: {{ .Release.Name }} + app.kubernetes.io/part-of: artcraft + spec: + automountServiceAccountToken: false + securityContext: + runAsNonRoot: true + runAsUser: 101 + runAsGroup: 101 + seccompProfile: {type: RuntimeDefault} + {{- with .Values.imagePullSecrets }} + imagePullSecrets: {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: {{ $name }} + image: {{ printf "%s:%s" $repository $tag | quote }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: [{name: http, containerPort: 8080}] + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: {drop: [ALL]} + resources: {{- toYaml .Values.resources | nindent 10 }} + startupProbe: + httpGet: {path: /index.html, port: http} + periodSeconds: 2 + timeoutSeconds: 2 + failureThreshold: 30 + readinessProbe: + httpGet: {path: /index.html, port: http} + periodSeconds: 5 + timeoutSeconds: 2 + failureThreshold: 3 + livenessProbe: + httpGet: {path: /index.html, port: http} + periodSeconds: 15 + timeoutSeconds: 2 + failureThreshold: 3 + volumeMounts: + - {name: tmp, mountPath: /tmp} + - name: nginx-exporter + image: {{ .Values.metrics.exporter.image }} + args: [--nginx.scrape-uri=http://127.0.0.1:8081/nginx_status] + ports: [{name: metrics, containerPort: 9113}] + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: {drop: [ALL]} + resources: {{- toYaml .Values.metrics.exporter.resources | nindent 10 }} + readinessProbe: + httpGet: {path: /metrics, port: metrics} + periodSeconds: 10 + livenessProbe: + httpGet: {path: /metrics, port: metrics} + periodSeconds: 20 + volumes: + - name: tmp + emptyDir: {sizeLimit: 1Gi} + {{- with .Values.nodeSelector }} + nodeSelector: {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.topologySpreadConstraints }} + topologySpreadConstraints: {{- toYaml . | nindent 8 }} + {{- end }} +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ $name }} + labels: + app.kubernetes.io/name: {{ $name }} + app.kubernetes.io/instance: {{ .Release.Name }} + app.kubernetes.io/part-of: artcraft +spec: + selector: + app.kubernetes.io/name: {{ $name }} + app.kubernetes.io/part-of: artcraft + ports: + - {name: http, port: 80, targetPort: http} + - {name: metrics, port: 9113, targetPort: metrics} +{{- if .Values.ingress.enabled }} +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ $name }} + labels: + app.kubernetes.io/name: {{ $name }} + app.kubernetes.io/instance: {{ .Release.Name }} + app.kubernetes.io/part-of: artcraft + {{- with .Values.ingress.annotations }} + annotations: {{- toYaml . | nindent 4 }} + {{- end }} +spec: + ingressClassName: {{ .Values.ingress.className }} + tls: [{hosts: [{{ .Values.ingress.host | quote }}]}] + rules: + - host: {{ .Values.ingress.host }} + http: + paths: + - path: / + pathType: Prefix + backend: + service: {name: "{{ $name }}", port: {name: http}} +{{- end }} +{{- if .Values.metrics.serviceMonitor.enabled }} +--- +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: {{ $name }} + labels: + app.kubernetes.io/name: {{ $name }} + app.kubernetes.io/instance: {{ .Release.Name }} + app.kubernetes.io/part-of: artcraft +spec: + selector: + matchLabels: + app.kubernetes.io/name: {{ $name }} + app.kubernetes.io/part-of: artcraft + endpoints: [{port: metrics, path: /metrics, interval: 30s, scrapeTimeout: 10s}] +{{- end }} diff --git a/chart/vectorcraft/values.yaml b/chart/vectorcraft/values.yaml new file mode 100644 index 0000000..d6083bd --- /dev/null +++ b/chart/vectorcraft/values.yaml @@ -0,0 +1,24 @@ +replicaCount: 1 +image: + repository: "" + tag: "" + pullPolicy: IfNotPresent +imagePullSecrets: [] +ingress: + enabled: false + className: "" + host: vectorcraft.example.com + annotations: {} +metrics: + serviceMonitor: + enabled: false + exporter: + image: docker.io/nginx/nginx-prometheus-exporter:1.5.3 + resources: + requests: {cpu: 5m, memory: 16Mi} + limits: {cpu: 100m, memory: 64Mi} +nodeSelector: {} +topologySpreadConstraints: [] +resources: + requests: {cpu: 10m, memory: 32Mi} + limits: {cpu: 500m, memory: 128Mi} diff --git a/image/Dockerfile b/image/Dockerfile new file mode 100644 index 0000000..0b8f3d6 --- /dev/null +++ b/image/Dockerfile @@ -0,0 +1,13 @@ +FROM docker.io/library/python:3.13-alpine AS fetch + +ARG APP_NAME +ARG APP_VERSION +COPY download.py /usr/local/bin/download.py +RUN APP_NAME="${APP_NAME}" APP_VERSION="${APP_VERSION}" python /usr/local/bin/download.py + +FROM docker.io/library/nginx:1.30.6-alpine +COPY nginx.conf /etc/nginx-artcraft.conf +COPY --from=fetch /srv/web /srv/web +USER 101:101 +EXPOSE 8080 +CMD ["nginx", "-g", "daemon off;", "-c", "/etc/nginx-artcraft.conf"] diff --git a/image/download.py b/image/download.py new file mode 100755 index 0000000..20a7efb --- /dev/null +++ b/image/download.py @@ -0,0 +1,34 @@ +import os +from pathlib import Path +import shutil +import tempfile +import urllib.request +import zipfile + +name = os.environ["APP_NAME"] +version = os.environ["APP_VERSION"] +plain_version = version.removeprefix("v") +url = f"https://github.com/storytold/{name}/releases/download/{version}/{name}-web-{plain_version}.zip" +with tempfile.TemporaryDirectory() as tmp: + archive = Path(tmp) / "release.zip" + urllib.request.urlretrieve(url, archive) + unpacked = Path(tmp) / "unpacked" + with zipfile.ZipFile(archive) as bundle: + for member in bundle.infolist(): + path = Path(member.filename) + if path.is_absolute() or ".." in path.parts or (member.external_attr >> 16) & 0o170000 == 0o120000: + raise ValueError(f"unsafe archive entry: {path}") + bundle.extractall(unpacked) + roots = [path.parent for path in unpacked.glob("*/index.html")] + if (unpacked / "index.html").exists(): + roots = [unpacked] + if len(roots) != 1: + raise ValueError("expected exactly one web root") + target = Path("/srv/web") + target.mkdir(parents=True) + for source in roots[0].iterdir(): + destination = target / source.name + if source.is_dir(): + shutil.copytree(source, destination) + else: + shutil.copy2(source, destination) diff --git a/image/nginx.conf b/image/nginx.conf new file mode 100644 index 0000000..91b8d1f --- /dev/null +++ b/image/nginx.conf @@ -0,0 +1,28 @@ +worker_processes auto; +pid /tmp/nginx.pid; +error_log /dev/stderr warn; +events { worker_connections 1024; } +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + access_log /dev/stdout; + client_body_temp_path /tmp/client_temp; + sendfile on; + gzip on; + gzip_types application/wasm application/javascript text/css application/json; + server { + listen 8080; + root /srv/web; + index index.html; + add_header X-Content-Type-Options nosniff always; + add_header Cache-Control no-cache always; + location / { try_files $uri $uri/ =404; } + location ~ /\. { deny all; } + } + server { + listen 127.0.0.1:8081; + access_log off; + location = /nginx_status { stub_status; } + location / { return 404; } + } +} diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..a1c414f --- /dev/null +++ b/install.sh @@ -0,0 +1,10 @@ +#!/bin/sh +set -eu + +namespace=${NAMESPACE:-default} +release=${RELEASE_NAME:-vectorcraft} +helm repo add brunner.ninja https://brunner.ninja/charts --force-update +helm repo update brunner.ninja +helm upgrade --install "$release" brunner.ninja/vectorcraft \ + --namespace "$namespace" --create-namespace \ + --wait --timeout 10m "$@" diff --git a/scripts/chart-version.sh b/scripts/chart-version.sh new file mode 100755 index 0000000..4a7ba2e --- /dev/null +++ b/scripts/chart-version.sh @@ -0,0 +1,9 @@ +#!/bin/sh +set -eu + +description=$(git describe --tags --long --match 'v[0-9]*') +version=${description#v} +case "$version" in + *-0-g*) version=${version%%-0-g*} ;; +esac +printf '%s\n' "$version" diff --git a/test.sh b/test.sh new file mode 100755 index 0000000..98655cb --- /dev/null +++ b/test.sh @@ -0,0 +1,10 @@ +#!/bin/sh +set -eu + +chart="chart/vectorcraft" +helm lint "$chart" +rendered=$(mktemp) +trap 'rm -f "$rendered"' EXIT +helm template vectorcraft "$chart" > "$rendered" +grep -q '^kind: Deployment$' "$rendered" +grep -q '^kind: Service$' "$rendered"