Add Helm chart tooling, CI, and release packaging

This commit is contained in:
Pulse Automation Bot 2025-10-18 11:50:57 +00:00
parent d79b8e8883
commit d15ad1d0b4
25 changed files with 1299 additions and 5 deletions

View file

@ -49,3 +49,34 @@ ssh pulse-relay "curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/ma
- ✅ Validates install script works on real server
- ✅ Removes manual step from release process
- ✅ Free to run (public repos get unlimited GitHub Actions minutes)
## Helm CI
**File**: `helm-ci.yml`
Runs `helm lint --strict` and renders the chart with common configuration combinations on every pull request that touches Helm content (and on pushes to `main`). This prevents regressions before they land.
- Triggered by PRs/pushes touching `deploy/helm/**`, docs, or the workflow itself
- Uses Helm v3.15.2
- Renders both the default deployment and an agent-enabled configuration to catch template issues
## Publish Helm Chart
**File**: `publish-helm-chart.yml`
Packages the Helm chart and pushes it to the GitHub Container Registry (OCI) whenever a GitHub Release is published. Also makes the packaged `.tgz` available as both an Actions artifact and a release asset. The same behaviour can be triggered locally via `./scripts/package-helm-chart.sh <version> [--push]`.
- Triggered automatically on `release: published`, or manually via workflow dispatch (requires `chart_version` input)
- Chart and app versions mirror the Pulse release tag (e.g., `v4.24.0``4.24.0`)
- Publishes to `oci://ghcr.io/<owner>/pulse-chart`
- Requires no additional secrets—uses the built-in `GITHUB_TOKEN` with `packages: write` permission
## Helm Integration (Kind)
**File**: `helm-integration.yml`
Creates a disposable Kind cluster, installs the chart, waits for the hub deployment to report ready, and performs a `/health` smoke check from inside the cluster.
- Triggered alongside the lint workflow for PRs/pushes touching Helm content
- Disables persistence to keep the Kind cluster lightweight
- Provides early detection of runtime issues (missing secrets, invalid probes, etc.)

48
.github/workflows/helm-ci.yml vendored Normal file
View file

@ -0,0 +1,48 @@
name: Helm CI
on:
push:
branches: [main]
paths:
- "deploy/helm/**"
- ".github/workflows/helm-ci.yml"
- "docs/KUBERNETES.md"
- "README.md"
pull_request:
paths:
- "deploy/helm/**"
- ".github/workflows/helm-ci.yml"
- "docs/KUBERNETES.md"
- "README.md"
workflow_dispatch: {}
jobs:
lint:
name: Lint and Render Chart
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Helm
uses: azure/setup-helm@v4
with:
version: v3.15.2
- name: Helm lint (strict)
run: helm lint deploy/helm/pulse --strict
- name: Render default manifests
run: helm template pulse deploy/helm/pulse > /tmp/pulse-rendered.yaml
- name: Render agent-enabled manifests
run: |
helm template pulse deploy/helm/pulse \
--set agent.enabled=true \
--set agent.kind=Deployment \
--set agent.secretEnv.create=true \
--set agent.secretEnv.data.PULSE_TOKEN=dummy-token \
--set server.secretEnv.create=true \
--set server.secretEnv.data.API_TOKENS=dummy-token \
--set persistence.enabled=false \
> /tmp/pulse-agent-rendered.yaml

58
.github/workflows/helm-integration.yml vendored Normal file
View file

@ -0,0 +1,58 @@
name: Helm Integration
on:
push:
branches: [main]
paths:
- "deploy/helm/**"
- ".github/workflows/helm-*.yml"
- "docs/KUBERNETES.md"
- "README.md"
pull_request:
paths:
- "deploy/helm/**"
- ".github/workflows/helm-*.yml"
- "docs/KUBERNETES.md"
- "README.md"
workflow_dispatch: {}
jobs:
kind-smoke-test:
name: Deploy to Kind and Smoke Test
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Helm
uses: azure/setup-helm@v4
with:
version: v3.15.2
- name: Create Kind cluster
uses: helm/kind-action@v1.8.0
with:
wait: 120s
- name: Install Pulse chart
run: |
helm upgrade --install pulse ./deploy/helm/pulse \
--namespace pulse \
--create-namespace \
--set persistence.enabled=false \
--set server.secretEnv.create=true \
--set server.secretEnv.data.API_TOKENS=dummy-token \
--wait \
--timeout 5m
- name: Verify deployment is available
run: kubectl -n pulse wait --for=condition=available deployment/pulse --timeout=120s
- name: Hit health endpoint from inside the cluster
run: |
kubectl -n pulse run smoke-test \
--rm \
--image=curlimages/curl:8.3.0 \
--restart=Never \
-- curl -fsS http://pulse:7655/health

View file

