diff --git a/.github/workflows/README.md b/.github/workflows/README.md index f907f3407..0d9368258 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -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 [--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//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.) diff --git a/.github/workflows/helm-ci.yml b/.github/workflows/helm-ci.yml new file mode 100644 index 000000000..533857202 --- /dev/null +++ b/.github/workflows/helm-ci.yml @@ -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 diff --git a/.github/workflows/helm-integration.yml b/.github/workflows/helm-integration.yml new file mode 100644 index 000000000..d80079742 --- /dev/null +++ b/.github/workflows/helm-integration.yml @@ -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 diff --git a/.github/workflows/publish-helm-chart.yml b/.github/workflows/publish-helm-chart.yml new file mode 100644 index 000000000..7add732ed --- /dev/null +++ b/.github/workflows/publish-helm-chart.yml @@ -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 diff --git a/.gitignore b/.gitignore index b51fb3eb9..6457d0ae5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ # Binaries /bin/ -pulse +/pulse # Logs *.log diff --git a/README.md b/README.md index b95e8e0e8..b1dacc6c4 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/deploy/helm/pulse/Chart.yaml b/deploy/helm/pulse/Chart.yaml new file mode 100644 index 000000000..e905dde05 --- /dev/null +++ b/deploy/helm/pulse/Chart.yaml @@ -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 diff --git a/deploy/helm/pulse/README.md b/deploy/helm/pulse/README.md new file mode 100644 index 000000000..a3cf08f18 --- /dev/null +++ b/deploy/helm/pulse/README.md @@ -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`. diff --git a/deploy/helm/pulse/templates/NOTES.txt b/deploy/helm/pulse/templates/NOTES.txt new file mode 100644 index 000000000..7235c3c74 --- /dev/null +++ b/deploy/helm/pulse/templates/NOTES.txt @@ -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. diff --git a/deploy/helm/pulse/templates/_helpers.tpl b/deploy/helm/pulse/templates/_helpers.tpl new file mode 100644 index 000000000..24e4b31dc --- /dev/null +++ b/deploy/helm/pulse/templates/_helpers.tpl @@ -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 -}} diff --git a/deploy/helm/pulse/templates/agent-secret.yaml b/deploy/helm/pulse/templates/agent-secret.yaml new file mode 100644 index 000000000..fdadfc363 --- /dev/null +++ b/deploy/helm/pulse/templates/agent-secret.yaml @@ -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 }} diff --git a/deploy/helm/pulse/templates/agent-serviceaccount.yaml b/deploy/helm/pulse/templates/agent-serviceaccount.yaml new file mode 100644 index 000000000..253378a9e --- /dev/null +++ b/deploy/helm/pulse/templates/agent-serviceaccount.yaml @@ -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 }} diff --git a/deploy/helm/pulse/templates/agent.yaml b/deploy/helm/pulse/templates/agent.yaml new file mode 100644 index 000000000..9954449a5 --- /dev/null +++ b/deploy/helm/pulse/templates/agent.yaml @@ -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 }} diff --git a/deploy/helm/pulse/templates/deployment.yaml b/deploy/helm/pulse/templates/deployment.yaml new file mode 100644 index 000000000..7e2d2abfe --- /dev/null +++ b/deploy/helm/pulse/templates/deployment.yaml @@ -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 }} diff --git a/deploy/helm/pulse/templates/ingress.yaml b/deploy/helm/pulse/templates/ingress.yaml new file mode 100644 index 000000000..29fe95d75 --- /dev/null +++ b/deploy/helm/pulse/templates/ingress.yaml @@ -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 }} diff --git a/deploy/helm/pulse/templates/pvc.yaml b/deploy/helm/pulse/templates/pvc.yaml new file mode 100644 index 000000000..289ecce99 --- /dev/null +++ b/deploy/helm/pulse/templates/pvc.yaml @@ -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 }} diff --git a/deploy/helm/pulse/templates/server-secret.yaml b/deploy/helm/pulse/templates/server-secret.yaml new file mode 100644 index 000000000..fa06059b0 --- /dev/null +++ b/deploy/helm/pulse/templates/server-secret.yaml @@ -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 }} diff --git a/deploy/helm/pulse/templates/service.yaml b/deploy/helm/pulse/templates/service.yaml new file mode 100644 index 000000000..c3418d618 --- /dev/null +++ b/deploy/helm/pulse/templates/service.yaml @@ -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 }} diff --git a/deploy/helm/pulse/templates/serviceaccount.yaml b/deploy/helm/pulse/templates/serviceaccount.yaml new file mode 100644 index 000000000..c263fa9fd --- /dev/null +++ b/deploy/helm/pulse/templates/serviceaccount.yaml @@ -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 }} diff --git a/deploy/helm/pulse/values.yaml b/deploy/helm/pulse/values.yaml new file mode 100644 index 000000000..8a501062f --- /dev/null +++ b/deploy/helm/pulse/values.yaml @@ -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: [] diff --git a/docs/INSTALL.md b/docs/INSTALL.md index a258ff070..ddc84a0cc 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -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 -``` \ No newline at end of file +``` diff --git a/docs/KUBERNETES.md b/docs/KUBERNETES.md new file mode 100644 index 000000000..f196888fc --- /dev/null +++ b/docs/KUBERNETES.md @@ -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 8 GiB 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 \ + --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 -n pulse -f ` +- **Upgrade (source):** Re-run `helm upgrade --install pulse ./deploy/helm/pulse -f ` with updated overrides. +- **Rollback:** `helm rollback pulse ` +- **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 ` +- Helm template rendering preview: `helm template pulse ./deploy/helm/pulse -f ` +- `NOTES.txt` emitted by Helm summarizes the service endpoint and agent prerequisites after each install or upgrade diff --git a/docs/RELEASE.md b/docs/RELEASE.md new file mode 100644 index 000000000..afaa03aef --- /dev/null +++ b/docs/RELEASE.md @@ -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=` before running `scripts/build-release.sh`, or re-run the signing step manually: + ```bash + SIGNING_KEY_ID= ./scripts/build-release.sh + # or sign later + gpg --detach-sign --armor --local-user 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` diff --git a/scripts/build-release.sh b/scripts/build-release.sh index 296362268..18b16719d 100755 --- a/scripts/build-release.sh +++ b/scripts/build-release.sh @@ -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 diff --git a/scripts/package-helm-chart.sh b/scripts/package-helm-chart.sh new file mode 100755 index 000000000..3fa11f8a0 --- /dev/null +++ b/scripts/package-helm-chart.sh @@ -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