From 227e9c97b612ee2f37637efa0869b4d8288f225b Mon Sep 17 00:00:00 2001 From: 0xFEEDC0DE64 Date: Wed, 21 Oct 2026 17:10:20 +0200 Subject: [PATCH] Create portable ComfyUI Helm chart --- .gitea/workflows/chart.yml | 71 ++++++++++++++++ .gitignore | 2 + README.md | 37 +++++++++ chart/comfyui/Chart.yaml | 13 +++ chart/comfyui/templates/_helpers.tpl | 36 ++++++++ chart/comfyui/templates/deployment.yaml | 104 ++++++++++++++++++++++++ chart/comfyui/templates/pvc.yaml | 17 ++++ chart/comfyui/templates/service.yaml | 15 ++++ chart/comfyui/values.schema.json | 74 +++++++++++++++++ chart/comfyui/values.yaml | 76 +++++++++++++++++ install.sh | 7 ++ scripts/chart-version.sh | 12 +++ test.sh | 11 +++ tests/render_test.py | 41 ++++++++++ 14 files changed, 516 insertions(+) create mode 100644 .gitea/workflows/chart.yml create mode 100644 .gitignore create mode 100644 README.md create mode 100644 chart/comfyui/Chart.yaml create mode 100644 chart/comfyui/templates/_helpers.tpl create mode 100644 chart/comfyui/templates/deployment.yaml create mode 100644 chart/comfyui/templates/pvc.yaml create mode 100644 chart/comfyui/templates/service.yaml create mode 100644 chart/comfyui/values.schema.json create mode 100644 chart/comfyui/values.yaml create mode 100644 install.sh create mode 100755 scripts/chart-version.sh create mode 100755 test.sh create mode 100644 tests/render_test.py diff --git a/.gitea/workflows/chart.yml b/.gitea/workflows/chart.yml new file mode 100644 index 0000000..2e38e06 --- /dev/null +++ b/.gitea/workflows/chart.yml @@ -0,0 +1,71 @@ +name: Validate, publish and promote ComfyUI chart +on: + push: + pull_request: +jobs: + chart: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: azure/setup-helm@v4 + with: + version: v4.3.0 + - name: Validate chart + run: | + python3 -m venv "${RUNNER_TEMP}/chart-test" + "${RUNNER_TEMP}/chart-test/bin/pip" install PyYAML + PATH="${RUNNER_TEMP}/chart-test/bin:${PATH}" ./test.sh + - name: Select immutable chart version + if: gitea.ref == 'refs/heads/main' + run: printf 'CHART_VERSION=%s\n' "$(./scripts/chart-version.sh)" >> "${GITHUB_ENV}" + - name: Package and publish chart + if: gitea.ref == 'refs/heads/main' + env: + PACKAGE_USERNAME: ${{ secrets.PACKAGE_USERNAME }} + PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }} + run: | + set -euo pipefail + test -n "${PACKAGE_USERNAME}" && test -n "${PACKAGE_TOKEN}" + mkdir -p .chart-packages + helm package chart/comfyui --destination .chart-packages --version "${CHART_VERSION}" + curl --fail --silent --show-error --user "${PACKAGE_USERNAME}:${PACKAGE_TOKEN}" \ + --request POST --upload-file ".chart-packages/comfyui-${CHART_VERSION}.tgz" \ + https://code.brunner.ninja/api/packages/feedc0de/helm/api/charts + - name: Check current main + id: current-main + if: gitea.ref == 'refs/heads/main' + env: + COMMIT_SHA: ${{ gitea.sha }} + run: | + git fetch --no-tags origin main + if test "$(git rev-parse FETCH_HEAD)" = "${COMMIT_SHA}"; then + echo 'promote=true' >> "${GITEA_OUTPUT}" + else + echo 'promote=false' >> "${GITEA_OUTPUT}" + fi + - uses: actions/checkout@v4 + if: steps.current-main.outputs.promote == 'true' + with: + repository: feedc0de/homelab-gitops + ref: main + token: ${{ secrets.GITOPS_TOKEN }} + path: homelab-gitops + - name: Promote chart + if: steps.current-main.outputs.promote == 'true' + working-directory: homelab-gitops + run: | + set -euo pipefail + ./scripts/update-chart-version.sh comfyui "${CHART_VERSION}" + git config user.name feedc0de + git config user.email feedc0de@users.noreply.code.brunner.ninja + git add bootstrap/comfyui.yaml + if git diff --cached --quiet; then exit 0; fi + git commit -m "Deploy comfyui chart ${CHART_VERSION}" + for attempt in 1 2 3; do + git push origin HEAD:main && exit 0 + test "${attempt}" = 3 && exit 1 + git fetch origin main + git rebase origin/main + done diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..67360e7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.chart-packages/ +__pycache__/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..e209ac3 --- /dev/null +++ b/README.md @@ -0,0 +1,37 @@ +# ComfyUI Helm chart + +This adapter chart runs the public ComfyUI container image and keeps model, +input, output, and user data in one PVC. It avoids the image's boot-time custom +node installation path by starting ComfyUI directly. The image tag and tested +ComfyUI release are recorded in `Chart.yaml`. + +Install the public chart: + +```sh +helm repo add brunner.ninja https://brunner.ninja/charts +helm repo update brunner.ninja +helm upgrade --install comfyui brunner.ninja/comfyui \ + --namespace comfyui --create-namespace --wait --timeout 10m +``` + +Set `persistence.existingClaim` to reuse a pre-existing model/data volume. For +another cluster, leave it empty and configure `persistence.size` and +`persistence.storageClass`. `args`, node placement, and resource sizing can be +overridden for the available CPU/GPU hardware. The chart creates an internal +Service on port 8188; it does not expose the editor publicly by default. + +This repository owns the portable Deployment, probes, Service, data-directory +initialization, and optional PVC. A consuming GitOps repository owns only its +selected chart version and cluster values. The homelab instance is managed by +the separate `homelab-gitops` Application `comfyui` in namespace `ollama`. + +## Validation and releases + +Run `./test.sh` to lint and render the chart and verify that its Service, +selectors, ports, PVC, mounts, and probes fit together. + +Git tags follow the application release (`v0.8.2` for ComfyUI 0.8.2). CI uses +`git describe --tags --long` for immutable package versions; later chart-only +commits retain their count and Git hash. The public chart repository is +`brunner.ninja/charts`. Gitea Actions needs `PACKAGE_USERNAME`, `PACKAGE_TOKEN`, +and `GITOPS_TOKEN`; the workflow has no Kubernetes credentials. diff --git a/chart/comfyui/Chart.yaml b/chart/comfyui/Chart.yaml new file mode 100644 index 0000000..c1f876e --- /dev/null +++ b/chart/comfyui/Chart.yaml @@ -0,0 +1,13 @@ +apiVersion: v2 +name: comfyui +description: A portable ComfyUI server chart using the public ComfyUI container image +type: application +version: 0.8.2 +appVersion: "0.6.3-comfyui-0.8.2" +home: https://www.comfy.org/ +sources: + - https://github.com/comfyanonymous/ComfyUI + - https://github.com/lecode-official/comfyui-docker +keywords: + - comfyui + - image-generation diff --git a/chart/comfyui/templates/_helpers.tpl b/chart/comfyui/templates/_helpers.tpl new file mode 100644 index 0000000..3138155 --- /dev/null +++ b/chart/comfyui/templates/_helpers.tpl @@ -0,0 +1,36 @@ +{{- define "comfyui.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{- define "comfyui.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 -}} + +{{- define "comfyui.selectorLabels" -}} +app.kubernetes.io/name: {{ include "comfyui.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end -}} + +{{- define "comfyui.labels" -}} +{{ include "comfyui.selectorLabels" . }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | quote }} +{{- end -}} + +{{- define "comfyui.dataClaim" -}} +{{- if .Values.persistence.existingClaim -}} +{{- .Values.persistence.existingClaim -}} +{{- else -}} +{{- printf "%s-data" (include "comfyui.fullname" .) -}} +{{- end -}} +{{- end -}} diff --git a/chart/comfyui/templates/deployment.yaml b/chart/comfyui/templates/deployment.yaml new file mode 100644 index 0000000..53ff43a --- /dev/null +++ b/chart/comfyui/templates/deployment.yaml @@ -0,0 +1,104 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "comfyui.fullname" . }} + labels: + {{- include "comfyui.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + strategy: + {{- toYaml .Values.strategy | nindent 4 }} + selector: + matchLabels: + {{- include "comfyui.selectorLabels" . | nindent 6 }} + template: + metadata: + labels: + {{- include "comfyui.labels" . | nindent 8 }} + {{- with .Values.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + automountServiceAccountToken: false + terminationGracePeriodSeconds: {{ .Values.terminationGracePeriodSeconds }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.podSecurityContext }} + securityContext: + {{- toYaml . | nindent 8 }} + {{- end }} + initContainers: + - name: data-directories + image: {{ .Values.initContainer.image | quote }} + command: [sh, -ec] + args: + - mkdir -p /data/models /data/output /data/input /data/user + resources: + {{- toYaml .Values.initContainer.resources | nindent 12 }} + volumeMounts: + - name: data + mountPath: /data + containers: + - name: comfyui + image: {{ printf "%s:%s" .Values.image.repository (default .Chart.AppVersion .Values.image.tag) | quote }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + command: + {{- toYaml .Values.command | nindent 12 }} + workingDir: {{ .Values.workingDir | quote }} + args: + {{- toYaml .Values.args | nindent 12 }} + ports: + - name: http + containerPort: 8188 + protocol: TCP + startupProbe: + httpGet: + path: /system_stats + port: http + {{- toYaml .Values.probes.startup | nindent 12 }} + readinessProbe: + httpGet: + path: /system_stats + port: http + {{- toYaml .Values.probes.readiness | nindent 12 }} + livenessProbe: + httpGet: + path: /system_stats + port: http + {{- toYaml .Values.probes.liveness | nindent 12 }} + resources: + {{- toYaml .Values.resources | nindent 12 }} + securityContext: + {{- toYaml .Values.containerSecurityContext | nindent 12 }} + volumeMounts: + - name: data + mountPath: /opt/comfyui/models + subPath: models + - name: data + mountPath: /opt/comfyui/output + subPath: output + - name: data + mountPath: /opt/comfyui/input + subPath: input + - name: data + mountPath: /opt/comfyui/user + subPath: user + volumes: + - name: data + {{- if .Values.persistence.enabled }} + persistentVolumeClaim: + claimName: {{ include "comfyui.dataClaim" . }} + {{- else }} + emptyDir: {} + {{- end }} diff --git a/chart/comfyui/templates/pvc.yaml b/chart/comfyui/templates/pvc.yaml new file mode 100644 index 0000000..26b9b37 --- /dev/null +++ b/chart/comfyui/templates/pvc.yaml @@ -0,0 +1,17 @@ +{{- if and .Values.persistence.enabled (not .Values.persistence.existingClaim) }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "comfyui.dataClaim" . }} + labels: + {{- include "comfyui.labels" . | nindent 4 }} +spec: + accessModes: + {{- toYaml .Values.persistence.accessModes | nindent 4 }} + resources: + requests: + storage: {{ .Values.persistence.size }} + {{- if .Values.persistence.storageClass }} + storageClassName: {{ .Values.persistence.storageClass | quote }} + {{- end }} +{{- end }} diff --git a/chart/comfyui/templates/service.yaml b/chart/comfyui/templates/service.yaml new file mode 100644 index 0000000..cbcaf1f --- /dev/null +++ b/chart/comfyui/templates/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "comfyui.fullname" . }} + labels: + {{- include "comfyui.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + selector: + {{- include "comfyui.selectorLabels" . | nindent 4 }} + ports: + - name: http + port: {{ .Values.service.port }} + targetPort: http + protocol: TCP diff --git a/chart/comfyui/values.schema.json b/chart/comfyui/values.schema.json new file mode 100644 index 0000000..a80328a --- /dev/null +++ b/chart/comfyui/values.schema.json @@ -0,0 +1,74 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "additionalProperties": false, + "properties": { + "global": { "type": "object" }, + "replicaCount": { "type": "integer", "minimum": 1 }, + "image": { + "type": "object", + "additionalProperties": false, + "required": ["repository", "tag", "pullPolicy"], + "properties": { + "repository": { "type": "string", "minLength": 1 }, + "tag": { "type": "string" }, + "pullPolicy": { "type": "string", "enum": ["Always", "IfNotPresent", "Never"] } + } + }, + "command": { "type": "array", "items": { "type": "string" } }, + "workingDir": { "type": "string" }, + "args": { "type": "array", "items": { "type": "string" } }, + "strategy": { + "type": "object", + "properties": { "type": { "type": "string", "enum": ["Recreate", "RollingUpdate"] } } + }, + "terminationGracePeriodSeconds": { "type": "integer", "minimum": 0 }, + "service": { + "type": "object", + "additionalProperties": false, + "required": ["type", "port"], + "properties": { + "type": { "type": "string", "enum": ["ClusterIP", "NodePort", "LoadBalancer"] }, + "port": { "type": "integer", "minimum": 1, "maximum": 65535 } + } + }, + "persistence": { + "type": "object", + "additionalProperties": false, + "required": ["enabled", "existingClaim", "size", "storageClass", "accessModes"], + "properties": { + "enabled": { "type": "boolean" }, + "existingClaim": { "type": "string" }, + "size": { "type": "string", "minLength": 1 }, + "storageClass": { "type": "string" }, + "accessModes": { "type": "array", "minItems": 1, "items": { "type": "string" } } + } + }, + "resources": { "type": "object" }, + "nodeSelector": { "type": "object" }, + "tolerations": { "type": "array", "items": { "type": "object" } }, + "affinity": { "type": "object" }, + "podAnnotations": { "type": "object" }, + "podSecurityContext": { "type": "object" }, + "containerSecurityContext": { "type": "object" }, + "initContainer": { + "type": "object", + "additionalProperties": false, + "required": ["image", "resources"], + "properties": { + "image": { "type": "string", "minLength": 1 }, + "resources": { "type": "object" } + } + }, + "probes": { + "type": "object", + "additionalProperties": false, + "required": ["startup", "readiness", "liveness"], + "properties": { + "startup": { "type": "object" }, + "readiness": { "type": "object" }, + "liveness": { "type": "object" } + } + } + } +} diff --git a/chart/comfyui/values.yaml b/chart/comfyui/values.yaml new file mode 100644 index 0000000..86a157d --- /dev/null +++ b/chart/comfyui/values.yaml @@ -0,0 +1,76 @@ +replicaCount: 1 + +image: + repository: ghcr.io/lecode-official/comfyui-docker + tag: "" + pullPolicy: IfNotPresent + +command: + - /opt/conda/bin/python + - /opt/comfyui/main.py +workingDir: /opt/comfyui +args: + - --listen + - 0.0.0.0 + - --port + - "8188" + - --disable-all-custom-nodes + - --disable-auto-launch + - --cpu + - --force-fp32 + - --fp32-text-enc + - --fp32-vae + - --cache-none + +strategy: + type: Recreate +terminationGracePeriodSeconds: 60 + +service: + type: ClusterIP + port: 8188 + +persistence: + enabled: true + existingClaim: "" + size: 20Gi + storageClass: "" + accessModes: + - ReadWriteOnce + +resources: {} +nodeSelector: {} +tolerations: [] +affinity: {} +podAnnotations: {} +podSecurityContext: {} +containerSecurityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + seccompProfile: + type: RuntimeDefault + +initContainer: + image: docker.io/library/busybox:1.38.0 + resources: + requests: + cpu: 10m + memory: 16Mi + limits: + memory: 64Mi + +probes: + startup: + periodSeconds: 10 + timeoutSeconds: 10 + failureThreshold: 60 + readiness: + periodSeconds: 15 + timeoutSeconds: 10 + failureThreshold: 3 + liveness: + periodSeconds: 30 + timeoutSeconds: 20 + failureThreshold: 10 diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..659a7ce --- /dev/null +++ b/install.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -euo pipefail + +release=${RELEASE_NAME:-comfyui} +namespace=${NAMESPACE:-default} +helm upgrade --install "$release" brunner.ninja/comfyui \ + --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..80517bf --- /dev/null +++ b/scripts/chart-version.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail + +description=$(git -C "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" describe --tags --long --match 'v[0-9]*' --dirty) +case "$description" in + *-dirty) echo 'Refusing to package a dirty tree' >&2; exit 1 ;; +esac +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..221f5da --- /dev/null +++ b/test.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +chart_dir="$repo_dir/chart/comfyui" +rendered=$(mktemp) +trap 'rm -f "$rendered"' EXIT + +helm lint "$chart_dir" +helm template comfyui "$chart_dir" > "$rendered" +python3 "$repo_dir/tests/render_test.py" "$rendered" diff --git a/tests/render_test.py b/tests/render_test.py new file mode 100644 index 0000000..e1dfd15 --- /dev/null +++ b/tests/render_test.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 +import pathlib +import sys + +import yaml + + +documents = [item for item in yaml.safe_load_all(pathlib.Path(sys.argv[1]).read_text()) if item] +deployments = [item for item in documents if item.get("kind") == "Deployment"] +services = [item for item in documents if item.get("kind") == "Service"] +claims = [item for item in documents if item.get("kind") == "PersistentVolumeClaim"] + +assert len(deployments) == 1 +assert len(services) == 1 +deployment = deployments[0] +service = services[0] +selector = deployment["spec"]["selector"]["matchLabels"] +pod = deployment["spec"]["template"] +pod_labels = pod["metadata"]["labels"] +assert all(pod_labels.get(key) == value for key, value in selector.items()) +assert service["spec"]["selector"] == selector + +container = pod["spec"]["containers"][0] +http_port = next(port for port in container["ports"] if port["name"] == "http") +service_port = service["spec"]["ports"][0] +assert service_port["targetPort"] == http_port["name"] +assert service_port["port"] == http_port["containerPort"] + +mounts = container["volumeMounts"] +assert {mount["subPath"] for mount in mounts} == {"models", "output", "input", "user"} +data_volume = next(volume for volume in pod["spec"]["volumes"] if volume["name"] == mounts[0]["name"]) +claim = data_volume.get("persistentVolumeClaim", {}).get("claimName") +assert claim +if claims: + assert len(claims) == 1 + assert claims[0]["metadata"]["name"] == claim + +for probe_name in ("startupProbe", "readinessProbe", "livenessProbe"): + assert container[probe_name]["httpGet"]["path"] == "/system_stats" + +print("ComfyUI chart render relationships are valid")