@ -0,0 +1,92 @@
name: Publish Helm Chart
on:
release:
types: [published]
workflow_dispatch:
inputs:
chart_version:
description: "Chart version (required when running manually, use format 4.24.0)"
required: true
app_version:
description: "Application version to embed (defaults to chart version)"
required: false
jobs:
publish:
name: Package and Push Helm Chart
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Helm
uses: azure/setup-helm@v4
with:
version: v3.15.2
- name: Determine chart version
id: versions
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
CHART_VERSION="${{ inputs.chart_version }}"
if [ -z "$CHART_VERSION" ]; then
echo "::error::chart_version input is required when running manually"
exit 1
fi
APP_VERSION="${{ inputs.app_version }}"
if [ -z "$APP_VERSION" ]; then
APP_VERSION="$CHART_VERSION"
fi
RELEASE_TAG="$CHART_VERSION"
else
RELEASE_TAG="${{ github.event.release.tag_name }}"
if [ -z "$RELEASE_TAG" ]; then
echo "::error::Release tag is empty"
exit 1
fi
CHART_VERSION="${RELEASE_TAG#v}"
APP_VERSION="$CHART_VERSION"
fi
echo "chart_version=$CHART_VERSION" >> "$GITHUB_OUTPUT"
echo "app_version=$APP_VERSION" >> "$GITHUB_OUTPUT"
echo "release_tag=$RELEASE_TAG" >> "$GITHUB_OUTPUT"
- name: Helm lint (strict)
run: helm lint deploy/helm/pulse --strict
- name: Package chart
run: |
mkdir -p dist
helm package deploy/helm/pulse \
--version "${{ steps.versions.outputs.chart_version }}" \
--app-version "${{ steps.versions.outputs.app_version }}" \
--destination dist
- name: Upload packaged chart artifact
uses: actions/upload-artifact@v4
with:
name: pulse-chart-${{ steps.versions.outputs.chart_version }}
path: dist/pulse-${{ steps.versions.outputs.chart_version }}.tgz
- name: Authenticate with GHCR
run: |
echo "${{ github.token }}" | helm registry login ghcr.io --username "${{ github.actor }}" --password-stdin
- name: Push chart to GHCR
run: |
helm push dist/pulse-${{ steps.versions.outputs.chart_version }}.tgz \
oci://ghcr.io/${{ github.repository_owner }}/pulse-chart
- name: Attach chart to release
if: github.event_name == 'release'
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
gh release upload "${{ steps.versions.outputs.release_tag }}" \
dist/pulse-${{ steps.versions.outputs.chart_version }}.tgz \
--clobber

2
.gitignore vendored
View file

@ -1,6 +1,6 @@
# Binaries
/bin/
pulse
/pulse
# Logs
*.log

View file

