blob: 370b2f00b29d4e6d727083774ea7b46a54250b1a [file]
#!/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 with Gateway API CRDs and Envoy Gateway installed.
- 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_GATEWAY_NAMESPACE="${HELM_SMOKE_GATEWAY_NAMESPACE:-envoy-gateway-system}"
HELM_SMOKE_GATEWAY_NAME="${HELM_SMOKE_GATEWAY_NAME:-iggy-smoke-gateway}"
HELM_SMOKE_GATEWAY_PF_PORT="${HELM_SMOKE_GATEWAY_PF_PORT:-8080}"
HELM_SMOKE_KIND_NAME="${HELM_SMOKE_KIND_NAME:-iggy-helm-smoke}"
HELM_SMOKE_SERVER_CPU_ALLOCATION="${HELM_SMOKE_SERVER_CPU_ALLOCATION:-1}"
# HELM_SMOKE_GATEWAY_NAMESPACE and HELM_SMOKE_GATEWAY_NAME must match the
# defaults in scripts/ci/setup-helm-smoke-cluster.sh - the HTTPRoute
# parentRef below targets the Gateway by ns + name and overriding one
# without the other silently breaks route attach.
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_yamllint() {
if ! command -v yamllint >/dev/null 2>&1; then
echo "Warning: yamllint not found, skipping YAML lint check" >&2
return 0
fi
echo "Running yamllint on Helm chart..."
yamllint -c "$CHART_DIR/.yamllint.yml" "$CHART_DIR"
}
validate_helmfmt() {
if ! command -v helmfmt >/dev/null 2>&1; then
echo "Warning: helmfmt not found, skipping Helm template format check" >&2
return 0
fi
echo "Checking Helm template formatting..."
if ! helmfmt --check "$CHART_DIR"; then
echo "Error: Helm templates are not formatted. Run 'helmfmt $CHART_DIR' locally and commit the changes." >&2
exit 1
fi
}
validate_helm_docs() {
if ! command -v helm-docs >/dev/null 2>&1; then
echo "Warning: helm-docs not found, skipping README drift check" >&2
return 0
fi
local readme_before
local readme_after
readme_before="$(cat "$CHART_DIR/README.md")"
helm-docs --chart-search-root "$CHART_DIR" --template-files README.md.gotmpl
readme_after="$(cat "$CHART_DIR/README.md")"
if [ "$readme_before" != "$readme_after" ]; then
echo "Error: README.md is out of sync with values.yaml. Run 'helm-docs' locally and commit the updated README." >&2
exit 1
fi
}
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"
validate_yamllint
validate_helmfmt
validate_helm_docs
}
# PID of the kubectl port-forward process started by smoke().
# Stored at script scope so the EXIT trap can kill it on any code path.
HELM_SMOKE_GW_PF_PID=""
# Temp values file that may need cleanup if smoke() aborts mid-flight.
HELM_SMOKE_VALUES_FILE=""
cleanup_smoke_state() {
if [ -n "${HELM_SMOKE_GW_PF_PID:-}" ]; then
kill "$HELM_SMOKE_GW_PF_PID" 2>/dev/null || true
HELM_SMOKE_GW_PF_PID=""
fi
if [ -n "${HELM_SMOKE_VALUES_FILE:-}" ]; then
rm -f "$HELM_SMOKE_VALUES_FILE"
HELM_SMOKE_VALUES_FILE=""
fi
}
# Look up the Service that fronts the Envoy Gateway. Retries briefly to
# tolerate the gap between Gateway "Programmed" and the owning Service
# appearing - kubectl jsonpath on an empty `items` array exits non-zero,
# so we also swallow stderr to avoid leaking "array index out of bounds".
find_gateway_service() {
local svc_selector
local svc_name
local _
svc_selector="gateway.envoyproxy.io/owning-gateway-name=${HELM_SMOKE_GATEWAY_NAME},gateway.envoyproxy.io/owning-gateway-namespace=${HELM_SMOKE_GATEWAY_NAMESPACE}"
for _ in $(seq 1 15); do
svc_name="$(kubectl get svc \
--namespace "$HELM_SMOKE_GATEWAY_NAMESPACE" \
--selector "$svc_selector" \
-o jsonpath='{.items[0].metadata.name}' 2>/dev/null || true)"
if [ -n "$svc_name" ]; then
printf '%s\n' "$svc_name"
return 0
fi
sleep 1
done
echo "Error: could not find Envoy Gateway proxy service after 15 attempts" >&2
return 1
}
# Start kubectl port-forward in the caller's shell (not a subshell) so the
# PID is visible to the EXIT trap. Waits up to ~45 s for the tunnel to
# accept connections (15 iterations * (curl --max-time 2 + sleep 1)).
# curl returns 0 on any HTTP response (even 4xx/5xx); non-zero means
# the tunnel isn't ready yet.
start_gateway_port_forward() {
local svc_name="$1"
local ready=false
local _
kubectl port-forward "svc/${svc_name}" "${HELM_SMOKE_GATEWAY_PF_PORT}:80" \
--namespace "$HELM_SMOKE_GATEWAY_NAMESPACE" \
>/dev/null 2>&1 &
HELM_SMOKE_GW_PF_PID=$!
for _ in $(seq 1 15); do
if curl -s --connect-timeout 1 --max-time 2 \
-o /dev/null "http://127.0.0.1:${HELM_SMOKE_GATEWAY_PF_PORT}" 2>/dev/null; then
ready=true
break
fi
if ! kill -0 "$HELM_SMOKE_GW_PF_PID" 2>/dev/null; then
echo "Error: gateway port-forward process exited unexpectedly" >&2
HELM_SMOKE_GW_PF_PID=""
return 1
fi
sleep 1
done
if [ "$ready" = false ]; then
echo "Error: gateway port-forward did not become ready within 45 s" >&2
return 1
fi
}
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 gateway_svc
local gateway_url
trap cleanup_smoke_state EXIT
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,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"
HELM_SMOKE_VALUES_FILE="$(mktemp "${TMPDIR:-/tmp}/iggy-helm-smoke-values.XXXXXX.yaml")"
cat > "$HELM_SMOKE_VALUES_FILE" <<EOF
server:
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:
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 "$HELM_SMOKE_VALUES_FILE"; then
helm_status=0
else
helm_status=$?
fi
rm -f "$HELM_SMOKE_VALUES_FILE"
HELM_SMOKE_VALUES_FILE=""
if [ "$helm_status" -ne 0 ]; then
return "$helm_status"
fi
kubectl apply -f - <<EOF
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: iggy-server
namespace: ${HELM_SMOKE_NAMESPACE}
spec:
parentRefs:
- name: ${HELM_SMOKE_GATEWAY_NAME}
namespace: ${HELM_SMOKE_GATEWAY_NAMESPACE}
hostnames:
- "${HELM_SMOKE_SERVER_HOST}"
rules:
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: ${HELM_SMOKE_RELEASE}
port: 3000
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: iggy-ui
namespace: ${HELM_SMOKE_NAMESPACE}
spec:
parentRefs:
- name: ${HELM_SMOKE_GATEWAY_NAME}
namespace: ${HELM_SMOKE_GATEWAY_NAMESPACE}
hostnames:
- "${HELM_SMOKE_UI_HOST}"
rules:
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: ${HELM_SMOKE_RELEASE}-ui
port: 3050
EOF
gateway_svc="$(find_gateway_service)"
start_gateway_port_forward "$gateway_svc"
gateway_url="http://127.0.0.1:${HELM_SMOKE_GATEWAY_PF_PORT}"
kubectl version --client=true > "$HELM_SMOKE_REPORT_DIR/kubectl-version.txt"
kubectl -n "$HELM_SMOKE_NAMESPACE" get pods,svc,httproutes > "$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}" \
"${gateway_url}/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}" \
"${gateway_url}/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 "$HELM_SMOKE_GATEWAY_NAMESPACE" get all > "$HELM_SMOKE_REPORT_DIR/envoy-gateway.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 httproutes > "$HELM_SMOKE_REPORT_DIR/httproutes.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 "$HELM_SMOKE_GATEWAY_NAMESPACE" logs deployment/envoy-gateway --all-containers=true > "$HELM_SMOKE_REPORT_DIR/envoy-gateway.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 "$@"