commit e9af5ed974d26a60e46003024e303ec9fef33f05 Author: 0xFEEDC0DE64 Date: Sun Oct 18 13:41:43 2026 +0200 Add reusable ImmichFrame Helm chart diff --git a/.gitea/workflows/chart.yml b/.gitea/workflows/chart.yml new file mode 100644 index 0000000..ed96762 --- /dev/null +++ b/.gitea/workflows/chart.yml @@ -0,0 +1,135 @@ +name: Validate, publish and promote Helm chart + +on: + push: + pull_request: + +env: + HELM_VERSION: v4.2.2 + KUBECONFORM_VERSION: v0.7.0 + GITOPS_REPOSITORY: feedc0de/homelab-gitops + +jobs: + validate: + name: Validate chart + runs-on: ubuntu-latest + steps: + - name: Check out source + uses: actions/checkout@v4 + + - name: Install Helm + uses: azure/setup-helm@v4 + with: + version: ${{ env.HELM_VERSION }} + + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.3" + + - name: Lint, render and test relationships + run: ./test.sh + + - name: Validate rendered Kubernetes resources + run: | + helm template immichframe chart/immichframe \ + --namespace default \ + --set ingress.enabled=true \ + --set ingress.hosts[0].host=frame.example.com \ + --set ingress.hosts[0].paths[0].path=/ \ + --set ingress.hosts[0].paths[0].pathType=Prefix \ + --set podDisruptionBudget.enabled=true \ + --set prometheusRule.enabled=true \ + | docker run --rm --interactive \ + "ghcr.io/yannh/kubeconform:${KUBECONFORM_VERSION}" \ + -strict -ignore-missing-schemas -summary + + publish: + name: Publish and promote chart + if: gitea.ref == 'refs/heads/main' + needs: validate + runs-on: ubuntu-latest + steps: + - name: Check out source + uses: actions/checkout@v4 + + - name: Install Helm + uses: azure/setup-helm@v4 + with: + version: ${{ env.HELM_VERSION }} + + - name: Select immutable chart version + run: | + set -euo pipefail + run_attempt="${GITHUB_RUN_ATTEMPT:-1}" + test -n "${GITHUB_RUN_NUMBER}" + printf 'CHART_VERSION=0.%s.%s\n' \ + "${GITHUB_RUN_NUMBER}" "${run_attempt}" >> "${GITHUB_ENV}" + + - name: Package and publish chart + env: + PACKAGE_USERNAME: ${{ secrets.PACKAGE_USERNAME }} + PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }} + run: | + set -euo pipefail + test -n "${PACKAGE_USERNAME}" + test -n "${PACKAGE_TOKEN}" + install -d .chart-packages + helm package chart/immichframe \ + --destination .chart-packages \ + --version "${CHART_VERSION}" + chart_file=".chart-packages/immichframe-${CHART_VERSION}.tgz" + test -f "${chart_file}" + curl --fail --silent --show-error \ + --user "${PACKAGE_USERNAME}:${PACKAGE_TOKEN}" \ + --request POST \ + --upload-file "${chart_file}" \ + https://code.brunner.ninja/api/packages/feedc0de/helm/api/charts + + - 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 "This build is no longer current main; skipping promotion" + echo "promote=false" >> "${GITEA_OUTPUT}" + fi + + - name: Check out GitOps repository + 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: Commit chart promotion + if: steps.current-main.outputs.promote == 'true' + working-directory: homelab-gitops + run: | + set -euo pipefail + ./scripts/update-immichframe-chart.sh "${CHART_VERSION}" + git config user.name "immichframe chart CI" + git config user.email "immichframe-chart-ci@brunner.ninja" + git add apps/immichframe/Chart.yaml + if git diff --cached --quiet; then + echo "GitOps already selects ${CHART_VERSION}" + exit 0 + fi + git commit -m "Deploy immichframe chart ${CHART_VERSION}" + for attempt in 1 2 3; do + if git push origin HEAD:main; then + exit 0 + fi + if [ "${attempt}" = 3 ]; then + echo "GitOps promotion failed after three attempts" >&2 + exit 1 + fi + git fetch origin main + git rebase origin/main + done diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7f9b0d2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.chart-packages/ +rendered.yaml diff --git a/README.md b/README.md new file mode 100644 index 0000000..0ca8fb2 --- /dev/null +++ b/README.md @@ -0,0 +1,75 @@ +# ImmichFrame Helm chart + +This repository packages the upstream +[ImmichFrame](https://github.com/immichFrame/ImmichFrame) container for +Kubernetes. It is an independent community chart and is not maintained by the +ImmichFrame project. + +The chart creates a Deployment, ConfigMap, Service, optional Ingress, optional +PVC, optional PodDisruptionBudget, and optional PrometheusRule. ImmichFrame does +not currently expose a Prometheus metrics endpoint, so the chart deliberately +does not create a ServiceMonitor. Its alerts use metrics supplied by +kube-state-metrics. + +## Install + +Add Daniel's public chart repository and choose an immutable chart version: + +```sh +helm repo add brunner https://brunner.ninja/charts +helm repo update brunner +helm upgrade --install immichframe brunner/immichframe \ + --version CHART_VERSION \ + --namespace immichframe --create-namespace \ + --values values.yaml +``` + +The chart version has no `latest` alias. Every successful main-branch workflow +publishes a unique SemVer version, `0..`, so GitOps can pin +and roll back an exact package. The default upstream image is the chart's +`appVersion`; `image.registry`, `image.repository`, and `image.tag` can all be +overridden independently. + +Each configured Immich account references an existing Kubernetes Secret. The +chart mounts its key as a file and writes only `ApiKeyFile` into +`Settings.yml`; API keys never enter Helm values or rendered ConfigMaps. + +```yaml +config: + accounts: + - immichServerUrl: https://immich.example.com + apiKeySecret: + name: immichframe-api + key: api-key + options: + Albums: + - 00000000-0000-0000-0000-000000000000 +``` + +## Deployment and persistence model + +ImmichFrame `v1.0.38.0` and newer imports `Settings.yml` once into a SQLite +database under `/app/Config`; afterward the database is its source of truth. +With `persistence.enabled: false` (the default), each Deployment replica has an +ephemeral database and reimports the declarative settings whenever its Pod is +recreated. This is suitable when Git remains the configuration owner and admin +UI edits are intentionally disposable. + +Enable persistence and normally use one replica if admin UI changes must +survive. With a persistent database, later `Settings.yml` changes are ignored +by ImmichFrame and must instead be applied through its admin UI. Multiple +replicas require storage that supports the chosen access mode and careful +application-level consideration; they must not share one SQLite database. + +## CI publication + +Gitea Actions lints, renders, schema-validates, and publishes the chart to +`https://brunner.ninja/charts`. The repository needs these Actions secrets: + +- `PACKAGE_USERNAME` and `PACKAGE_TOKEN` for the Gitea Helm package registry. +- `GITOPS_TOKEN` with write access to `feedc0de/homelab-gitops` so CI can + promote the exact published chart version. + +The live homelab values are intentionally kept in the public +[homelab-gitops application](https://code.brunner.ninja/feedc0de/homelab-gitops/src/branch/main/apps/immichframe), +where the API key is stored only as Sealed Secrets ciphertext. diff --git a/chart/immichframe/Chart.yaml b/chart/immichframe/Chart.yaml new file mode 100644 index 0000000..6e51dc4 --- /dev/null +++ b/chart/immichframe/Chart.yaml @@ -0,0 +1,16 @@ +apiVersion: v2 +name: immichframe +description: Community Kubernetes deployment for the upstream ImmichFrame container +type: application +version: 0.1.0 +appVersion: v1.0.39.0 +home: https://github.com/immichFrame/ImmichFrame +sources: +- https://github.com/immichFrame/ImmichFrame +- https://code.brunner.ninja/feedc0de/immichframe-helm +keywords: +- immich +- digital-photo-frame +- photos +maintainers: +- name: feedc0de diff --git a/chart/immichframe/templates/_helpers.tpl b/chart/immichframe/templates/_helpers.tpl new file mode 100644 index 0000000..16909a3 --- /dev/null +++ b/chart/immichframe/templates/_helpers.tpl @@ -0,0 +1,34 @@ +{{- define "immichframe.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{- define "immichframe.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 "immichframe.labels" -}} +helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" }} +app.kubernetes.io/name: {{ include "immichframe.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +app: {{ include "immichframe.fullname" . }} +{{- end }} + +{{- define "immichframe.selectorLabels" -}} +app: {{ include "immichframe.fullname" . }} +{{- end }} + +{{- define "immichframe.image" -}} +{{- $tag := default .Chart.AppVersion .Values.image.tag -}} +{{- printf "%s/%s:%s" .Values.image.registry .Values.image.repository $tag -}} +{{- end }} diff --git a/chart/immichframe/templates/configmap.yaml b/chart/immichframe/templates/configmap.yaml new file mode 100644 index 0000000..487cf6a --- /dev/null +++ b/chart/immichframe/templates/configmap.yaml @@ -0,0 +1,18 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "immichframe.fullname" . }}-config + labels: + {{- include "immichframe.labels" . | nindent 4 }} +data: + Settings.yml: | + General: + {{- toYaml .Values.config.general | nindent 6 }} + Accounts: + {{- range $index, $account := .Values.config.accounts }} + - ImmichServerUrl: {{ $account.immichServerUrl | quote }} + ApiKeyFile: {{ printf "/app/Secrets/account-%d-api-key" $index | quote }} + {{- with $account.options }} + {{- toYaml . | nindent 8 }} + {{- end }} + {{- end }} diff --git a/chart/immichframe/templates/deployment.yaml b/chart/immichframe/templates/deployment.yaml new file mode 100644 index 0000000..c7f4d92 --- /dev/null +++ b/chart/immichframe/templates/deployment.yaml @@ -0,0 +1,120 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "immichframe.fullname" . }} + labels: + {{- include "immichframe.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + strategy: + type: RollingUpdate + rollingUpdate: + maxUnavailable: 0 + maxSurge: 1 + selector: + matchLabels: + {{- include "immichframe.selectorLabels" . | nindent 6 }} + template: + metadata: + annotations: + checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }} + checksum/external-secret-revision: {{ .Values.config.secretRevision | quote }} + {{- with .Values.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "immichframe.labels" . | nindent 8 }} + {{- with .Values.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + automountServiceAccountToken: false + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- range . }} + - name: {{ if kindIs "string" . }}{{ . }}{{ else }}{{ .name }}{{ end }} + {{- end }} + {{- end }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + {{- if .Values.affinity }} + affinity: + {{- toYaml .Values.affinity | nindent 8 }} + {{- else if .Values.podAntiAffinity.enabled }} + affinity: + podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - weight: 100 + podAffinityTerm: + labelSelector: + matchLabels: + {{- include "immichframe.selectorLabels" . | nindent 18 }} + topologyKey: kubernetes.io/hostname + {{- end }} + containers: + - name: immichframe + image: {{ include "immichframe.image" . | quote }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: http + containerPort: 8080 + protocol: TCP + env: + - name: TZ + value: {{ .Values.timezone | quote }} + startupProbe: + tcpSocket: + port: http + {{- toYaml .Values.startupProbe | nindent 10 }} + readinessProbe: + tcpSocket: + port: http + {{- toYaml .Values.readinessProbe | nindent 10 }} + livenessProbe: + tcpSocket: + port: http + {{- toYaml .Values.livenessProbe | nindent 10 }} + resources: + {{- toYaml .Values.resources | nindent 10 }} + securityContext: + {{- toYaml .Values.securityContext | nindent 10 }} + volumeMounts: + {{- if .Values.persistence.enabled }} + - name: config-data + mountPath: /app/Config + {{- end }} + - name: settings + mountPath: /app/Config/Settings.yml + subPath: Settings.yml + readOnly: true + - name: api-keys + mountPath: /app/Secrets + readOnly: true + volumes: + {{- if .Values.persistence.enabled }} + - name: config-data + persistentVolumeClaim: + claimName: {{ default (printf "%s-config" (include "immichframe.fullname" .)) .Values.persistence.existingClaim }} + {{- end }} + - name: settings + configMap: + name: {{ include "immichframe.fullname" . }}-config + - name: api-keys + projected: + defaultMode: 0440 + sources: + {{- range $index, $account := .Values.config.accounts }} + - secret: + name: {{ $account.apiKeySecret.name }} + items: + - key: {{ $account.apiKeySecret.key }} + path: {{ printf "account-%d-api-key" $index }} + {{- end }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/chart/immichframe/templates/ingress.yaml b/chart/immichframe/templates/ingress.yaml new file mode 100644 index 0000000..eb8640c --- /dev/null +++ b/chart/immichframe/templates/ingress.yaml @@ -0,0 +1,35 @@ +{{- if .Values.ingress.enabled }} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "immichframe.fullname" . }} + labels: + {{- include "immichframe.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- with .Values.ingress.className }} + ingressClassName: {{ . }} + {{- end }} + {{- with .Values.ingress.tls }} + tls: + {{- toYaml . | nindent 4 }} + {{- end }} + rules: + {{- range .Values.ingress.hosts }} + - host: {{ .host | quote }} + http: + paths: + {{- range .paths }} + - path: {{ .path }} + pathType: {{ .pathType }} + backend: + service: + name: {{ include "immichframe.fullname" $ }} + port: + name: http + {{- end }} + {{- end }} +{{- end }} diff --git a/chart/immichframe/templates/pdb.yaml b/chart/immichframe/templates/pdb.yaml new file mode 100644 index 0000000..d9955f0 --- /dev/null +++ b/chart/immichframe/templates/pdb.yaml @@ -0,0 +1,13 @@ +{{- if .Values.podDisruptionBudget.enabled }} +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: {{ include "immichframe.fullname" . }} + labels: + {{- include "immichframe.labels" . | nindent 4 }} +spec: + minAvailable: {{ .Values.podDisruptionBudget.minAvailable }} + selector: + matchLabels: + {{- include "immichframe.selectorLabels" . | nindent 6 }} +{{- end }} diff --git a/chart/immichframe/templates/prometheusrule.yaml b/chart/immichframe/templates/prometheusrule.yaml new file mode 100644 index 0000000..d1e39b6 --- /dev/null +++ b/chart/immichframe/templates/prometheusrule.yaml @@ -0,0 +1,28 @@ +{{- if .Values.prometheusRule.enabled }} +apiVersion: monitoring.coreos.com/v1 +kind: PrometheusRule +metadata: + name: {{ include "immichframe.fullname" . }} + labels: + {{- include "immichframe.labels" . | nindent 4 }} + {{- with .Values.prometheusRule.labels }} + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + groups: + - name: {{ include "immichframe.fullname" . }} + rules: + - alert: ImmichFrameUnavailable + expr: kube_deployment_status_replicas_available{namespace={{ .Release.Namespace | quote }}, deployment={{ include "immichframe.fullname" . | quote }}} < 1 + for: 5m + labels: + severity: critical + annotations: + summary: ImmichFrame has no available replicas + - alert: ImmichFrameContainerRestarting + expr: increase(kube_pod_container_status_restarts_total{namespace={{ .Release.Namespace | quote }}, container="immichframe"}[15m]) > 2 + labels: + severity: warning + annotations: + summary: ImmichFrame is repeatedly restarting +{{- end }} diff --git a/chart/immichframe/templates/pvc.yaml b/chart/immichframe/templates/pvc.yaml new file mode 100644 index 0000000..040ac51 --- /dev/null +++ b/chart/immichframe/templates/pvc.yaml @@ -0,0 +1,17 @@ +{{- if and .Values.persistence.enabled (not .Values.persistence.existingClaim) }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "immichframe.fullname" . }}-config + labels: + {{- include "immichframe.labels" . | nindent 4 }} +spec: + accessModes: + {{- toYaml .Values.persistence.accessModes | nindent 4 }} + {{- with .Values.persistence.storageClass }} + storageClassName: {{ . }} + {{- end }} + resources: + requests: + storage: {{ .Values.persistence.size }} +{{- end }} diff --git a/chart/immichframe/templates/service.yaml b/chart/immichframe/templates/service.yaml new file mode 100644 index 0000000..1668caf --- /dev/null +++ b/chart/immichframe/templates/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "immichframe.fullname" . }} + labels: + {{- include "immichframe.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + ports: + - name: http + port: {{ .Values.service.port }} + protocol: TCP + targetPort: http + selector: + {{- include "immichframe.selectorLabels" . | nindent 4 }} diff --git a/chart/immichframe/values.schema.json b/chart/immichframe/values.schema.json new file mode 100644 index 0000000..1d1a83f --- /dev/null +++ b/chart/immichframe/values.schema.json @@ -0,0 +1,178 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": false, + "properties": { + "replicaCount": { "type": "integer", "minimum": 1 }, + "image": { + "type": "object", + "additionalProperties": false, + "required": ["registry", "repository", "tag", "pullPolicy"], + "properties": { + "registry": { "type": "string", "minLength": 1 }, + "repository": { "type": "string", "minLength": 1 }, + "tag": { "type": "string" }, + "pullPolicy": { "enum": ["Always", "IfNotPresent", "Never"] } + } + }, + "imagePullSecrets": { + "type": "array", + "items": { + "oneOf": [ + { "type": "string", "minLength": 1 }, + { + "type": "object", + "additionalProperties": false, + "required": ["name"], + "properties": { "name": { "type": "string", "minLength": 1 } } + } + ] + } + }, + "nameOverride": { "type": "string" }, + "fullnameOverride": { "type": "string" }, + "timezone": { "type": "string", "minLength": 1 }, + "config": { + "type": "object", + "additionalProperties": false, + "required": ["general", "accounts", "secretRevision"], + "properties": { + "secretRevision": { "type": "string" }, + "general": { "type": "object" }, + "accounts": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["immichServerUrl", "apiKeySecret", "options"], + "properties": { + "immichServerUrl": { "type": "string", "format": "uri" }, + "apiKeySecret": { + "type": "object", + "additionalProperties": false, + "required": ["name", "key"], + "properties": { + "name": { "type": "string", "minLength": 1 }, + "key": { "type": "string", "minLength": 1 } + } + }, + "options": { "type": "object" } + } + } + } + } + }, + "service": { + "type": "object", + "additionalProperties": false, + "required": ["type", "port"], + "properties": { + "type": { "enum": ["ClusterIP", "NodePort", "LoadBalancer"] }, + "port": { "type": "integer", "minimum": 1, "maximum": 65535 } + } + }, + "ingress": { + "type": "object", + "additionalProperties": false, + "required": ["enabled", "className", "annotations", "hosts", "tls"], + "properties": { + "enabled": { "type": "boolean" }, + "className": { "type": "string" }, + "annotations": { "type": "object", "additionalProperties": { "type": "string" } }, + "hosts": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["host", "paths"], + "properties": { + "host": { "type": "string", "minLength": 1 }, + "paths": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["path", "pathType"], + "properties": { + "path": { "type": "string", "minLength": 1 }, + "pathType": { "enum": ["Exact", "Prefix", "ImplementationSpecific"] } + } + } + } + } + } + }, + "tls": { "type": "array", "items": { "type": "object" } } + } + }, + "persistence": { + "type": "object", + "additionalProperties": false, + "required": ["enabled", "existingClaim", "storageClass", "accessModes", "size"], + "properties": { + "enabled": { "type": "boolean" }, + "existingClaim": { "type": "string" }, + "storageClass": { "type": "string" }, + "accessModes": { "type": "array", "minItems": 1, "items": { "type": "string" } }, + "size": { "type": "string", "minLength": 1 } + } + }, + "podDisruptionBudget": { + "type": "object", + "additionalProperties": false, + "required": ["enabled", "minAvailable"], + "properties": { + "enabled": { "type": "boolean" }, + "minAvailable": { "oneOf": [{ "type": "integer", "minimum": 0 }, { "type": "string" }] } + } + }, + "prometheusRule": { + "type": "object", + "additionalProperties": false, + "required": ["enabled", "labels"], + "properties": { + "enabled": { "type": "boolean" }, + "labels": { "type": "object", "additionalProperties": { "type": "string" } } + } + }, + "podAntiAffinity": { + "type": "object", + "additionalProperties": false, + "required": ["enabled"], + "properties": { "enabled": { "type": "boolean" } } + }, + "podAnnotations": { "type": "object", "additionalProperties": { "type": "string" } }, + "podLabels": { "type": "object", "additionalProperties": { "type": "string" } }, + "podSecurityContext": { "type": "object" }, + "securityContext": { "type": "object" }, + "resources": { "type": "object" }, + "startupProbe": { "$ref": "#/$defs/probe" }, + "readinessProbe": { "$ref": "#/$defs/probe" }, + "livenessProbe": { "$ref": "#/$defs/probe" }, + "nodeSelector": { "type": "object" }, + "tolerations": { "type": "array", "items": { "type": "object" } }, + "affinity": { "type": "object" } + }, + "required": [ + "replicaCount", "image", "imagePullSecrets", "nameOverride", + "fullnameOverride", "timezone", "config", "service", "ingress", + "persistence", "podDisruptionBudget", "prometheusRule", + "podAntiAffinity", "podAnnotations", "podLabels", "podSecurityContext", + "securityContext", "resources", "startupProbe", "readinessProbe", + "livenessProbe", "nodeSelector", "tolerations", "affinity" + ], + "$defs": { + "probe": { + "type": "object", + "additionalProperties": false, + "properties": { + "failureThreshold": { "type": "integer", "minimum": 1 }, + "initialDelaySeconds": { "type": "integer", "minimum": 0 }, + "periodSeconds": { "type": "integer", "minimum": 1 }, + "timeoutSeconds": { "type": "integer", "minimum": 1 } + } + } + } +} diff --git a/chart/immichframe/values.yaml b/chart/immichframe/values.yaml new file mode 100644 index 0000000..0ebafa7 --- /dev/null +++ b/chart/immichframe/values.yaml @@ -0,0 +1,92 @@ +replicaCount: 1 + +image: + registry: ghcr.io + repository: immichframe/immichframe + tag: "" + pullPolicy: IfNotPresent + +imagePullSecrets: [] +nameOverride: "" +fullnameOverride: "" + +timezone: Etc/UTC + +config: + secretRevision: "" + general: + Interval: 45 + ShowClock: true + accounts: + - immichServerUrl: https://immich.example.com + apiKeySecret: + name: immichframe-api + key: api-key + options: {} + +service: + type: ClusterIP + port: 80 + +ingress: + enabled: false + className: "" + annotations: {} + hosts: [] + tls: [] + +persistence: + enabled: false + existingClaim: "" + storageClass: "" + accessModes: + - ReadWriteOnce + size: 1Gi + +podDisruptionBudget: + enabled: false + minAvailable: 1 + +prometheusRule: + enabled: false + labels: {} + +podAntiAffinity: + enabled: true + +podAnnotations: {} +podLabels: {} + +podSecurityContext: + seccompProfile: + type: RuntimeDefault + +securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + readOnlyRootFilesystem: false + runAsNonRoot: true + +resources: {} + +startupProbe: + failureThreshold: 30 + periodSeconds: 2 + timeoutSeconds: 1 + +readinessProbe: + failureThreshold: 3 + periodSeconds: 10 + timeoutSeconds: 1 + +livenessProbe: + failureThreshold: 3 + initialDelaySeconds: 10 + periodSeconds: 30 + timeoutSeconds: 1 + +nodeSelector: {} +tolerations: [] +affinity: {} diff --git a/test.sh b/test.sh new file mode 100755 index 0000000..716d024 --- /dev/null +++ b/test.sh @@ -0,0 +1,41 @@ +#!/bin/sh +set -eu + +project_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +chart_dir="$project_dir/chart/immichframe" +rendered_file="${TMPDIR:-/tmp}/immichframe-chart-rendered.yaml" + +helm lint "$chart_dir" +helm template immichframe "$chart_dir" \ + --namespace test-namespace \ + --set replicaCount=2 \ + --set ingress.enabled=true \ + --set ingress.className=example \ + --set ingress.hosts[0].host=frame.example.com \ + --set ingress.hosts[0].paths[0].path=/ \ + --set ingress.hosts[0].paths[0].pathType=Prefix \ + --set podDisruptionBudget.enabled=true \ + --set prometheusRule.enabled=true \ + --set persistence.enabled=true \ + > "$rendered_file" + +ruby -ryaml -e ' + documents = YAML.load_stream(File.read(ARGV.fetch(0))).compact + find = ->(kind) { documents.fetch(documents.index { |doc| doc["kind"] == kind }) } + deployment = find.call("Deployment") + service = find.call("Service") + ingress = find.call("Ingress") + config = find.call("ConfigMap") + settings = YAML.safe_load(config.fetch("data").fetch("Settings.yml")) + container = deployment.dig("spec", "template", "spec", "containers").fetch(0) + selector = deployment.dig("spec", "selector", "matchLabels") + raise "Service selector drift" unless service.dig("spec", "selector") == selector + raise "Ingress service drift" unless ingress.dig("spec", "rules", 0, "http", "paths", 0, "backend", "service", "name") == service.dig("metadata", "name") + raise "Probe port drift" unless %w[startupProbe readinessProbe livenessProbe].all? { |probe| container.dig(probe, "tcpSocket", "port") == "http" } + account = settings.fetch("Accounts").fetch(0) + raise "API key leaked into ConfigMap" if account.key?("ApiKey") + raise "API key file missing" unless account.fetch("ApiKeyFile").start_with?("/app/Secrets/") + raise "PVC not rendered" unless documents.any? { |doc| doc["kind"] == "PersistentVolumeClaim" } + raise "PDB not rendered" unless documents.any? { |doc| doc["kind"] == "PodDisruptionBudget" } + raise "PrometheusRule not rendered" unless documents.any? { |doc| doc["kind"] == "PrometheusRule" } +' "$rendered_file"