@ -84,6 +84,14 @@ docker run -d -p 7655:7655 -v pulse_data:/data rcourtman/pulse:latest
# Testing: Install from main branch source (for testing latest fixes)
curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/install.sh | bash -s -- --main
# Alternative: Kubernetes (Helm)
helm registry login ghcr.io
helm install pulse oci://ghcr.io/rcourtman/pulse-chart \
--version $(curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/VERSION) \
--namespace pulse \
--create-namespace
# Replace the VERSION lookup with a specific release if you need to pin. For local development, see docs/KUBERNETES.md.
```
**Proxmox users**: The installer detects PVE hosts and automatically creates an optimized LXC container. Choose Quick mode for one-minute setup.

View file

@ -0,0 +1,17 @@
apiVersion: v2
name: pulse
description: Helm chart for deploying the Pulse hub and optional Docker monitoring agent.
type: application
version: 0.1.0
appVersion: "4.24.0"
icon: https://raw.githubusercontent.com/rcourtman/Pulse/main/docs/images/pulse-logo.svg
keywords:
- monitoring
- proxmox
- observability
home: https://github.com/rcourtman/Pulse
sources:
- https://github.com/rcourtman/Pulse
maintainers:
- name: Pulse Maintainers
email: pulse@rcourtman.dev

View file

@ -0,0 +1,41 @@
# Pulse Helm Chart
This chart deploys the Pulse hub (web UI + API) and, optionally, the Docker monitoring agent.
## Installing from GHCR
```bash
helm registry login ghcr.io
helm install pulse oci://ghcr.io/rcourtman/pulse-chart \
--version $(curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/VERSION) \
--namespace pulse \
--create-namespace
```
Replace the inline `curl` command if you need to pin to a specific release. The chart version tracks the Pulse release version (e.g., Pulse `v4.24.0` → chart `4.24.0`).
## Common Values
| Key | Description | Default |
| --- | --- | --- |
| `persistence.enabled` | Persist /data using a PVC. Disable for ephemeral testing. | `true` |
| `ingress.enabled` | Create an Ingress resource. Configure hosts/TLS via `ingress.hosts` and `ingress.tls`. | `false` |
| `server.secretEnv` | Manage sensitive env vars (API tokens, auth secrets). Enable `create` or point at an existing secret. | `{}` |
| `agent.enabled` | Deploy the Docker agent as a DaemonSet or Deployment. Requires access to the Docker socket by default. | `false` |
See `values.yaml` for the full list of overridable settings.
## Local Development
Install from the working copy when testing chart changes:
```bash
helm upgrade --install pulse ./deploy/helm/pulse \
--namespace pulse \
--create-namespace \
--set persistence.enabled=false \
--set server.secretEnv.create=true \
--set server.secretEnv.data.API_TOKENS=dummy-token
```
For more deployment details (ingress, agent, persistence options), refer to `docs/KUBERNETES.md`.

View file

@ -0,0 +1,4 @@
1. Pulse hub URL: http://{{ include "pulse.fullname" . }}:{{ .Values.service.port }}
(Adjust if using ingress or LoadBalancer.)
2. Create an API token from the Pulse UI under Settings → Security for the Docker agent.
3. Deploy the optional agent by enabling `agent.enabled` and providing your server URL and token.

View file

@ -0,0 +1,97 @@
{{/*
Expand the name of the chart.
*/}}
{{- define "pulse.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{/*
Create a default fully qualified app name.
*/}}
{{- define "pulse.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 label.
*/}}
{{- define "pulse.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{/*
Common labels.
*/}}
{{- define "pulse.labels" -}}
helm.sh/chart: {{ include "pulse.chart" . }}
{{ include "pulse.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end -}}
{{/*
Selector labels.
*/}}
{{- define "pulse.selectorLabels" -}}
app.kubernetes.io/name: {{ include "pulse.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end -}}
{{/*
Return the name of the service account to use.
*/}}
{{- define "pulse.serviceAccountName" -}}
{{- if .Values.serviceAccount.create -}}
{{- default (printf "%s-sa" (include "pulse.fullname" .)) .Values.serviceAccount.name -}}
{{- else -}}
{{- default "default" .Values.serviceAccount.name -}}
{{- end -}}
{{- end -}}
{{/*
Return the server secret name (Pulse hub env vars).
*/}}
{{- define "pulse.serverSecretName" -}}
{{- $secret := .Values.server.secretEnv -}}
{{- if $secret.name -}}
{{- $secret.name -}}
{{- else -}}
{{- printf "%s-server-env" (include "pulse.fullname" .) -}}
{{- end -}}
{{- end -}}
{{/*
Return the agent secret name.
*/}}
{{- define "pulse.agentSecretName" -}}
{{- $secret := .Values.agent.secretEnv -}}
{{- if $secret.name -}}
{{- $secret.name -}}
{{- else -}}
{{- printf "%s-agent-env" (include "pulse.fullname" .) -}}
{{- end -}}
{{- end -}}
{{/*
Return the agent service account name.
*/}}
{{- define "pulse.agentServiceAccountName" -}}
{{- if .Values.agent.serviceAccount.create -}}
{{- default (printf "%s-agent" (include "pulse.fullname" .)) .Values.agent.serviceAccount.name -}}
{{- else if .Values.agent.serviceAccount.name -}}
{{- .Values.agent.serviceAccount.name -}}
{{- else -}}
{{- include "pulse.serviceAccountName" . -}}
{{- end -}}
{{- end -}}

View file

@ -0,0 +1,14 @@
{{- $secret := .Values.agent.secretEnv }}
{{- if and .Values.agent.enabled $secret.create (gt (len $secret.data) 0) }}
apiVersion: v1
kind: Secret
metadata:
name: {{ include "pulse.agentSecretName" . }}
labels:
{{- include "pulse.labels" . | nindent 4 }}
type: Opaque
data:
{{- range $key, $value := $secret.data }}
{{ $key }}: {{ $value | toString | b64enc }}
{{- end }}
{{- end }}

View file

@ -0,0 +1,12 @@
{{- if and .Values.agent.enabled .Values.agent.serviceAccount.create }}
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ default (printf "%s-agent" (include "pulse.fullname" .)) .Values.agent.serviceAccount.name }}
labels:
{{- include "pulse.labels" . | nindent 4 }}
{{- with .Values.agent.serviceAccount.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
{{- end }}

View file

@ -0,0 +1,125 @@
{{- if .Values.agent.enabled }}
{{- $kind := default "DaemonSet" .Values.agent.kind | lower }}
{{- $isDaemon := eq $kind "daemonset" }}
{{- $workloadName := printf "%s-agent" (include "pulse.fullname" .) }}
apiVersion: apps/v1
kind: {{ if $isDaemon }}DaemonSet{{ else }}Deployment{{ end }}
metadata:
name: {{ $workloadName }}
labels:
{{- include "pulse.labels" . | nindent 4 }}
app.kubernetes.io/component: agent
spec:
{{- if not $isDaemon }}
replicas: {{ .Values.agent.replicaCount }}
{{- end }}
selector:
matchLabels:
{{- include "pulse.selectorLabels" . | nindent 6 }}
app.kubernetes.io/component: agent
{{- if $isDaemon }}
updateStrategy:
type: RollingUpdate
{{- end }}
template:
metadata:
labels:
{{- include "pulse.selectorLabels" . | nindent 8 }}
app.kubernetes.io/component: agent
{{- with .Values.agent.podLabels }}
{{- toYaml . | nindent 8 }}
{{- end }}
{{- if .Values.agent.podAnnotations }}
annotations:
{{- toYaml .Values.agent.podAnnotations | nindent 8 }}
{{- end }}
spec:
serviceAccountName: {{ include "pulse.agentServiceAccountName" . }}
{{- $podSecurityContext := .Values.agent.podSecurityContext }}
{{- if $podSecurityContext }}
securityContext:
{{- toYaml $podSecurityContext | nindent 8 }}
{{- end }}
containers:
- name: pulse-docker-agent
image: "{{ .Values.agent.image.repository }}:{{ default .Chart.AppVersion .Values.agent.image.tag }}"
imagePullPolicy: {{ .Values.agent.image.pullPolicy }}
{{- if .Values.agent.args }}
args:
{{- toYaml .Values.agent.args | nindent 12 }}
{{- end }}
{{- $csec := .Values.agent.securityContext }}
{{- if $csec }}
securityContext:
{{- toYaml $csec | nindent 12 }}
{{- end }}
{{- $envList := list }}
{{- range .Values.agent.env }}
{{- $envList = append $envList . }}
{{- end }}
{{- range .Values.agent.extraEnv }}
{{- $envList = append $envList . }}
{{- end }}
{{- $secret := .Values.agent.secretEnv }}
{{- $secretKeys := list }}
{{- if $secret.keys }}
{{- $secretKeys = $secret.keys }}
{{- else if $secret.data }}
{{- $secretKeys = keys $secret.data }}
{{- end }}
{{- $root := . }}
{{- range $key := $secretKeys }}
{{- $envList = append $envList (dict "name" $key "valueFrom" (dict "secretKeyRef" (dict "name" (include "pulse.agentSecretName" $root) "key" $key))) }}
{{- end }}
{{- if $envList }}
env:
{{- toYaml $envList | nindent 12 }}
{{- end }}
{{- $envFrom := concat .Values.agent.envFrom .Values.agent.extraEnvFrom }}
{{- if $envFrom }}
envFrom:
{{- range $envFrom }}
{{- toYaml . | nindent 12 }}
{{- end }}
{{- end }}
{{- if or .Values.agent.dockerSocket.enabled .Values.agent.extraVolumeMounts }}
volumeMounts:
{{- if .Values.agent.dockerSocket.enabled }}
- name: docker-socket
mountPath: {{ .Values.agent.dockerSocket.path }}
{{- end }}
{{- with .Values.agent.extraVolumeMounts }}
{{- toYaml . | nindent 12 }}
{{- end }}
{{- end }}
{{- with .Values.agent.resources }}
resources:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- if or .Values.agent.dockerSocket.enabled .Values.agent.extraVolumes }}
volumes:
{{- if .Values.agent.dockerSocket.enabled }}
- name: docker-socket
hostPath:
path: {{ .Values.agent.dockerSocket.path }}
{{- with .Values.agent.dockerSocket.hostPathType }}
type: {{ . }}
{{- end }}
{{- end }}
{{- with .Values.agent.extraVolumes }}
{{- toYaml . | nindent 8 }}
{{- end }}
{{- end }}
{{- with .Values.agent.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.agent.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.agent.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- end }}

View file

@ -0,0 +1,135 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "pulse.fullname" . }}
labels:
{{- include "pulse.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
{{- include "pulse.selectorLabels" . | nindent 6 }}
template:
metadata:
labels:
{{- include "pulse.selectorLabels" . | nindent 8 }}
{{- with .Values.server.podLabels }}
{{- toYaml . | nindent 8 }}
{{- end }}
{{- $podAnnotations := merge (dict) .Values.podAnnotations .Values.server.podAnnotations }}
{{- if $podAnnotations }}
annotations:
{{- toYaml $podAnnotations | nindent 8 }}
{{- end }}
spec:
serviceAccountName: {{ include "pulse.serviceAccountName" . }}
{{- $podSecurityContext := merge (dict) .Values.podSecurityContext .Values.server.podSecurityContext }}
{{- if $podSecurityContext }}
securityContext:
{{- toYaml $podSecurityContext | nindent 8 }}
{{- end }}
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
containers:
- name: pulse
image: "{{ .Values.image.repository }}:{{ default .Chart.AppVersion .Values.image.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: 7655
{{- $containerSecurityContext := merge (dict) .Values.securityContext .Values.server.securityContext }}
{{- if $containerSecurityContext }}
securityContext:
{{- toYaml $containerSecurityContext | nindent 12 }}
{{- end }}
{{- $envList := list }}
{{- range .Values.server.env }}
{{- $envList = append $envList . }}
{{- end }}
{{- range .Values.server.extraEnv }}
{{- $envList = append $envList . }}
{{- end }}
{{- $secret := .Values.server.secretEnv }}
{{- $secretKeys := list }}
{{- if $secret.keys }}
{{- $secretKeys = $secret.keys }}
{{- else if $secret.data }}
{{- $secretKeys = keys $secret.data }}
{{- end }}
{{- $root := . }}
{{- range $key := $secretKeys }}
{{- $envList = append $envList (dict "name" $key "valueFrom" (dict "secretKeyRef" (dict "name" (include "pulse.serverSecretName" $root) "key" $key))) }}
{{- end }}
{{- if $envList }}
env:
{{- toYaml $envList | nindent 12 }}
{{- end }}
{{- $envFrom := concat .Values.server.envFrom .Values.server.extraEnvFrom }}
{{- if $envFrom }}
envFrom:
{{- range $envFrom }}
{{- toYaml . | nindent 12 }}
{{- end }}
{{- end }}
volumeMounts:
- name: data
mountPath: /data
{{- with .Values.server.extraVolumeMounts }}
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.server.livenessProbe }}
{{- if .enabled }}
livenessProbe:
httpGet:
path: {{ .path }}
port: http
initialDelaySeconds: {{ .initialDelaySeconds }}
periodSeconds: {{ .periodSeconds }}
timeoutSeconds: {{ .timeoutSeconds }}
failureThreshold: {{ .failureThreshold }}
{{- end }}
{{- end }}
{{- with .Values.server.readinessProbe }}
{{- if .enabled }}
readinessProbe:
httpGet:
path: {{ .path }}
port: http
initialDelaySeconds: {{ .initialDelaySeconds }}
periodSeconds: {{ .periodSeconds }}
timeoutSeconds: {{ .timeoutSeconds }}
failureThreshold: {{ .failureThreshold }}
{{- end }}
{{- end }}
{{- with .Values.server.resources }}
resources:
{{- toYaml . | nindent 12 }}
{{- end }}
volumes:
- name: data
{{- if and .Values.persistence.enabled (not .Values.persistence.existingClaim) }}
persistentVolumeClaim:
claimName: {{ include "pulse.fullname" . }}
{{- else if and .Values.persistence.enabled .Values.persistence.existingClaim }}
persistentVolumeClaim:
claimName: {{ .Values.persistence.existingClaim }}
{{- else }}
emptyDir: {}
{{- end }}
{{- with .Values.server.extraVolumes }}
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.server.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.server.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.server.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}

View file

@ -0,0 +1,35 @@
{{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "pulse.fullname" . }}
labels:
{{- include "pulse.labels" . | nindent 4 }}
{{- with .Values.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if .Values.ingress.className }}
ingressClassName: {{ .Values.ingress.className }}
{{- end }}
rules:
{{- range .Values.ingress.hosts }}
- host: {{ .host }}
http:
paths:
{{- range .paths }}
- path: {{ .path }}
pathType: {{ .pathType }}
backend:
service:
name: {{ include "pulse.fullname" $ }}
port:
number: {{ $.Values.service.port }}
{{- end }}
{{- end }}
{{- if .Values.ingress.tls }}
tls:
{{- toYaml .Values.ingress.tls | nindent 4 }}
{{- end }}
{{- end }}

View file

@ -0,0 +1,21 @@
{{- if and .Values.persistence.enabled (not .Values.persistence.existingClaim) }}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "pulse.fullname" . }}
labels:
{{- include "pulse.labels" . | nindent 4 }}
{{- with .Values.persistence.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
accessModes:
{{- toYaml .Values.persistence.accessModes | nindent 4 }}
resources:
requests:
storage: {{ .Values.persistence.size }}
{{- with .Values.persistence.storageClass }}
storageClassName: {{ . }}
{{- end }}
{{- end }}

View file

@ -0,0 +1,14 @@
{{- $secret := .Values.server.secretEnv }}
{{- if and $secret.create (gt (len $secret.data) 0) }}
apiVersion: v1
kind: Secret
metadata:
name: {{ include "pulse.serverSecretName" . }}
labels:
{{- include "pulse.labels" . | nindent 4 }}
type: Opaque
data:
{{- range $key, $value := $secret.data }}
{{ $key }}: {{ $value | toString | b64enc }}
{{- end }}
{{- end }}

View file

@ -0,0 +1,30 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "pulse.fullname" . }}
labels:
{{- include "pulse.labels" . | nindent 4 }}
{{- with .Values.service.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
type: {{ .Values.service.type }}
{{- if and (eq .Values.service.type "LoadBalancer") .Values.service.loadBalancerIP }}
loadBalancerIP: {{ .Values.service.loadBalancerIP }}
{{- end }}
{{- if and (eq .Values.service.type "LoadBalancer") (ne .Values.service.externalTrafficPolicy "") }}
externalTrafficPolicy: {{ .Values.service.externalTrafficPolicy }}
{{- end }}
selector:
{{- include "pulse.selectorLabels" . | nindent 4 }}
ports:
- name: http
port: {{ .Values.service.port }}
targetPort: http
protocol: TCP
{{- if eq .Values.service.type "NodePort" }}
{{- with .Values.service.nodePort }}
nodePort: {{ . }}
{{- end }}
{{- end }}

View file

@ -0,0 +1,12 @@
{{- if .Values.serviceAccount.create }}
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ include "pulse.serviceAccountName" . }}
labels:
{{- include "pulse.labels" . | nindent 4 }}
{{- with .Values.serviceAccount.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
{{- end }}

View file

@ -0,0 +1,137 @@
# Default values for the Pulse Helm chart.
# This file can be used as-is for a minimal installation or as a reference when
# overriding values (for example with `-f custom-values.yaml`).
replicaCount: 1
image:
repository: rcourtman/pulse
# Overrides the image tag whose default is the chart appVersion.
tag: ""
pullPolicy: IfNotPresent
imagePullSecrets: []
nameOverride: ""
fullnameOverride: ""
serviceAccount:
create: true
annotations: {}
name: ""
podAnnotations: {}
podLabels: {}
podSecurityContext: {}
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
service:
type: ClusterIP
port: 7655
annotations: {}
loadBalancerIP: ""
externalTrafficPolicy: Cluster
ingress:
enabled: false
className: ""
annotations: {}
hosts:
- host: pulse.local
paths:
- path: /
pathType: Prefix
tls: []
persistence:
enabled: true
existingClaim: ""
storageClass: ""
accessModes:
- ReadWriteOnce
size: 8Gi
annotations: {}
server:
env:
- name: TZ
value: UTC
envFrom: []
extraEnv: []
extraEnvFrom: []
secretEnv:
create: false
name: ""
data: {}
keys: []
podAnnotations: {}
podLabels: {}
podSecurityContext: {}
securityContext: {}
extraVolumes: []
extraVolumeMounts: []
resources: {}
nodeSelector: {}
tolerations: []
affinity: {}
livenessProbe:
enabled: true
path: /
initialDelaySeconds: 20
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
enabled: true
path: /
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
agent:
enabled: false
kind: DaemonSet # Supported: DaemonSet | Deployment
replicaCount: 1
serviceAccount:
create: false
name: ""
annotations: {}
image:
repository: ghcr.io/rcourtman/pulse-docker-agent
tag: ""
pullPolicy: IfNotPresent
env:
- name: PULSE_URL
value: http://pulse:7655
envFrom: []
extraEnv: []
extraEnvFrom: []
secretEnv:
create: false
name: ""
data: {}
keys: []
args: []
resources: {}
podAnnotations: {}
podLabels: {}
podSecurityContext: {}
securityContext:
runAsUser: 0
runAsGroup: 0
privileged: false
nodeSelector: {}
tolerations: []
affinity: {}
dockerSocket:
enabled: true
path: /var/run/docker.sock
hostPathType: Socket
extraVolumes: []
extraVolumeMounts: []

View file

@ -52,6 +52,26 @@ docker run -d -p 7655:7655 -v pulse_data:/data rcourtman/pulse:latest
See [Docker Guide](DOCKER.md) for advanced options.
### Kubernetes (Helm)
Use the bundled Helm chart for Kubernetes clusters:
```bash
helm registry login ghcr.io
helm install pulse oci://ghcr.io/rcourtman/pulse-chart \
--version $(curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/VERSION) \
--namespace pulse \
--create-namespace
# Replace the VERSION lookup with a specific release tag (without "v") if you need to pin.
# Developing locally? Install from the checked-out chart directory instead:
# helm upgrade --install pulse ./deploy/helm/pulse \
# --namespace pulse \
# --create-namespace
```
Read the full [Kubernetes deployment guide](KUBERNETES.md) for ingress, persistence, and Docker agent configuration.
## Updating
### Automatic Updates (Recommended)
@ -177,4 +197,4 @@ sudo rm /etc/systemd/system/pulse.service
docker stop pulse
docker rm pulse
docker volume rm pulse_data # Warning: deletes all data
```
```

176
docs/KUBERNETES.md Normal file
View file

@ -0,0 +1,176 @@
# Kubernetes Deployment (Helm)
Deploy Pulse to Kubernetes with the bundled Helm chart under `deploy/helm/pulse`. The chart provisions the Pulse hub (web UI + API) and can optionally run the Docker monitoring agent alongside it. Stable builds are published automatically to the GitHub Container Registry (GHCR) whenever a Pulse release goes out.
## Prerequisites
- Kubernetes 1.24 or newer with access to a default `StorageClass`
- Helm 3.9+
- An ingress controller (only if you plan to expose Pulse through an Ingress)
- (Optional) A Docker-compatible runtime on the nodes where you expect to run the Docker agent; the agent talks to `/var/run/docker.sock`
## Installing from GHCR (recommended)
1. Authenticate against GHCR (one-time step on each machine):
```bash
helm registry login ghcr.io
```
2. Install the chart published for the latest Pulse release (swap the inline `curl` command with a pinned version if you need to lock upgrades):
```bash
helm install pulse oci://ghcr.io/rcourtman/pulse-chart \
--version $(curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/VERSION) \
--namespace pulse \
--create-namespace
```
The chart version tracks the Pulse release version. Check [GitHub Releases](https://github.com/rcourtman/Pulse/releases) or run `gh release list --limit 1` to find the newest tag if you prefer to specify it manually.
3. Port-forward the service to finish the first-time security setup:
```bash
kubectl -n pulse port-forward svc/pulse 7655:7655
```
4. Browse to `http://localhost:7655`, complete the security bootstrap (admin user + MFA + TLS preferences), and create an API token under **Settings → Security** for any automation or agents you plan to run.
The chart mounts a PersistentVolumeClaim at `/data` so database files, credentials, and configuration survive pod restarts. By default it requests 8GiB with `ReadWriteOnce` access—adjust via `persistence.*` in `values.yaml`.
## Working From Source (local packaging)
Need to test local modifications or work offline? Install directly from the checked-out repository:
1. Clone the repository and switch into it.
```bash
git clone https://github.com/rcourtman/Pulse.git
cd Pulse
```
2. Render or install the chart from `deploy/helm/pulse`:
```bash
helm upgrade --install pulse ./deploy/helm/pulse \
--namespace pulse \
--create-namespace
```
3. Continue with the port-forward and initial setup steps described above.
## Common Configuration
Most day-to-day overrides are done in a custom values file:
```yaml
# file: helm-values.yaml
service:
type: ClusterIP
ingress:
enabled: true
className: nginx
hosts:
- host: pulse.example.com
paths:
- path: /
pathType: Prefix
tls:
- hosts: [pulse.example.com]
secretName: pulse-tls
server:
env:
- name: TZ
value: Europe/Berlin
secretEnv:
create: true
data:
API_TOKENS: docker-agent-token
```
Install or upgrade with the overrides:
```bash
helm upgrade --install pulse ./deploy/helm/pulse \
--namespace pulse \
--create-namespace \
-f helm-values.yaml
```
The `server.secretEnv` block above pre-seeds one or more API tokens so the UI is immediately accessible and automation can authenticate. If you prefer to manage credentials separately, set `server.secretEnv.name` to reference an existing secret instead of letting the chart create one.
### Accessing Pulse
- **Port forward:** `kubectl -n pulse port-forward svc/pulse 7655:7655`
- **Ingress:** Enable via the snippet above, or supply your own annotations for external DNS, TLS, etc.
- **LoadBalancer:** Set `service.type: LoadBalancer` and, optionally, `service.loadBalancerIP`.
### Persistence Options
- `persistence.enabled`: Disable to use an ephemeral `emptyDir`
- `persistence.existingClaim`: Bind to a pre-provisioned PVC
- `persistence.storageClass`: Pin to a specific storage class
- `persistence.size`: Resize the default PVC request
## Enabling the Docker Agent
The optional agent reports Docker host metrics back to the Pulse hub. Enable it once you have a valid API token:
```yaml
# agent-values.yaml
agent:
enabled: true
env:
- name: PULSE_URL
value: https://pulse.example.com
secretEnv:
create: true
data:
PULSE_TOKEN: docker-agent-token
dockerSocket:
enabled: true
path: /var/run/docker.sock
hostPathType: Socket
```
Apply with (choose one):
- **Published chart:**
```bash
helm upgrade pulse oci://ghcr.io/rcourtman/pulse-chart \
--install \
--version <pulse-version> \
--namespace pulse \
-f agent-values.yaml
```
- **Local checkout:**
```bash
helm upgrade --install pulse ./deploy/helm/pulse \
--namespace pulse \
-f agent-values.yaml
```
Notes:
- The agent expects a Docker-compatible runtime and access to the Docker socket. Set `agent.dockerSocket.enabled: false` if you run the agent elsewhere or publish the Docker API securely.
- Use separate API tokens per host; list multiple tokens with `;` or `,` separators in `PULSE_TARGETS` if needed.
- Run the agent as a `DaemonSet` (default) to cover every node, or switch to `agent.kind: Deployment` for a single pod.
## Upgrades and Removal
- **Upgrade (GHCR):** `helm upgrade pulse oci://ghcr.io/rcourtman/pulse-chart --version <new-version> -n pulse -f <values.yaml>`
- **Upgrade (source):** Re-run `helm upgrade --install pulse ./deploy/helm/pulse -f <values>` with updated overrides.
- **Rollback:** `helm rollback pulse <revision>`
- **Uninstall:** `helm uninstall pulse -n pulse` (PVCs remain unless you delete them manually)
## Reference
- Review every available option in `deploy/helm/pulse/values.yaml`
- Inspect published charts without installing: `helm show values oci://ghcr.io/rcourtman/pulse-chart --version <version>`
- Helm template rendering preview: `helm template pulse ./deploy/helm/pulse -f <values>`
- `NOTES.txt` emitted by Helm summarizes the service endpoint and agent prerequisites after each install or upgrade

