Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
@@ -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
50 changes: 50 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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"
52 changes: 52 additions & 0 deletions .github/workflows/smoke.yml
Original file line number Diff line number Diff line change
@@ -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
23 changes: 23 additions & 0 deletions ci/externalsecrets-values.yaml
Original file line number Diff line number Diff line change
@@ -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
52 changes: 52 additions & 0 deletions ci/full-values.yaml
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions ci/minimal-values.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Minimal: pure defaults from values.yaml. Asserts the chart installs with no overrides.
16 changes: 16 additions & 0 deletions templates/_helpers.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
7 changes: 5 additions & 2 deletions templates/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions templates/pdb.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
53 changes: 53 additions & 0 deletions templates/tests/test-connection.yaml
Original file line number Diff line number Diff line change
@@ -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
6 changes: 5 additions & 1 deletion values.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
{
Expand Down
9 changes: 9 additions & 0 deletions values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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: []

Expand Down
Loading