This is an automated email from the ASF dual-hosted git repository.

hgruszecki pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/iggy.git


The following commit(s) were added to refs/heads/master by this push:
     new 411a697eb ci: add Helm chart validation (#3019)
411a697eb is described below

commit 411a697eb184ebc33d7ce4b862f51db49ce46ea5
Author: Aviraj Khare <[email protected]>
AuthorDate: Fri Mar 27 16:42:41 2026 +0530

    ci: add Helm chart validation (#3019)
---
 .github/actions/utils/setup-helm-tools/action.yml |  67 ++++
 .github/config/components.yml                     |   5 +
 .github/workflows/_test.yml                       |  23 ++
 helm/charts/iggy/README.md                        |  79 +++-
 helm/charts/iggy/templates/hpa.yaml               |  13 +-
 helm/charts/iggy/templates/ingress.yaml           |  12 +-
 scripts/ci/setup-helm-smoke-cluster.sh            | 235 +++++++++++
 scripts/ci/setup-helm-tools.sh                    | 111 ++++++
 scripts/ci/test-helm.sh                           | 452 ++++++++++++++++++++++
 9 files changed, 981 insertions(+), 16 deletions(-)

diff --git a/.github/actions/utils/setup-helm-tools/action.yml 
b/.github/actions/utils/setup-helm-tools/action.yml
new file mode 100644
index 000000000..971796613
--- /dev/null
+++ b/.github/actions/utils/setup-helm-tools/action.yml
@@ -0,0 +1,67 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+name: setup-helm-tools
+description: Install pinned Helm and optionally kind for Helm chart CI jobs
+inputs:
+  install-kind:
+    description: "Whether to install kind in addition to Helm"
+    required: false
+    default: "false"
+  helm-version:
+    description: "Helm release version"
+    required: false
+    default: "v4.1.3"
+  helm-checksum:
+    description: "SHA256 checksum for the Helm tarball"
+    required: false
+    default: "02ce9722d541238f81459938b84cf47df2fdf1187493b4bfb2346754d82a4700"
+  kind-version:
+    description: "kind release version"
+    required: false
+    default: "v0.31.0"
+  kind-checksum:
+    description: "SHA256 checksum for the kind binary"
+    required: false
+    default: "eb244cbafcc157dff60cf68693c14c9a75c4e6e6fedaf9cd71c58117cb93e3fa"
+
+runs:
+  using: "composite"
+  steps:
+    - name: Verify supported runner
+      shell: bash
+      run: |
+        set -euo pipefail
+        if [[ "${{ runner.os }}" != "Linux" || "${{ runner.arch }}" != "X64" 
]]; then
+          echo "setup-helm-tools currently supports Linux X64 runners only" >&2
+          exit 1
+        fi
+
+    - name: Install Helm and optional kind
+      shell: bash
+      env:
+        HELM_VERSION: ${{ inputs.helm-version }}
+        HELM_CHECKSUM: ${{ inputs.helm-checksum }}
+        KIND_VERSION: ${{ inputs.kind-version }}
+        KIND_CHECKSUM: ${{ inputs.kind-checksum }}
+      run: |
+        set -euo pipefail
+        args=()
+        if [[ "${{ inputs.install-kind }}" == "true" ]]; then
+          args+=(--install-kind)
+        fi
+        scripts/ci/setup-helm-tools.sh "${args[@]}"
diff --git a/.github/config/components.yml b/.github/config/components.yml
index 29079bd8f..7585a91b9 100644
--- a/.github/config/components.yml
+++ b/.github/config/components.yml
@@ -373,6 +373,11 @@ components:
       - "web/**"
     tasks: ["lint", "build"]
 
+  helm:
+    paths:
+      - "helm/**"
+    tasks: ["validate", "smoke"]
+
   rust-bench-dashboard:
     paths:
       - "core/bench/dashboard/**"
diff --git a/.github/workflows/_test.yml b/.github/workflows/_test.yml
index ac3eb6ca7..75a7b7c1d 100644
--- a/.github/workflows/_test.yml
+++ b/.github/workflows/_test.yml
@@ -192,6 +192,29 @@ jobs:
             npm run build
           fi
 
+      # Helm chart testing
+      - name: Setup Helm test tools
+        if: inputs.component == 'helm' && (inputs.task == 'validate' || 
inputs.task == 'smoke')
+        uses: ./.github/actions/utils/setup-helm-tools
+        with:
+          install-kind: ${{ inputs.task == 'smoke' }}
+
+      - name: Setup Helm smoke cluster
+        if: inputs.component == 'helm' && inputs.task == 'smoke'
+        run: scripts/ci/setup-helm-smoke-cluster.sh
+
+      - name: Validate Helm chart
+        if: inputs.component == 'helm' && inputs.task == 'validate'
+        run: scripts/ci/test-helm.sh validate
+
+      - name: Smoke test Helm chart runtime
+        if: inputs.component == 'helm' && inputs.task == 'smoke'
+        run: scripts/ci/test-helm.sh smoke --cleanup
+
+      - name: Collect Helm smoke diagnostics
+        if: failure() && inputs.component == 'helm' && inputs.task == 'smoke'
+        run: scripts/ci/test-helm.sh collect-smoke-diagnostics
+
       # CI workflow validation
       - name: Validate CI workflows
         if: inputs.component == 'ci-workflows' && inputs.task == 'validate'
diff --git a/helm/charts/iggy/README.md b/helm/charts/iggy/README.md
index 76c62b3e3..9082cb968 100644
--- a/helm/charts/iggy/README.md
+++ b/helm/charts/iggy/README.md
@@ -89,7 +89,7 @@ helm uninstall iggy
 | `server.enabled` | bool | `true` | Enable the Iggy server deployment |
 | `server.replicaCount` | int | `1` | Number of server replicas |
 | `server.image.repository` | string | `"apache/iggy"` | Server image 
repository |
-| `server.image.tag` | string | `""` | Server image tag (defaults to chart 
appVersion) |
+| `server.image.tag` | string | `"0.7.0"` | Server image tag |
 | `server.ports.http` | int | `3000` | HTTP API port |
 | `server.ports.tcp` | int | `8090` | TCP protocol port |
 | `server.ports.quic` | int | `8080` | QUIC protocol port |
@@ -135,6 +135,83 @@ helm uninstall iggy
 | `ui.securityContext` | object | `{}` | UI container security context |
 | `ui.podSecurityContext` | object | `{}` | UI pod security context |
 
+## Testing
+
+The chart CI paths are also available locally from the repository root.
+
+### Render Validation
+
+If `helm` is already installed locally:
+
+```bash
+scripts/ci/test-helm.sh validate
+```
+
+If you want the pinned Linux CI tool version instead:
+
+```bash
+scripts/ci/setup-helm-tools.sh
+scripts/ci/test-helm.sh validate
+```
+
+This runs `helm lint --strict` plus the CI render scenarios, including:
+
+* default chart output
+* all-features render
+* legacy Kubernetes 1.18 API coverage
+* server-only render
+* UI-only render
+* existing-secret render
+
+### Runtime Smoke Test
+
+The smoke path requires `helm`, `kind`, `kubectl`, and `curl`.
+
+Before running the local smoke path, keep these common gotchas in mind:
+
+* the Iggy server requires working `io_uring` support from the Kubernetes 
node/kernel/runtime
+* the server also needs enough available memory and locked-memory headroom 
during startup
+* `scripts/ci/test-helm.sh cleanup-smoke` removes the Helm release and smoke 
namespace, but it does not delete the reusable kind cluster created by 
`scripts/ci/setup-helm-smoke-cluster.sh`
+
+If `helm` and `kind` are already installed:
+
+```bash
+scripts/ci/setup-helm-smoke-cluster.sh
+scripts/ci/test-helm.sh smoke --cleanup
+```
+
+If you want the pinned Linux CI tool versions:
+
+```bash
+scripts/ci/setup-helm-tools.sh --install-kind
+scripts/ci/setup-helm-smoke-cluster.sh
+scripts/ci/test-helm.sh smoke --cleanup
+```
+
+If a previous local smoke install failed and left resources behind, reset the 
smoke namespace with:
+
+```bash
+scripts/ci/test-helm.sh cleanup-smoke
+```
+
+On Apple Silicon hosts, the released `apache/iggy:0.7.0` `arm64` image may 
still fail during the runtime smoke path in kind. If your Docker setup supports 
amd64 emulation well enough, you can try recreating the dedicated smoke cluster 
with:
+
+```bash
+HELM_SMOKE_KIND_PLATFORM=linux/amd64 scripts/ci/setup-helm-smoke-cluster.sh
+```
+
+The smoke script defaults `IGGY_SYSTEM_SHARDING_CPU_ALLOCATION=1` for the 
server pod so the local kind path avoids the chart's `numa:auto` default and 
keeps the local runtime to a single shard, which has been more reliable on 
containerized local nodes. If you need a different local override, set 
`HELM_SMOKE_SERVER_CPU_ALLOCATION` before running `scripts/ci/test-helm.sh 
smoke`. Pass `--cleanup` to remove the smoke namespace after a successful run; 
omit it if you want to inspect the deploy [...]
+
+On smoke-test failures you can collect the same diagnostics as CI with:
+
+```bash
+scripts/ci/test-helm.sh collect-smoke-diagnostics
+```
+
+> **Note:** `scripts/ci/setup-helm-tools.sh` currently supports Linux `x86_64` 
only.
+> On other local platforms, install equivalent `helm` and `kind` binaries 
yourself and then use the same scripts above.
+> The runtime smoke test may still fail on some local/containerized clusters 
if the node/kernel does not provide the `io_uring` support required by the 
server runtime even after the local sharding override, or if the local 
environment does not provide enough memory for the server to initialize cleanly.
+
 ## Troubleshooting
 
 ### Pod CrashLoopBackOff with "Out of memory" error
diff --git a/helm/charts/iggy/templates/hpa.yaml 
b/helm/charts/iggy/templates/hpa.yaml
index ea7b4cb54..5d9f635d9 100644
--- a/helm/charts/iggy/templates/hpa.yaml
+++ b/helm/charts/iggy/templates/hpa.yaml
@@ -14,9 +14,8 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
-
-{{ if .Values.autoscaling.enabled }}
-{{- if .Capabilities.APIVersions.Has "autoscaling/v2" -}}
+{{ if .Values.autoscaling.enabled -}}
+{{- if semverCompare ">=1.23-0" .Capabilities.KubeVersion.GitVersion -}}
 apiVersion: autoscaling/v2
 {{- else -}}
 apiVersion: autoscaling/v2beta2
@@ -38,24 +37,24 @@ spec:
     - type: Resource
       resource:
         name: cpu
-        {{- if .Capabilities.APIVersions.Has "autoscaling/v2" }}
+        {{- if semverCompare ">=1.23-0" .Capabilities.KubeVersion.GitVersion }}
         target:
           type: Utilization
           averageUtilization: {{ 
.Values.autoscaling.targetCPUUtilizationPercentage }}
         {{- else }}
-          targetAverageUtilization: {{ 
.Values.autoscaling.targetCPUUtilizationPercentage }}
+        targetAverageUtilization: {{ 
.Values.autoscaling.targetCPUUtilizationPercentage }}
         {{- end }}
     {{- end }}
     {{- if .Values.autoscaling.targetMemoryUtilizationPercentage }}
     - type: Resource
       resource:
         name: memory
-        {{- if .Capabilities.APIVersions.Has "autoscaling/v2" }}
+        {{- if semverCompare ">=1.23-0" .Capabilities.KubeVersion.GitVersion }}
         target:
           type: Utilization
           averageUtilization: {{ 
.Values.autoscaling.targetMemoryUtilizationPercentage }}
         {{- else }}
-          targetAverageUtilization: {{ 
.Values.autoscaling.targetMemoryUtilizationPercentage }}
+        targetAverageUtilization: {{ 
.Values.autoscaling.targetMemoryUtilizationPercentage }}
         {{- end }}
     {{- end }}
 {{- end }}
diff --git a/helm/charts/iggy/templates/ingress.yaml 
b/helm/charts/iggy/templates/ingress.yaml
index ddefbba40..b0339e574 100644
--- a/helm/charts/iggy/templates/ingress.yaml
+++ b/helm/charts/iggy/templates/ingress.yaml
@@ -14,8 +14,6 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
-
-
 {{ if .Values.server.ingress.enabled -}}
 {{ $fullName := include "iggy.fullname" . -}}
 {{ $svcPort := .Values.server.service.port -}}
@@ -24,11 +22,11 @@
   {{- $_ := set .Values.server.ingress.annotations 
"kubernetes.io/ingress.class" .Values.server.ingress.className}}
   {{- end }}
 {{- end }}
-{{ if .Capabilities.APIVersions.Has "networking.k8s.io/v1" }}
+{{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}}
 apiVersion: networking.k8s.io/v1
-{{- else if .Capabilities.APIVersions.Has "networking.k8s.io/v1beta1" }}
+{{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}}
 apiVersion: networking.k8s.io/v1beta1
-{{- else }}
+{{- else -}}
 apiVersion: extensions/v1beta1
 {{- end }}
 kind: Ingress
@@ -77,8 +75,6 @@ spec:
           {{- end }}
     {{- end }}
 {{- end }}
-
-
 {{ if .Values.ui.ingress.enabled -}}
 ---
 {{ $fullName := include "iggy.fullname" . -}}
@@ -97,7 +93,7 @@ apiVersion: extensions/v1beta1
 {{- end }}
 kind: Ingress
 metadata:
-  name: {{ $fullName }}
+  name: {{ $fullName }}-ui
   labels:
     {{- include "iggy.labels" . | nindent 4 }}
   {{- with .Values.ui.ingress.annotations }}
diff --git a/scripts/ci/setup-helm-smoke-cluster.sh 
b/scripts/ci/setup-helm-smoke-cluster.sh
new file mode 100755
index 000000000..17921f4ed
--- /dev/null
+++ b/scripts/ci/setup-helm-smoke-cluster.sh
@@ -0,0 +1,235 @@
+#!/usr/bin/env bash
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+#
+
+set -euo pipefail
+
+usage() {
+  cat <<'EOF'
+Usage: scripts/ci/setup-helm-smoke-cluster.sh
+
+Create or reuse the kind cluster used by the Helm smoke test and ensure
+ingress-nginx is installed and ready in that cluster.
+
+Environment:
+  HELM_SMOKE_KIND_NAME               kind cluster name (default: 
iggy-helm-smoke)
+  HELM_SMOKE_KIND_IMAGE              kind node image (default: 
kindest/node:v1.35.0)
+  HELM_SMOKE_KIND_PLATFORM           docker platform for the kind node image
+  HELM_SMOKE_KIND_WAIT               kind create wait timeout (default: 120s)
+  HELM_SMOKE_INGRESS_NGINX_VERSION   ingress-nginx controller tag (default: 
controller-v1.15.1)
+  HELM_SMOKE_INGRESS_NGINX_TIMEOUT   rollout timeout (default: 5m)
+EOF
+}
+
+if [ "${1:-}" = "--help" ] || [ "${1:-}" = "-h" ] || [ "${1:-}" = "help" ]; 
then
+  usage
+  exit 0
+fi
+
+if [ "$#" -ne 0 ]; then
+  usage
+  exit 1
+fi
+
+HELM_SMOKE_KIND_NAME="${HELM_SMOKE_KIND_NAME:-iggy-helm-smoke}"
+HELM_SMOKE_KIND_IMAGE="${HELM_SMOKE_KIND_IMAGE:-kindest/node:v1.35.0}"
+HELM_SMOKE_KIND_PLATFORM="${HELM_SMOKE_KIND_PLATFORM:-}"
+HELM_SMOKE_KIND_WAIT="${HELM_SMOKE_KIND_WAIT:-120s}"
+HELM_SMOKE_INGRESS_NGINX_VERSION="${HELM_SMOKE_INGRESS_NGINX_VERSION:-controller-v1.15.1}"
+HELM_SMOKE_INGRESS_NGINX_TIMEOUT="${HELM_SMOKE_INGRESS_NGINX_TIMEOUT:-5m}"
+
+require_command() {
+  if ! command -v "$1" >/dev/null 2>&1; then
+    echo "Error: required command '$1' not found" >&2
+    exit 1
+  fi
+}
+
+duration_to_seconds() {
+  local duration="$1"
+  local total=0
+  local value
+  local unit
+  local multiplier
+
+  if [[ "$duration" =~ ^[0-9]+$ ]]; then
+    echo "$duration"
+    return 0
+  fi
+
+  while [ -n "$duration" ]; do
+    if [[ "$duration" =~ ^([0-9]+)([smhd])(.*)$ ]]; then
+      value="${BASH_REMATCH[1]}"
+      unit="${BASH_REMATCH[2]}"
+      duration="${BASH_REMATCH[3]}"
+      case "$unit" in
+        s) multiplier=1 ;;
+        m) multiplier=60 ;;
+        h) multiplier=3600 ;;
+        d) multiplier=86400 ;;
+      esac
+      total=$((total + (value * multiplier)))
+      continue
+    fi
+
+    echo "Error: unsupported timeout format '$1'" >&2
+    echo "Use plain seconds or a combination of s, m, h, or d units such as 
300, 5m, or 1h30m." >&2
+    return 1
+  done
+
+  echo "$total"
+}
+
+wait_for_completed_job() {
+  local job_name="$1"
+
+  if ! kubectl -n ingress-nginx get "job/${job_name}" >/dev/null 2>&1; then
+    return 0
+  fi
+
+  kubectl -n ingress-nginx wait \
+    --for=condition=complete \
+    "job/${job_name}" \
+    --timeout="$HELM_SMOKE_INGRESS_NGINX_TIMEOUT"
+}
+
+wait_for_ingress_validation() {
+  local sleep_seconds=4
+  local validation_timeout_seconds
+  local deadline
+  local probe_file
+
+  validation_timeout_seconds="$(duration_to_seconds 
"$HELM_SMOKE_INGRESS_NGINX_TIMEOUT")"
+  deadline=$((SECONDS + validation_timeout_seconds))
+  probe_file="$(mktemp "${TMPDIR:-/tmp}/ingress-nginx-probe.XXXXXX.yaml")"
+
+  cat > "$probe_file" <<'EOF'
+apiVersion: networking.k8s.io/v1
+kind: Ingress
+metadata:
+  name: ingress-nginx-readiness-probe
+  namespace: ingress-nginx
+spec:
+  ingressClassName: nginx
+  rules:
+    - host: readiness-probe.iggy.local
+      http:
+        paths:
+          - path: /
+            pathType: Prefix
+            backend:
+              service:
+                name: ingress-nginx-controller
+                port:
+                  number: 80
+EOF
+
+  while (( SECONDS < deadline )); do
+    if kubectl apply --dry-run=server -f "$probe_file" >/dev/null 2>&1; then
+      rm -f "$probe_file"
+      return 0
+    fi
+    sleep "$sleep_seconds"
+  done
+
+  echo "Error: ingress-nginx admission webhook did not become ready within 
${HELM_SMOKE_INGRESS_NGINX_TIMEOUT}" >&2
+  if ! kubectl apply --dry-run=server -f "$probe_file"; then
+    rm -f "$probe_file"
+    return 1
+  fi
+  rm -f "$probe_file"
+}
+
+require_command kind
+require_command kubectl
+
+if [ -n "$HELM_SMOKE_KIND_PLATFORM" ]; then
+  require_command docker
+fi
+
+kind_context="kind-${HELM_SMOKE_KIND_NAME}"
+kind_config="$(mktemp "${TMPDIR:-/tmp}/iggy-kind.XXXXXX.yaml")"
+trap 'rm -f "$kind_config"' EXIT
+
+cat > "$kind_config" <<'EOF'
+kind: Cluster
+apiVersion: kind.x-k8s.io/v1alpha4
+nodes:
+  - role: control-plane
+    kubeadmConfigPatches:
+      - |
+        kind: InitConfiguration
+        nodeRegistration:
+          kubeletExtraArgs:
+            node-labels: "ingress-ready=true"
+    extraPortMappings:
+      - containerPort: 80
+        hostPort: 80
+        protocol: TCP
+      - containerPort: 443
+        hostPort: 443
+        protocol: TCP
+EOF
+
+cluster_exists=false
+if kind get clusters | grep -Fxq "$HELM_SMOKE_KIND_NAME"; then
+  cluster_exists=true
+fi
+
+if [ "$cluster_exists" = true ] && [ -n "$HELM_SMOKE_KIND_PLATFORM" ]; then
+  current_kind_image_id="$(
+    docker inspect "${HELM_SMOKE_KIND_NAME}-control-plane" \
+      --format '{{.Image}}' 2>/dev/null || true
+  )"
+  current_kind_platform="$(
+    docker image inspect "$current_kind_image_id" \
+      --format '{{.Os}}/{{.Architecture}}' 2>/dev/null || true
+  )"
+  if [ -n "$current_kind_platform" ] && [ "$current_kind_platform" != 
"$HELM_SMOKE_KIND_PLATFORM" ]; then
+    echo "Recreating kind cluster '$HELM_SMOKE_KIND_NAME' to switch from 
${current_kind_platform} to ${HELM_SMOKE_KIND_PLATFORM}"
+    kind delete cluster --name "$HELM_SMOKE_KIND_NAME"
+    cluster_exists=false
+  fi
+fi
+
+if [ "$cluster_exists" = true ]; then
+  echo "Reusing existing kind cluster '$HELM_SMOKE_KIND_NAME'"
+else
+  if [ -n "$HELM_SMOKE_KIND_PLATFORM" ]; then
+    echo "Pulling ${HELM_SMOKE_KIND_IMAGE} for ${HELM_SMOKE_KIND_PLATFORM}"
+    docker pull --platform "$HELM_SMOKE_KIND_PLATFORM" 
"$HELM_SMOKE_KIND_IMAGE" >/dev/null
+  fi
+  kind create cluster \
+    --name "$HELM_SMOKE_KIND_NAME" \
+    --wait "$HELM_SMOKE_KIND_WAIT" \
+    --config "$kind_config" \
+    --image "$HELM_SMOKE_KIND_IMAGE"
+fi
+
+kubectl config use-context "$kind_context" >/dev/null
+kubectl apply -f 
"https://raw.githubusercontent.com/kubernetes/ingress-nginx/${HELM_SMOKE_INGRESS_NGINX_VERSION}/deploy/static/provider/kind/deploy.yaml";
+kubectl -n ingress-nginx rollout status deployment/ingress-nginx-controller 
--timeout="$HELM_SMOKE_INGRESS_NGINX_TIMEOUT"
+kubectl -n ingress-nginx wait \
+  --for=condition=ready \
+  pod \
+  --selector=app.kubernetes.io/component=controller \
+  --timeout="$HELM_SMOKE_INGRESS_NGINX_TIMEOUT"
+wait_for_completed_job ingress-nginx-admission-create
+wait_for_completed_job ingress-nginx-admission-patch
+wait_for_ingress_validation
diff --git a/scripts/ci/setup-helm-tools.sh b/scripts/ci/setup-helm-tools.sh
new file mode 100755
index 000000000..5c4a2686f
--- /dev/null
+++ b/scripts/ci/setup-helm-tools.sh
@@ -0,0 +1,111 @@
+#!/usr/bin/env bash
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+#
+
+set -euo pipefail
+
+usage() {
+  cat <<'EOF'
+Usage: scripts/ci/setup-helm-tools.sh [--install-kind]
+
+Install the pinned Helm toolchain used by Helm chart CI jobs. When requested,
+also install the pinned kind binary used by the Helm smoke test.
+
+Environment:
+  HELM_VERSION         Helm release version (default: v4.1.3)
+  HELM_CHECKSUM        SHA256 checksum for the Helm tarball
+  KIND_VERSION         kind release version (default: v0.31.0)
+  KIND_CHECKSUM        SHA256 checksum for the kind binary
+  HELM_TOOLS_BIN_DIR   Target binary directory (default: /usr/local/bin)
+EOF
+}
+
+INSTALL_KIND="false"
+
+while [ $# -gt 0 ]; do
+  case "$1" in
+    --install-kind)
+      INSTALL_KIND="true"
+      ;;
+    --help|-h|help)
+      usage
+      exit 0
+      ;;
+    *)
+      echo "Error: unknown option '$1'" >&2
+      usage
+      exit 1
+      ;;
+  esac
+  shift
+done
+
+HELM_VERSION="${HELM_VERSION:-v4.1.3}"
+HELM_CHECKSUM="${HELM_CHECKSUM:-02ce9722d541238f81459938b84cf47df2fdf1187493b4bfb2346754d82a4700}"
+KIND_VERSION="${KIND_VERSION:-v0.31.0}"
+KIND_CHECKSUM="${KIND_CHECKSUM:-eb244cbafcc157dff60cf68693c14c9a75c4e6e6fedaf9cd71c58117cb93e3fa}"
+HELM_TOOLS_BIN_DIR="${HELM_TOOLS_BIN_DIR:-/usr/local/bin}"
+
+require_command() {
+  if ! command -v "$1" >/dev/null 2>&1; then
+    echo "Error: required command '$1' not found" >&2
+    exit 1
+  fi
+}
+
+install_binary() {
+  local source="$1"
+  local destination="$2"
+
+  mkdir -p "$HELM_TOOLS_BIN_DIR"
+  if [ -w "$HELM_TOOLS_BIN_DIR" ]; then
+    install -m 0755 "$source" "$destination"
+  else
+    require_command sudo
+    sudo install -m 0755 "$source" "$destination"
+  fi
+}
+
+if [[ "$(uname -s)" != "Linux" || "$(uname -m)" != "x86_64" ]]; then
+  cat >&2 <<'EOF'
+Error: scripts/ci/setup-helm-tools.sh currently supports Linux x86_64 only.
+Install helm and kind manually on other platforms, then run the local Helm 
test scripts.
+EOF
+  exit 1
+fi
+
+require_command wget
+require_command sha256sum
+require_command tar
+require_command install
+
+helm_archive="/tmp/helm.tar.gz"
+helm_dir="/tmp/linux-amd64"
+wget -qO "$helm_archive" 
"https://get.helm.sh/helm-${HELM_VERSION}-linux-amd64.tar.gz";
+echo "${HELM_CHECKSUM}  ${helm_archive}" | sha256sum -c -
+rm -rf "$helm_dir"
+tar -zxf "$helm_archive" -C /tmp linux-amd64/helm
+install_binary "$helm_dir/helm" "${HELM_TOOLS_BIN_DIR}/helm"
+
+if [ "$INSTALL_KIND" = "true" ]; then
+  kind_binary="/tmp/kind"
+  wget -qO "$kind_binary" 
"https://github.com/kubernetes-sigs/kind/releases/download/${KIND_VERSION}/kind-linux-amd64";
+  echo "${KIND_CHECKSUM}  ${kind_binary}" | sha256sum -c -
+  install_binary "$kind_binary" "${HELM_TOOLS_BIN_DIR}/kind"
+fi
diff --git a/scripts/ci/test-helm.sh b/scripts/ci/test-helm.sh
new file mode 100755
index 000000000..c8cd377f5
--- /dev/null
+++ b/scripts/ci/test-helm.sh
@@ -0,0 +1,452 @@
+#!/usr/bin/env bash
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+#
+
+set -euo pipefail
+
+usage() {
+  cat <<'EOF'
+Usage: scripts/ci/test-helm.sh 
<validate|smoke|cleanup-smoke|collect-smoke-diagnostics> [--cleanup]
+
+Commands:
+  validate                  Run Helm lint and render validation scenarios.
+  smoke                     Run the Helm runtime smoke scenario against the 
current Kubernetes context.
+  cleanup-smoke             Remove the Helm smoke release namespace and any 
failed-install leftovers.
+  collect-smoke-diagnostics Collect diagnostics for the Helm smoke namespace.
+
+Notes:
+  - validate requires helm.
+  - smoke requires helm, kubectl, and curl, plus an existing cluster and 
ingress controller.
+  - pass --cleanup with smoke to remove the Helm smoke namespace after a 
successful run.
+  - cleanup-smoke requires kubectl and optionally helm.
+  - collect-smoke-diagnostics is best-effort and does not fail on missing 
resources.
+EOF
+}
+
+REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
+cd "$REPO_ROOT"
+
+CHART_DIR="${CHART_DIR:-helm/charts/iggy}"
+HELM_RENDER_DIR="${HELM_RENDER_DIR:-/tmp/helm-render}"
+HELM_SMOKE_NAMESPACE="${HELM_SMOKE_NAMESPACE:-iggy-smoke}"
+HELM_SMOKE_RELEASE="${HELM_SMOKE_RELEASE:-iggy-smoke}"
+HELM_SMOKE_REPORT_DIR="${HELM_SMOKE_REPORT_DIR:-reports/helm-smoke}"
+HELM_SMOKE_SERVER_HOST="${HELM_SMOKE_SERVER_HOST:-server.iggy.local}"
+HELM_SMOKE_UI_HOST="${HELM_SMOKE_UI_HOST:-ui.iggy.local}"
+HELM_SMOKE_TIMEOUT="${HELM_SMOKE_TIMEOUT:-5m}"
+HELM_SMOKE_INGRESS_CLASS="${HELM_SMOKE_INGRESS_CLASS:-nginx}"
+HELM_SMOKE_KIND_NAME="${HELM_SMOKE_KIND_NAME:-iggy-helm-smoke}"
+HELM_SMOKE_SERVER_CPU_ALLOCATION="${HELM_SMOKE_SERVER_CPU_ALLOCATION:-1}"
+
+require_command() {
+  if ! command -v "$1" >/dev/null 2>&1; then
+    echo "Error: required command '$1' not found" >&2
+    exit 1
+  fi
+}
+
+extract_chart_field() {
+  local field="$1"
+  local value
+
+  value="$(
+    awk -v field="$field" '
+      $1 == field ":" {
+        gsub(/"/, "", $2)
+        print $2
+        exit
+      }
+    ' "$CHART_DIR/Chart.yaml"
+  )"
+
+  if [ -z "$value" ]; then
+    echo "Error: could not extract '$field' from $CHART_DIR/Chart.yaml" >&2
+    exit 1
+  fi
+
+  printf '%s\n' "$value"
+}
+
+extract_values_tag() {
+  local section="$1"
+  local value
+
+  value="$(
+    awk -v section="$section" '
+      $1 == section ":" {
+        in_section = 1
+        next
+      }
+      in_section && /^[^[:space:]]/ {
+        in_section = 0
+      }
+      in_section && $1 == "tag:" {
+        gsub(/"/, "", $2)
+        print $2
+        exit
+      }
+    ' "$CHART_DIR/values.yaml"
+  )"
+
+  if [ -z "$value" ]; then
+    echo "Error: could not extract '$section.image.tag' from 
$CHART_DIR/values.yaml" >&2
+    exit 1
+  fi
+
+  printf '%s\n' "$value"
+}
+
+prepare_render_dir() {
+  if [ -z "$HELM_RENDER_DIR" ] || [ "$HELM_RENDER_DIR" = "/" ]; then
+    echo "Error: HELM_RENDER_DIR must not be empty or /" >&2
+    exit 1
+  fi
+
+  rm -rf "$HELM_RENDER_DIR"
+  mkdir -p "$HELM_RENDER_DIR"
+}
+
+extract_kind_names() {
+  local file="$1"
+  local kind="$2"
+
+  awk -v kind="$kind" '
+    /^kind: / {
+      current_kind = $2
+      in_metadata = 0
+      next
+    }
+    /^metadata:$/ {
+      in_metadata = 1
+      next
+    }
+    in_metadata && /^  name: / {
+      if (current_kind == kind) {
+        print $2
+      }
+      in_metadata = 0
+    }
+  ' "$file"
+}
+
+validate() {
+  require_command helm
+
+  local chart_version
+  local chart_app_version
+  local server_image_tag
+  local ui_image_tag
+
+  chart_version="$(extract_chart_field version)"
+  chart_app_version="$(extract_chart_field appVersion)"
+  server_image_tag="$(extract_values_tag server)"
+  ui_image_tag="$(extract_values_tag ui)"
+
+  prepare_render_dir
+
+  helm lint --strict "$CHART_DIR"
+
+  helm template iggy "$CHART_DIR" > "$HELM_RENDER_DIR/default.yaml"
+  test "$(grep -c '^kind: Deployment$' "$HELM_RENDER_DIR/default.yaml")" -eq 2
+  test "$(grep -c '^kind: Service$' "$HELM_RENDER_DIR/default.yaml")" -eq 2
+  test "$(grep -c '^kind: ServiceAccount$' "$HELM_RENDER_DIR/default.yaml")" 
-eq 1
+  test "$(grep -c '^kind: Secret$' "$HELM_RENDER_DIR/default.yaml")" -eq 1
+  grep -q "helm.sh/chart: iggy-${chart_version}" 
"$HELM_RENDER_DIR/default.yaml"
+  grep -q "helm.sh/chart: iggy-ui-${chart_version}" 
"$HELM_RENDER_DIR/default.yaml"
+  grep -q "app.kubernetes.io/version: \"${chart_app_version}\"" 
"$HELM_RENDER_DIR/default.yaml"
+  grep -q "image: \"apache/iggy:${server_image_tag}\"" 
"$HELM_RENDER_DIR/default.yaml"
+  grep -q "image: \"apache/iggy-web-ui:${ui_image_tag}\"" 
"$HELM_RENDER_DIR/default.yaml"
+
+  helm template iggy "$CHART_DIR" \
+    --set server.persistence.enabled=true \
+    --set autoscaling.enabled=true \
+    --set autoscaling.targetCPUUtilizationPercentage=80 \
+    --set server.ingress.enabled=true \
+    --set ui.ingress.enabled=true \
+    --set server.serviceMonitor.enabled=true \
+    > "$HELM_RENDER_DIR/all-features.yaml"
+  grep -q '^kind: PersistentVolumeClaim$' "$HELM_RENDER_DIR/all-features.yaml"
+  grep -q '^kind: HorizontalPodAutoscaler$' 
"$HELM_RENDER_DIR/all-features.yaml"
+  test "$(grep -c '^kind: Ingress$' "$HELM_RENDER_DIR/all-features.yaml")" -eq 
2
+  test "$(extract_kind_names "$HELM_RENDER_DIR/all-features.yaml" Ingress | 
sort -u | wc -l | tr -d ' ')" -eq 2
+  extract_kind_names "$HELM_RENDER_DIR/all-features.yaml" Ingress | grep -qx 
'iggy'
+  extract_kind_names "$HELM_RENDER_DIR/all-features.yaml" Ingress | grep -qx 
'iggy-ui'
+  grep -q '^kind: ServiceMonitor$' "$HELM_RENDER_DIR/all-features.yaml"
+
+  helm template iggy "$CHART_DIR" \
+    --kube-version 1.18.0 \
+    --api-versions networking.k8s.io/v1beta1 \
+    --api-versions autoscaling/v2beta2 \
+    --set autoscaling.enabled=true \
+    --set autoscaling.targetCPUUtilizationPercentage=80 \
+    --set server.ingress.enabled=true \
+    --set ui.ingress.enabled=true \
+    > "$HELM_RENDER_DIR/legacy-k8s-1.18.yaml"
+  test "$(grep -c '^apiVersion: networking.k8s.io/v1beta1$' 
"$HELM_RENDER_DIR/legacy-k8s-1.18.yaml")" -eq 2
+  grep -q '^apiVersion: autoscaling/v2beta2$' 
"$HELM_RENDER_DIR/legacy-k8s-1.18.yaml"
+
+  helm template iggy "$CHART_DIR" --set ui.enabled=false > 
"$HELM_RENDER_DIR/server-only.yaml"
+  test "$(grep -c '^kind: Deployment$' "$HELM_RENDER_DIR/server-only.yaml")" 
-eq 1
+  test "$(grep -c '^kind: Service$' "$HELM_RENDER_DIR/server-only.yaml")" -eq 1
+
+  helm template iggy "$CHART_DIR" --set server.enabled=false > 
"$HELM_RENDER_DIR/ui-only.yaml"
+  test "$(grep -c '^kind: Deployment$' "$HELM_RENDER_DIR/ui-only.yaml")" -eq 1
+  test "$(grep -c '^kind: Service$' "$HELM_RENDER_DIR/ui-only.yaml")" -eq 1
+
+  helm template iggy "$CHART_DIR" \
+    --set server.users.root.createSecret=false \
+    --set server.users.root.existingSecret.name=supersecret \
+    > "$HELM_RENDER_DIR/existing-secret.yaml"
+  if grep -q 'root-credentials' "$HELM_RENDER_DIR/existing-secret.yaml"; then
+    echo "Error: existing-secret render should not include generated root 
credentials" >&2
+    exit 1
+  fi
+  grep -q 'name: supersecret' "$HELM_RENDER_DIR/existing-secret.yaml"
+}
+
+smoke() {
+  local cleanup_after_success="$1"
+  require_command helm
+  require_command kubectl
+  require_command curl
+
+  local ui_image_tag
+  local server_ping_status
+  local ui_healthz_status
+  local leftover_resources
+  local helm_status
+  local smoke_values_file
+
+  mkdir -p "$HELM_SMOKE_REPORT_DIR"
+
+  if ! helm status "$HELM_SMOKE_RELEASE" -n "$HELM_SMOKE_NAMESPACE" >/dev/null 
2>&1; then
+    leftover_resources="$(
+      kubectl -n "$HELM_SMOKE_NAMESPACE" get 
deployment,service,ingress,secret,serviceaccount \
+        -l "app.kubernetes.io/instance=${HELM_SMOKE_RELEASE}" \
+        -o name 2>/dev/null || true
+    )"
+    if [ -n "$leftover_resources" ]; then
+      cat >&2 <<EOF
+Found leftover resources for a failed Helm smoke install in namespace 
'${HELM_SMOKE_NAMESPACE}'.
+Run 'scripts/ci/test-helm.sh cleanup-smoke' once, then rerun the smoke test.
+EOF
+      printf '%s\n' "$leftover_resources" >&2
+      exit 1
+    fi
+  fi
+
+  ui_image_tag="$(extract_values_tag ui)"
+
+  echo "$ui_image_tag" > "$HELM_SMOKE_REPORT_DIR/ui-image-tag.txt"
+
+  smoke_values_file="$(mktemp 
"${TMPDIR:-/tmp}/iggy-helm-smoke-values.XXXXXX.yaml")"
+
+  cat > "$smoke_values_file" <<EOF
+server:
+  ingress:
+    enabled: true
+    className: ${HELM_SMOKE_INGRESS_CLASS}
+    hosts:
+      - host: ${HELM_SMOKE_SERVER_HOST}
+        paths:
+          - path: /
+            pathType: Prefix
+  env:
+    - name: RUST_LOG
+      value: info
+    - name: IGGY_HTTP_ADDRESS
+      value: "0.0.0.0:3000"
+    - name: IGGY_TCP_ADDRESS
+      value: "0.0.0.0:8090"
+    - name: IGGY_QUIC_ADDRESS
+      value: "0.0.0.0:8080"
+    - name: IGGY_WEBSOCKET_ADDRESS
+      value: "0.0.0.0:8092"
+    - name: IGGY_SYSTEM_SHARDING_CPU_ALLOCATION
+      value: "${HELM_SMOKE_SERVER_CPU_ALLOCATION}"
+ui:
+  ingress:
+    enabled: true
+    className: ${HELM_SMOKE_INGRESS_CLASS}
+    hosts:
+      - host: ${HELM_SMOKE_UI_HOST}
+        paths:
+          - path: /
+            pathType: Prefix
+  image:
+    tag: "${ui_image_tag}"
+EOF
+
+  helm_status=0
+  if helm upgrade --install "$HELM_SMOKE_RELEASE" "$CHART_DIR" \
+    --atomic \
+    --namespace "$HELM_SMOKE_NAMESPACE" \
+    --create-namespace \
+    --wait \
+    --timeout "$HELM_SMOKE_TIMEOUT" \
+    -f "$smoke_values_file"; then
+    helm_status=0
+  else
+    helm_status=$?
+  fi
+
+  rm -f "$smoke_values_file"
+
+  if [ "$helm_status" -ne 0 ]; then
+    return "$helm_status"
+  fi
+
+  kubectl version --client=true > "$HELM_SMOKE_REPORT_DIR/kubectl-version.txt"
+  kubectl -n "$HELM_SMOKE_NAMESPACE" rollout status 
"deployment/$HELM_SMOKE_RELEASE" --timeout="$HELM_SMOKE_TIMEOUT"
+  kubectl -n "$HELM_SMOKE_NAMESPACE" rollout status 
"deployment/${HELM_SMOKE_RELEASE}-ui" --timeout="$HELM_SMOKE_TIMEOUT"
+  kubectl -n "$HELM_SMOKE_NAMESPACE" get pods,svc,ingress > 
"$HELM_SMOKE_REPORT_DIR/resources.txt"
+
+  for _ in $(seq 1 30); do
+    server_ping_status="$(
+      curl -sS -o "$HELM_SMOKE_REPORT_DIR/ping.txt" -w '%{http_code}' \
+        -H "Host: ${HELM_SMOKE_SERVER_HOST}" \
+        http://127.0.0.1/ping || true
+    )"
+    if [ "$server_ping_status" = "200" ] && grep -qx 'pong' 
"$HELM_SMOKE_REPORT_DIR/ping.txt"; then
+      break
+    fi
+    sleep 2
+  done
+
+  test "$server_ping_status" = "200"
+  grep -qx 'pong' "$HELM_SMOKE_REPORT_DIR/ping.txt"
+
+  for _ in $(seq 1 30); do
+    ui_healthz_status="$(
+      curl -sS -o "$HELM_SMOKE_REPORT_DIR/ui-healthz.txt" -w '%{http_code}' \
+        -H "Host: ${HELM_SMOKE_UI_HOST}" \
+        http://127.0.0.1/healthz || true
+    )"
+    if [ "$ui_healthz_status" = "200" ]; then
+      break
+    fi
+    sleep 2
+  done
+
+  test "$ui_healthz_status" = "200"
+
+  if [ "$cleanup_after_success" = true ]; then
+    cleanup_smoke
+  fi
+}
+
+cleanup_smoke() {
+  require_command kubectl
+
+  if command -v helm >/dev/null 2>&1; then
+    helm uninstall "$HELM_SMOKE_RELEASE" -n "$HELM_SMOKE_NAMESPACE" >/dev/null 
2>&1 || true
+  fi
+
+  kubectl delete namespace "$HELM_SMOKE_NAMESPACE" --ignore-not-found=true 
--wait=true >/dev/null 2>&1 || true
+}
+
+collect_smoke_diagnostics() {
+  set +e
+
+  mkdir -p "$HELM_SMOKE_REPORT_DIR"
+
+  helm list -A > "$HELM_SMOKE_REPORT_DIR/helm-list.txt" 2>&1 || true
+  kubectl get namespaces > "$HELM_SMOKE_REPORT_DIR/namespaces.txt" 2>&1 || true
+  kubectl -n ingress-nginx get all > 
"$HELM_SMOKE_REPORT_DIR/ingress-nginx.txt" 2>&1 || true
+  kubectl -n "$HELM_SMOKE_NAMESPACE" get all > 
"$HELM_SMOKE_REPORT_DIR/get-all.txt" 2>&1 || true
+  kubectl -n "$HELM_SMOKE_NAMESPACE" get ingress > 
"$HELM_SMOKE_REPORT_DIR/ingresses.txt" 2>&1 || true
+  kubectl -n "$HELM_SMOKE_NAMESPACE" describe "deployment/$HELM_SMOKE_RELEASE" 
> "$HELM_SMOKE_REPORT_DIR/describe-deployment.txt" 2>&1 || true
+  kubectl -n "$HELM_SMOKE_NAMESPACE" describe 
"deployment/${HELM_SMOKE_RELEASE}-ui" > 
"$HELM_SMOKE_REPORT_DIR/describe-ui-deployment.txt" 2>&1 || true
+  kubectl -n "$HELM_SMOKE_NAMESPACE" describe pods > 
"$HELM_SMOKE_REPORT_DIR/describe-pods.txt" 2>&1 || true
+  kubectl -n "$HELM_SMOKE_NAMESPACE" logs "deployment/$HELM_SMOKE_RELEASE" 
--all-containers=true > "$HELM_SMOKE_REPORT_DIR/server.log" 2>&1 || true
+  kubectl -n "$HELM_SMOKE_NAMESPACE" logs 
"deployment/${HELM_SMOKE_RELEASE}-ui" --all-containers=true > 
"$HELM_SMOKE_REPORT_DIR/ui.log" 2>&1 || true
+  kubectl -n ingress-nginx logs deployment/ingress-nginx-controller 
--all-containers=true > "$HELM_SMOKE_REPORT_DIR/ingress-nginx-controller.log" 
2>&1 || true
+
+  if command -v kind >/dev/null 2>&1; then
+    kind export logs "$HELM_SMOKE_REPORT_DIR/kind-logs" --name 
"$HELM_SMOKE_KIND_NAME" >/dev/null 2>&1 || true
+  fi
+}
+
+main() {
+  local command
+  local smoke_cleanup=false
+
+  if [ $# -lt 1 ]; then
+    usage
+    exit 1
+  fi
+
+  command="$1"
+  shift
+
+  while [ $# -gt 0 ]; do
+    case "$1" in
+      --cleanup)
+        smoke_cleanup=true
+        ;;
+      --help|-h|help)
+        usage
+        exit 0
+        ;;
+      *)
+        echo "Error: unknown option '$1'" >&2
+        usage
+        exit 1
+        ;;
+    esac
+    shift
+  done
+
+  case "$command" in
+    validate)
+      if [ "$smoke_cleanup" = true ]; then
+        echo "Error: --cleanup is only supported with the smoke command" >&2
+        exit 1
+      fi
+      validate
+      ;;
+    smoke)
+      smoke "$smoke_cleanup"
+      ;;
+    cleanup-smoke)
+      if [ "$smoke_cleanup" = true ]; then
+        echo "Error: --cleanup is only supported with the smoke command" >&2
+        exit 1
+      fi
+      cleanup_smoke
+      ;;
+    collect-smoke-diagnostics)
+      if [ "$smoke_cleanup" = true ]; then
+        echo "Error: --cleanup is only supported with the smoke command" >&2
+        exit 1
+      fi
+      collect_smoke_diagnostics
+      ;;
+    --help|-h|help)
+      usage
+      ;;
+    *)
+      echo "Error: unknown command '$command'" >&2
+      usage
+      exit 1
+      ;;
+  esac
+}
+
+main "$@"


Reply via email to