72
docs/RELEASE.md Normal file
View file

@ -0,0 +1,72 @@
# Pulse Release Checklist
Use this checklist when preparing and publishing a new Pulse release.
## Pre-release
- [ ] Ensure `VERSION` is up to date and matches the tag you plan to cut (format `4.x.y`)
- [ ] Confirm the Helm chart renders and installs locally:
```bash
helm lint deploy/helm/pulse --strict
helm template pulse deploy/helm/pulse \
--set persistence.enabled=false \
--set server.secretEnv.create=true \
--set server.secretEnv.data.API_TOKENS=dummy-token
```
- [ ] (Optional) Run the Kind-based integration test locally:
```bash
kind create cluster
helm upgrade --install pulse ./deploy/helm/pulse \
--namespace pulse \
--create-namespace \
--set persistence.enabled=false \
--set server.secretEnv.create=true \
--set server.secretEnv.data.API_TOKENS=dummy-token \
--wait
kubectl -n pulse get pods
kind delete cluster
```
## Publishing
1. Tag the release (`git tag v4.x.y && git push origin v4.x.y`) or draft a GitHub release.
2. Package the Helm chart locally so you can preview the artifact (the GitHub workflow performs the same command, but local packaging provides an explicit hand-off):
```bash
./scripts/package-helm-chart.sh 4.x.y
# Optional: push to GHCR after authenticating
# helm registry login ghcr.io
# ./scripts/package-helm-chart.sh 4.x.y --push
```
The script emits `dist/pulse-4.x.y.tgz`, and `scripts/build-release.sh` copies the tarball into `release/` alongside the binary archives. Uploading can be handled manually with the `--push` flag or delegated to the automated workflow described below.
> `scripts/build-release.sh` automatically runs the same packaging step (unless you export `SKIP_HELM_PACKAGE=1`) so release archives and chart tarballs are produced together.
3. If you rely on automation, monitor the **Publish Helm Chart** workflow (triggered by the release) to ensure it finishes successfully. When running entirely locally, skip this step and verify the push command completed.
4. (Optional) Sign `release/checksums.txt` by exporting `SIGNING_KEY_ID=<gpg-key-id>` before running `scripts/build-release.sh`, or re-run the signing step manually:
```bash
SIGNING_KEY_ID=<gpg-key-id> ./scripts/build-release.sh
# or sign later
gpg --detach-sign --armor --local-user <gpg-key-id> release/checksums.txt
```
Publish both `checksums.txt` and `checksums.txt.asc` so users can verify artifacts:
```bash
gpg --verify checksums.txt.asc checksums.txt
```
5. Update the release notes to include an upgrade/install snippet pointing at GHCR, for example:
```bash
helm install pulse oci://ghcr.io/rcourtman/pulse-chart \
--version 4.x.y \
--namespace pulse \
--create-namespace
```
6. Mention any chart-breaking changes (new values, migrations) in the release notes.
## Post-release
- [ ] Verify `helm show chart oci://ghcr.io/rcourtman/pulse-chart --version 4.x.y` shows the expected metadata (version, appVersion, icon)
- [ ] Run `helm install` against a test cluster (Kind/k3s) using the published OCI artifact
- [ ] Announce the release with links to both the GitHub release and the Helm installation instructions (`docs/KUBERNETES.md`)
- [ ] Verify signatures: `gpg --verify checksums.txt.asc checksums.txt`

View file

@ -203,9 +203,43 @@ for build_name in "${!builds[@]}"; do
cp "$BUILD_DIR/pulse-sensor-proxy-$build_name" "$RELEASE_DIR/"
done
# Generate checksums (include tarballs and standalone binaries)
cd $RELEASE_DIR
sha256sum *.tar.gz pulse-sensor-proxy-* > checksums.txt
# Optionally package Helm chart
if [ "${SKIP_HELM_PACKAGE:-0}" != "1" ]; then
if command -v helm >/dev/null 2>&1; then
echo "Packaging Helm chart..."
./scripts/package-helm-chart.sh "$VERSION"
if [ -f "dist/pulse-$VERSION.tgz" ]; then
cp "dist/pulse-$VERSION.tgz" "$RELEASE_DIR/"
fi
else
echo "Helm not found on PATH; skipping Helm chart packaging. Install Helm 3.9+ or set SKIP_HELM_PACKAGE=1 to silence this message."
fi
fi
# Generate checksums (include tarballs, helm chart, and standalone binaries)
cd "$RELEASE_DIR"
shopt -s nullglob
checksum_files=( *.tar.gz pulse-sensor-proxy-* )
if compgen -G "pulse-*.tgz" > /dev/null; then
checksum_files+=( pulse-*.tgz )
fi
if [ ${#checksum_files[@]} -eq 0 ]; then
echo "Warning: no release artifacts found to checksum."
else
sha256sum "${checksum_files[@]}" > checksums.txt
if [ -n "${SIGNING_KEY_ID:-}" ]; then
if command -v gpg >/dev/null 2>&1; then
echo "Signing checksums with GPG key ${SIGNING_KEY_ID}..."
gpg --batch --yes --detach-sign --armor \
--local-user "${SIGNING_KEY_ID}" \
--output checksums.txt.asc \
checksums.txt
else
echo "SIGNING_KEY_ID is set but gpg is not installed; skipping signature."
fi
fi
fi
shopt -u nullglob
cd ..
echo

61
scripts/package-helm-chart.sh Executable file
View file

@ -0,0 +1,61 @@
#!/usr/bin/env bash
# Package (and optionally push) the Pulse Helm chart.
# Usage:
# ./scripts/package-helm-chart.sh [version] [--push]
# Environment:
# OCI_REPO (default: ghcr.io/rcourtman/pulse-chart)
# HELM_BIN (default: helm)
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
CHART_DIR="$REPO_ROOT/deploy/helm/pulse"
DIST_DIR="$REPO_ROOT/dist"
HELM_BIN="${HELM_BIN:-helm}"
OCI_REPO="${OCI_REPO:-ghcr.io/rcourtman/pulse-chart}"
if ! command -v "$HELM_BIN" >/dev/null 2>&1; then
echo "Error: Helm not found (expected at \$HELM_BIN=$HELM_BIN). Install Helm 3.9+ first." >&2
exit 1
fi
VERSION_DEFAULT="$(cat "$REPO_ROOT/VERSION")"
VERSION="${1:-$VERSION_DEFAULT}"
VERSION="${VERSION#v}" # strip leading v if provided
PUSH=false
for arg in "$@"; do
if [ "$arg" = "--push" ]; then
PUSH=true
fi
done
echo "Packaging Pulse chart version $VERSION (appVersion=$VERSION)"
rm -rf "$DIST_DIR"
mkdir -p "$DIST_DIR"
"$HELM_BIN" lint "$CHART_DIR" --strict
"$HELM_BIN" package "$CHART_DIR" \
--version "$VERSION" \
--app-version "$VERSION" \
--destination "$DIST_DIR"
PACKAGE_PATH="$DIST_DIR/pulse-$VERSION.tgz"
if [ ! -f "$PACKAGE_PATH" ]; then
echo "Error: Expected package $PACKAGE_PATH not found" >&2
exit 1
fi
if [ "$PUSH" = true ]; then
echo "Pushing chart to oci://$OCI_REPO"
"$HELM_BIN" push "$PACKAGE_PATH" "oci://$OCI_REPO"
fi
echo "Chart packaged at $PACKAGE_PATH"
if [ "$PUSH" = true ]; then
echo "Chart pushed to oci://$OCI_REPO"
else
echo "Run with --push (after logging into GHCR) to upload the artifact."
fi