diff --git a/.github/workflows/chart-e2e.yml b/.github/workflows/chart-e2e.yml new file mode 100644 index 0000000..4a72d84 --- /dev/null +++ b/.github/workflows/chart-e2e.yml @@ -0,0 +1,63 @@ +# SPDX-License-Identifier: MIT OR Apache-2.0 +# +# Chart end-to-end gate: spin up a kind cluster, install the chart as a single STANDALONE +# node, wait for it to become Ready, and run `helm test` -- the RESP PING connectivity smoke +# check (templates/tests/test-connection.yaml) -- against the real published image. This +# complements deploy-lint (which statically renders + kubeconform-validates the chart) with a +# LIVE install proving the chart actually deploys and serves. Runs only when the chart changes. +name: chart-e2e + +on: + pull_request: + paths: + - "deploy/helm/ironcache/**" + - ".github/workflows/chart-e2e.yml" + +permissions: + contents: read + +env: + HELM_VERSION: "v3.15.4" + +jobs: + install-test: + name: helm install + helm test (kind) + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - name: Create a kind cluster + uses: helm/kind-action@v1.10.0 + with: + # Pin the k8s version to 1.30 so the run is deterministic AND exercises the native + # SleepAction preStop path (beta/default-on from 1.30; the chart gates it on >=1.30). + # The action's default is 1.29, where SleepAction is alpha-off -- a moving, wrong target. + version: v0.23.0 + node_image: kindest/node:v1.30.0 + - uses: azure/setup-helm@v4 + with: + version: ${{ env.HELM_VERSION }} + - name: Install (standalone) + wait + helm test + run: | + set -euo pipefail + kubectl create namespace ic-test + # A single STANDALONE node: fast + deterministic (no 3-node quorum formation, which is + # slow/flaky in CI). This exercises install -> readiness (--wait) -> the RESP PING helm + # test against the real, public, multi-arch ghcr.io/elares/ironcache: image. + helm install ic deploy/helm/ironcache -n ic-test \ + --set replicas=1 --set cluster.enabled=false \ + --wait --timeout 5m + kubectl -n ic-test get statefulset,pod + # `helm test` creates the PING Pod, waits for it, and fails the command if it exits + # non-zero. NOT `--logs`: the test hook's `hook-succeeded` delete policy removes the Pod + # on success, and `helm test --logs` then races it ("pods not found") -- a false failure + # even though the PING passed. The Diagnostics-on-failure step below dumps logs when a + # real failure leaves the Pod in place (hook-succeeded does not delete a FAILED Pod). + helm test ic -n ic-test + - name: Diagnostics on failure + if: failure() + run: | + kubectl -n ic-test get all || true + kubectl -n ic-test describe pod -l app.kubernetes.io/instance=ic || true + kubectl -n ic-test logs -l app.kubernetes.io/instance=ic --tail=200 || true + kubectl -n ic-test get events --sort-by=.lastTimestamp || true diff --git a/.github/workflows/deploy-lint.yml b/.github/workflows/deploy-lint.yml index b1b754e..4b5f870 100644 --- a/.github/workflows/deploy-lint.yml +++ b/.github/workflows/deploy-lint.yml @@ -169,6 +169,22 @@ jobs: echo "OK (accepted): fullnameOverride" echo "::endgroup::" + - name: preStop SleepAction version gate (>=1.30 only) + run: | + set -euo pipefail + # SleepAction is alpha-off in 1.29 and only beta/default-on from 1.30. The chart MUST + # omit the preStop `sleep` handler below 1.30 (an empty preStop is an INVALID pod that + # the API server rejects -- a real install break, caught live once by chart-e2e on a + # 1.29 kind node). Guard the gate boundary so it cannot regress. + r129="$(helm template ic deploy/helm/ironcache --kube-version 1.29.0)" + r130="$(helm template ic deploy/helm/ironcache --kube-version 1.30.0)" + if grep -q 'sleep:' <<< "$r129"; then + echo "ERROR: preStop sleep rendered on k8s 1.29 (alpha-off) -- would create an invalid pod"; exit 1 + fi + grep -q 'sleep:' <<< "$r130" \ + || { echo "ERROR: preStop sleep NOT rendered on k8s 1.30 (should be beta/default-on)"; exit 1; } + echo "OK: preStop omitted on 1.29, rendered on 1.30" + k8s: name: kubeconform (raw manifests) runs-on: ubuntu-latest diff --git a/deploy/helm/ironcache/templates/statefulset.yaml b/deploy/helm/ironcache/templates/statefulset.yaml index 1e8ce0c..ae92e35 100644 --- a/deploy/helm/ironcache/templates/statefulset.yaml +++ b/deploy/helm/ironcache/templates/statefulset.yaml @@ -203,15 +203,16 @@ spec: periodSeconds: 10 timeoutSeconds: 3 failureThreshold: 6 - {{- if and (gt (int .Values.preStop.sleepSeconds) 0) (semverCompare ">=1.29-0" .Capabilities.KubeVersion.Version) }} - # preStop lame-duck (native SleepAction, beta/default-on in k8s 1.29+): let the - # Service endpoint deprogram before the process drains, so a rolling update does not - # reset in-flight clients. The distroless image is shell-free, so exec `sleep` is NOT - # usable here. Gated on the cluster version so the chart SELF-PROTECTS on k8s < 1.29 - # (where the API server rejects the unknown `sleep` field) -- there it silently - # degrades to the server's /readyz-returns-503-on-drain fallback. Under `helm template` - # (no live cluster) .Capabilities.KubeVersion is the Helm built-in version, so this - # still renders + is kubeconform-validated in CI. + {{- if and (gt (int .Values.preStop.sleepSeconds) 0) (semverCompare ">=1.30-0" .Capabilities.KubeVersion.Version) }} + # preStop lame-duck (native SleepAction): let the Service endpoint deprogram before the + # process drains, so a rolling update does not reset in-flight clients. The distroless + # image is shell-free, so exec `sleep` is NOT usable here. SleepAction is alpha in 1.29 + # (feature gate OFF by default) and only beta/default-on from 1.30, so the gate is + # `>=1.30-0` -- on 1.29 (and earlier) the API server rejects the `sleep` handler as + # unknown, leaving an INVALID empty preStop. Below the floor the chart SELF-PROTECTS by + # omitting the block and degrading to the server's /readyz-returns-503-on-drain fallback. + # Under `helm template` (no live cluster) .Capabilities.KubeVersion is the Helm built-in + # version, so this still renders + is kubeconform-validated in CI. lifecycle: preStop: sleep: diff --git a/deploy/helm/ironcache/templates/tests/test-connection.yaml b/deploy/helm/ironcache/templates/tests/test-connection.yaml new file mode 100644 index 0000000..aa2f7cb --- /dev/null +++ b/deploy/helm/ironcache/templates/tests/test-connection.yaml @@ -0,0 +1,40 @@ +{{- /* SPDX-License-Identifier: MIT OR Apache-2.0 */ -}} +{{- if not .Values.auth.enabled }} +# `helm test` connectivity smoke check: PING the client Service using the image's built-in +# RESP client (`ironcache cli`, PING-only, exits 0 on +PONG). Runs only on +# `helm test `, never on install (the helm.sh/hook: test annotation). Rendered only +# when auth is off -- the PING subcommand has no password flag; with auth enabled, run your +# own authenticated check. Uses the same distroless image (no shell needed). +apiVersion: v1 +kind: Pod +metadata: + name: {{ include "ironcache.fullname" . }}-test-connection + namespace: {{ .Release.Namespace }} + labels: + {{- include "ironcache.labels" . | nindent 4 }} + annotations: + "helm.sh/hook": test + "helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded +spec: + restartPolicy: Never + automountServiceAccountToken: false + securityContext: + runAsNonRoot: true + runAsUser: 65532 + runAsGroup: 65532 + seccompProfile: + type: RuntimeDefault + containers: + - name: ping + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + securityContext: + {{- toYaml .Values.containerSecurityContext | nindent 8 }} + command: + - /usr/local/bin/ironcache + - cli + - -h + - {{ include "ironcache.fullname" . }}.{{ .Release.Namespace }}.svc.cluster.local + - -p + - {{ int .Values.service.clientPort | quote }} +{{- end }} diff --git a/deploy/helm/ironcache/values.yaml b/deploy/helm/ironcache/values.yaml index c584495..cf5bc0a 100644 --- a/deploy/helm/ironcache/values.yaml +++ b/deploy/helm/ironcache/values.yaml @@ -312,11 +312,11 @@ startupProbe: # Service endpoint deprograms (kube-proxy stops routing NEW clients here) BEFORE the # process is drained -- avoiding client-visible resets on every rolling update. Uses the # native SleepAction (alpha 1.29, beta/default-on 1.30, GA 1.34); the distroless image is -# SHELL-FREE, so an exec `sleep` hook would silently fail. The chart auto-gates this block -# on the cluster version (semverCompare >= 1.29), so on OLDER clusters it is simply omitted -# and the server's readiness-fail-on-drain takes over (/readyz returns 503 the moment drain -# starts) -- no broken install. Set 0 to disable it explicitly. Keep it comfortably under -# terminationGracePeriodSeconds. +# SHELL-FREE, so an exec `sleep` hook would silently fail. The chart auto-gates this block on +# the cluster version (semverCompare >= 1.30, since 1.29 is alpha-off and the API server would +# reject the `sleep` handler there), so on OLDER clusters it is simply omitted and the server's +# readiness-fail-on-drain takes over (/readyz returns 503 the moment drain starts) -- no broken +# install. Set 0 to disable it explicitly. Keep it comfortably under terminationGracePeriodSeconds. preStop: sleepSeconds: 10 diff --git a/deploy/k8s/ironcache.yaml b/deploy/k8s/ironcache.yaml index 84f6186..3557378 100644 --- a/deploy/k8s/ironcache.yaml +++ b/deploy/k8s/ironcache.yaml @@ -287,8 +287,12 @@ spec: periodSeconds: 10 timeoutSeconds: 3 failureThreshold: 6 - # preStop lame-duck (native SleepAction, k8s 1.29+): endpoint deprograms before - # drain. The distroless image is shell-free, so exec `sleep` is NOT usable. + # preStop lame-duck (native SleepAction): endpoint deprograms before drain. The + # distroless image is shell-free, so exec `sleep` is NOT usable. REQUIRES k8s >= 1.30 + # (SleepAction is beta/default-on there; on 1.29 it is alpha-off and the API server + # rejects this handler -- DELETE this lifecycle block on older clusters). The Helm + # chart auto-gates this by cluster version; this static manifest cannot, so it is pinned + # to the >= 1.30 assumption. lifecycle: preStop: sleep: