diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..531c835 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,79 @@ +name: lint + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + lint: + name: helm lint + kubeconform + kube-linter + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Helm + uses: azure/setup-helm@v4 + with: + version: v3.16.3 + + - name: Install kubeconform + run: | + set -euo pipefail + KUBECONFORM_VERSION=v0.6.7 + curl -sSLo kubeconform.tar.gz \ + "https://github.com/yannh/kubeconform/releases/download/${KUBECONFORM_VERSION}/kubeconform-linux-amd64.tar.gz" + tar -xzf kubeconform.tar.gz + sudo mv kubeconform /usr/local/bin/ + kubeconform -v + + - name: Install kube-linter + run: | + set -euo pipefail + KUBE_LINTER_VERSION=v0.7.2 + curl -sSLo kube-linter.tar.gz \ + "https://github.com/stackrox/kube-linter/releases/download/${KUBE_LINTER_VERSION}/kube-linter-linux.tar.gz" + tar -xzf kube-linter.tar.gz + sudo mv kube-linter /usr/local/bin/ + kube-linter version + + - name: Helm lint (default values) + run: helm lint . + + - name: Helm lint (matrix values) + run: | + set -euo pipefail + for f in ci/*-values.yaml; do + echo "::group::helm lint $f" + helm lint . -f "$f" + echo "::endgroup::" + done + + - name: Helm template + kubeconform + run: | + set -euo pipefail + for f in ci/*-values.yaml; do + echo "::group::kubeconform $f" + # ESO CRDs are not part of the upstream Kubernetes schema set; skip them. + helm template release . -f "$f" \ + | kubeconform \ + -strict \ + -ignore-missing-schemas \ + -skip ExternalSecret,SecretStore,ClusterSecretStore \ + -kubernetes-version 1.28.0 \ + -summary + echo "::endgroup::" + done + + - name: kube-linter + run: | + set -euo pipefail + for f in ci/*-values.yaml; do + echo "::group::kube-linter $f" + helm template release . -f "$f" | kube-linter lint - + echo "::endgroup::" + done diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..8eac2c6 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,50 @@ +name: release + +on: + push: + tags: + - "v*" + +permissions: + contents: read + packages: write + +jobs: + release: + name: Package and publish chart to GHCR (OCI) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Helm + uses: azure/setup-helm@v4 + with: + version: v3.16.3 + + - name: Verify tag matches Chart.yaml version + run: | + set -euo pipefail + TAG="${GITHUB_REF_NAME#v}" + CHART_VERSION=$(awk '/^version:/ {print $2; exit}' Chart.yaml | tr -d '"') + if [ "$TAG" != "$CHART_VERSION" ]; then + echo "::error::Tag $GITHUB_REF_NAME (stripped: $TAG) does not match Chart.yaml version $CHART_VERSION" + exit 1 + fi + echo "Tag $GITHUB_REF_NAME matches Chart.yaml version $CHART_VERSION." + + - name: Log in to GHCR + run: | + set -euo pipefail + echo "${{ secrets.GITHUB_TOKEN }}" \ + | helm registry login ghcr.io --username "${{ github.actor }}" --password-stdin + + - name: Helm package + run: helm package . --destination .helm-package + + - name: Helm push to GHCR + run: | + set -euo pipefail + PKG=$(ls .helm-package/*.tgz | head -1) + OWNER_LC=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]') + helm push "$PKG" "oci://ghcr.io/${OWNER_LC}/charts" diff --git a/.github/workflows/smoke.yml b/.github/workflows/smoke.yml new file mode 100644 index 0000000..8a836dd --- /dev/null +++ b/.github/workflows/smoke.yml @@ -0,0 +1,52 @@ +name: smoke + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + smoke: + name: kind smoke (${{ matrix.values }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + values: [minimal-values, full-values] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Helm + uses: azure/setup-helm@v4 + with: + version: v3.16.3 + + - name: Create kind cluster + uses: helm/kind-action@v1 + with: + version: v0.24.0 + node_image: kindest/node:v1.28.13 + cluster_name: smoke + wait: 120s + + - name: Helm install + run: | + helm install http-echo . \ + -f ci/${{ matrix.values }}.yaml \ + --namespace smoke --create-namespace \ + --wait --timeout 5m + + - name: Helm test + run: helm test http-echo --namespace smoke --logs + + - name: Diagnostics on failure + if: failure() + run: | + kubectl -n smoke get all + kubectl -n smoke describe pods + kubectl -n smoke logs -l app.kubernetes.io/instance=http-echo --tail=200 || true + kubectl get events -n smoke --sort-by=.lastTimestamp || true diff --git a/ci/externalsecrets-values.yaml b/ci/externalsecrets-values.yaml new file mode 100644 index 0000000..459f93f --- /dev/null +++ b/ci/externalsecrets-values.yaml @@ -0,0 +1,23 @@ +# ExternalSecrets: lint-only path. Vanilla kind clusters lack the ESO CRDs, so the +# CI smoke job runs `helm lint` + `helm template | kubeconform -skip ExternalSecret` +# against this file rather than `helm install`. +externalSecrets: + enabled: true + refreshInterval: 30m + secretStoreRef: + kind: ClusterSecretStore + name: example-cluster-store + creationPolicy: Owner + remoteRefs: + # CI test fixture; not a credential. + - secretKey: DEMO_VALUE + remoteRef: + key: prod/http-echo + property: demo_value + dataFrom: + - extract: + key: prod/http-echo/bulk + +# Plain Secret stays disabled — the chart enforces mutual exclusion at template time. +secret: + enabled: false diff --git a/ci/full-values.yaml b/ci/full-values.yaml new file mode 100644 index 0000000..296455a --- /dev/null +++ b/ci/full-values.yaml @@ -0,0 +1,52 @@ +# Full: exercises every optional path the chart can render, except ESO (covered separately). +# Note: HPA + replicaCount=3 is intentional — HPA wins (the Deployment omits replicas:), +# but replicaCount is preserved as a sensible fallback if autoscaling is later disabled. +replicaCount: 3 + +ingress: + enabled: true + className: nginx + annotations: + nginx.ingress.kubernetes.io/rewrite-target: / + hosts: + - host: chart-example.local + paths: + - path: / + pathType: Prefix + +autoscaling: + enabled: true + minReplicas: 2 + maxReplicas: 5 + targetCPUUtilizationPercentage: 70 + +podDisruptionBudget: + enabled: true + maxUnavailable: 1 + +configMap: + enabled: true + data: + LOG_LEVEL: debug + GREETING: hello + +secret: + enabled: true + # CI test fixture; not a credential. + stringData: + DEMO_VALUE: ci-fake-value + +networkPolicy: + enabled: true + +serviceAccount: + create: true + annotations: + eks.amazonaws.com/role-arn: arn:aws:iam::000000000000:role/example + +podAnnotations: + example.com/scrape: "true" + +env: + - name: EXTRA_VAR + value: extra diff --git a/ci/minimal-values.yaml b/ci/minimal-values.yaml new file mode 100644 index 0000000..13efc3c --- /dev/null +++ b/ci/minimal-values.yaml @@ -0,0 +1 @@ +# Minimal: pure defaults from values.yaml. Asserts the chart installs with no overrides. diff --git a/templates/_helpers.tpl b/templates/_helpers.tpl index e384090..89fe379 100644 --- a/templates/_helpers.tpl +++ b/templates/_helpers.tpl @@ -63,3 +63,19 @@ Create the name of the service account to use. {{- default "default" .Values.serviceAccount.name }} {{- end }} {{- end }} + +{{/* +Default affinity used when .Values.affinity is empty. +Soft pod-anti-affinity by hostname so replicas spread across nodes. +Uses selectorLabels so it stays correct under nameOverride/fullnameOverride. +*/}} +{{- define "http-echo.defaultAffinity" -}} +podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - weight: 100 + podAffinityTerm: + labelSelector: + matchLabels: + {{- include "http-echo.selectorLabels" . | nindent 12 }} + topologyKey: kubernetes.io/hostname +{{- end }} diff --git a/templates/deployment.yaml b/templates/deployment.yaml index d96ed1c..fcb6660 100644 --- a/templates/deployment.yaml +++ b/templates/deployment.yaml @@ -111,9 +111,12 @@ spec: nodeSelector: {{- toYaml . | nindent 8 }} {{- end }} - {{- with .Values.affinity }} + {{- if .Values.affinity }} affinity: - {{- toYaml . | nindent 8 }} + {{- toYaml .Values.affinity | nindent 8 }} + {{- else }} + affinity: + {{- include "http-echo.defaultAffinity" . | nindent 8 }} {{- end }} {{- with .Values.tolerations }} tolerations: diff --git a/templates/pdb.yaml b/templates/pdb.yaml index fdd462a..f40a71c 100644 --- a/templates/pdb.yaml +++ b/templates/pdb.yaml @@ -19,6 +19,9 @@ spec: {{- else if not (kindIs "invalid" .Values.podDisruptionBudget.minAvailable) }} minAvailable: {{ .Values.podDisruptionBudget.minAvailable }} {{- end }} + {{- with .Values.podDisruptionBudget.unhealthyPodEvictionPolicy }} + unhealthyPodEvictionPolicy: {{ . }} + {{- end }} selector: matchLabels: {{- include "http-echo.selectorLabels" . | nindent 6 }} diff --git a/templates/tests/test-connection.yaml b/templates/tests/test-connection.yaml new file mode 100644 index 0000000..10a1afe --- /dev/null +++ b/templates/tests/test-connection.yaml @@ -0,0 +1,53 @@ +apiVersion: v1 +kind: Pod +metadata: + name: "{{ include "http-echo.fullname" . }}-test-connection" + labels: + helm.sh/chart: {{ include "http-echo.chart" . }} + app.kubernetes.io/managed-by: {{ .Release.Service }} + {{- if .Chart.AppVersion }} + app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} + {{- end }} + # Deliberately NOT using selectorLabels — those would make this pod match + # the chart's NetworkPolicy podSelector, blocking its own egress to the + # service. + app.kubernetes.io/name: {{ include "http-echo.name" . }}-test + app.kubernetes.io/instance: {{ .Release.Name }}-test + app.kubernetes.io/component: test + annotations: + "helm.sh/hook": test + # before-hook-creation only — keep the pod after success so `helm test --logs` + # can read its output. The next `helm test` run cleans up the previous pod. + "helm.sh/hook-delete-policy": before-hook-creation +spec: + restartPolicy: Never + securityContext: + runAsNonRoot: true + runAsUser: 65532 + runAsGroup: 65532 + seccompProfile: + type: RuntimeDefault + containers: + - name: wget + image: busybox:1.36 + command: ["/bin/sh", "-c"] + args: + - >- + wget -T 5 -t 2 --spider -S "http://{{ include "http-echo.fullname" . }}:{{ .Values.service.port }}/" 2>&1 + | grep -q "HTTP/.* 200" + resources: + requests: + cpu: 10m + memory: 16Mi + limits: + cpu: 50m + memory: 32Mi + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + runAsNonRoot: true + runAsUser: 65532 + capabilities: + drop: ["ALL"] + seccompProfile: + type: RuntimeDefault diff --git a/values.schema.json b/values.schema.json index 597e201..04abb7c 100644 --- a/values.schema.json +++ b/values.schema.json @@ -149,7 +149,11 @@ "properties": { "enabled": { "type": "boolean" }, "maxUnavailable": { "type": ["integer", "string", "null"] }, - "minAvailable": { "type": ["integer", "string", "null"] } + "minAvailable": { "type": ["integer", "string", "null"] }, + "unhealthyPodEvictionPolicy": { + "type": "string", + "enum": ["AlwaysAllow", "IfHealthyBudget"] + } }, "allOf": [ { diff --git a/values.yaml b/values.yaml index 2784b2b..b6d820a 100644 --- a/values.yaml +++ b/values.yaml @@ -145,6 +145,10 @@ podDisruptionBudget: maxUnavailable: 1 # -- Minimum available pods. Mutually exclusive with maxUnavailable. minAvailable: null + # -- Eviction policy for unhealthy pods. AlwaysAllow lets unhealthy pods be + # evicted regardless of the budget; IfHealthyBudget blocks until the budget + # is met. AlwaysAllow is the safe default for stateless services. + unhealthyPodEvictionPolicy: AlwaysAllow # Non-secret env. Disabled by default; flip to true and add `data:` entries to # mount a ConfigMap via envFrom on the container. Empty data + enabled=true @@ -210,6 +214,11 @@ networkPolicy: nodeSelector: {} tolerations: [] +# -- Pod affinity. When empty, the chart synthesizes a soft pod-anti-affinity +# rule (preferred, weight 100) that spreads replicas across nodes by hostname, +# using the chart's selectorLabels — so it stays correct under `nameOverride`. +# Set to `{ }` (the default) to use the synthesized rule, or provide a full +# affinity spec to override entirely. affinity: {} topologySpreadConstraints: []