test(helm): add reproducible upgrade smoke coverage

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-07-29 15:22:36 +08:00
parent 2854b26b5d
commit 0efc91b73f
8 changed files with 255 additions and 6 deletions

View file

@ -180,3 +180,28 @@ jobs:
with:
entrypoint: '/kubeconform'
args: "-strict -summary -output text -schema-location default -schema-location https://raw.githubusercontent.com/datreeio/CRDs-catalog/main/{{.Group}}/{{.ResourceKind}}_{{.ResourceAPIVersion}}.json charts/skillhub/rendered.yaml"
install-upgrade:
name: Install and Upgrade Smoke
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Set up Helm
uses: azure/setup-helm@v4
with:
version: v3.19.0
- name: Create Kubernetes cluster
uses: helm/kind-action@ef37e7f390d99f746eb8b610417061a60e82a6cc # v1.14.0
with:
cluster_name: skillhub-helm-smoke
wait: 120s
- name: Run install and upgrade smoke
run: bash charts/skillhub/tests/install-upgrade-smoke.sh

View file

@ -343,6 +343,7 @@ Basic Kubernetes manifests are available under [`deploy/k8s/`](./deploy/k8s):
- `backend-deployment.yaml`
- `frontend-deployment.yaml`
- `services.yaml`
- `ingress.yaml`
For a configurable deployment with bundled PostgreSQL and Redis dependencies,
use the Helm chart under [`charts/skillhub/`](./charts/skillhub):
@ -357,7 +358,6 @@ helm upgrade --install skillhub ./charts/skillhub \
See the [Helm chart guide](./charts/skillhub/README.md) for required secrets,
Ingress/TLS, external data services, storage migration, and upgrade constraints.
- `ingress.yaml`
Apply them after creating your own secret:

View file

@ -54,6 +54,16 @@ helm -n skillhub upgrade -i skillhub ./charts/skillhub \
> `ingress.tls[]`。旧的 `ingress.host``ingress.tls.enabled`
> `ingress.tls.secretName` 不再接受,升级前必须改成本文 Ingress 示例中的数组结构。
合并或发布前,可在一个空的测试 Kubernetes 集群中运行可重复的安装/升级 smoke
```bash
bash charts/skillhub/tests/install-upgrade-smoke.sh
```
脚本验证 `install -> Ready -> HTTP health -> upgrade -> Ready`,并确认 Secret 数据、
PVC UID 与绑定 PV 在升级前后保持不变。默认清理自己创建的 namespace设置
`KEEP_HELM_SMOKE=true` 可保留现场用于排查。
### 高可用模式
```bash
@ -134,6 +144,11 @@ redis:
可预测密码,而是在任何随机密码缺失时终止渲染并指出具体配置项。敏感值应放在
受保护的 values、External Secrets、Sealed Secrets 或密钥注入插件中。
内置 PostgreSQL、Redis、Sentinel 及 metrics exporter 镜像默认使用不可变 digest
避免 Bitnami 子 Chart 的 `latest` 默认值造成不可复现的安装和回滚。覆盖私有镜像
仓库或 tag 时,必须同时把对应的 `image.digest` 设为空,或改成私有仓库中该镜像
的真实 digestdigest 非空时会优先于 tag。
## 配置参考
### 副本数配置
@ -207,27 +222,32 @@ postgresql:
registry: registry.example.com
repository: library/postgresql
tag: 18.4.0
digest: ""
metrics:
image:
registry: registry.example.com
repository: library/postgres-exporter
tag: 0.20.1
digest: ""
redis:
image:
registry: registry.example.com
repository: library/redis
tag: 8.8.0
digest: ""
sentinel:
image:
registry: registry.example.com
repository: library/redis-sentinel
tag: 8.8.0
digest: ""
metrics:
image:
registry: registry.example.com
repository: library/redis-exporter
tag: 1.86.0
digest: ""
```
`global.security.allowInsecureImages` 是 Bitnami 对自定义镜像仓库和镜像名称的校验

View file

@ -1,4 +1,5 @@
{{- /* Cross-field validation that JSON Schema cannot express reliably. */ -}}
{{- $absoluteHttpUrlPattern := "^https?://(\\[[0-9A-Fa-f:.]+\\]|[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?)(:[0-9]{1,5})?([/?#][^[:space:]]*)?$" -}}
{{- if not .Values.server.enabled -}}
{{- fail "server.enabled=false is unsupported because the bundled web component requires the SkillHub server" -}}
{{- end -}}
@ -17,7 +18,7 @@
{{- end -}}
{{- range $host := .Values.ingress.hosts -}}
{{- range $path := $host.paths -}}
{{- if regexMatch "^/(api|oauth2|login/oauth2|\\.well-known)(?:/|$)" $path.path -}}
{{- if regexMatch "^/(api|oauth2|login/oauth2|\\.well-known)(/|$)" $path.path -}}
{{- fail "ingress.hosts[].paths reserves /api, /oauth2, /login/oauth2 and /.well-known for the SkillHub server" -}}
{{- end -}}
{{- end -}}
@ -61,10 +62,10 @@
{{- if and (not .Values.redis.enabled) .Values.externalRedis.sentinel.enabled (not .Values.externalRedis.sentinel.nodes) -}}
{{- fail "external Redis Sentinel requires at least one externalRedis.sentinel.nodes entry" -}}
{{- end -}}
{{- if and .Values.s3.endpoint (not (regexMatch "^https?://.+" .Values.s3.endpoint)) -}}
{{- if and .Values.s3.endpoint (not (regexMatch $absoluteHttpUrlPattern .Values.s3.endpoint)) -}}
{{- fail "s3.endpoint must be an absolute HTTP(S) URL" -}}
{{- end -}}
{{- if and .Values.s3.publicEndpoint (not (regexMatch "^https?://.+" .Values.s3.publicEndpoint)) -}}
{{- if and .Values.s3.publicEndpoint (not (regexMatch $absoluteHttpUrlPattern .Values.s3.publicEndpoint)) -}}
{{- fail "s3.publicEndpoint must be an absolute HTTP(S) URL" -}}
{{- end -}}

View file

@ -27,6 +27,13 @@ render verify "$CHART_DIR" >"$TMP_DIR/default.yaml"
grep -Fq 'name: POSTGRESQL_MAX_CONNECTIONS' "$TMP_DIR/default.yaml"
grep -Fq 'value: "verify-postgresql"' "$TMP_DIR/default.yaml"
grep -Fq 'value: "verify-redis-master"' "$TMP_DIR/default.yaml"
grep -Fq 'bitnami/postgresql@sha256:52e4eae10959a7ef4810f2d0c9ce656e9337090fd2427d8d15d2e1335ba4f8fb' "$TMP_DIR/default.yaml"
grep -Fq 'bitnami/postgres-exporter@sha256:fbd5fbc3f350c793efef777b0710f17019e8dd7812fc7c7396b13aa5130f3bd9' "$TMP_DIR/default.yaml"
grep -Fq 'bitnami/redis@sha256:5ff436b8a0dbe3808f03c7b682865cd30ddb1f08c1a8d17970ebf475f5689722' "$TMP_DIR/default.yaml"
grep -Fq 'bitnami/redis-exporter@sha256:0468a9a14ebd96953f967f9f3e8673c3eb1ad1dd54d379fef15c3f3ee714c99e' "$TMP_DIR/default.yaml"
if grep -Eq 'image:.*:latest([@"[:space:]]|$)' "$TMP_DIR/default.yaml"; then
fail "default workloads must not use mutable latest image tags"
fi
grep -Fq 'fsGroup: 101' "$TMP_DIR/default.yaml"
grep -Fq 'fsGroupChangePolicy: OnRootMismatch' "$TMP_DIR/default.yaml"
grep -Fq 'type: Recreate' "$TMP_DIR/default.yaml"
@ -104,6 +111,13 @@ grep -Fq 'name: SPRING_DATA_REDIS_PASSWORD' "$TMP_DIR/sentinel.yaml"
grep -Fq 'name: SPRING_DATA_REDIS_SENTINEL_PASSWORD' "$TMP_DIR/sentinel.yaml"
grep -A1 -F 'name: SKILLHUB_REDIS_SENTINEL_CHECK_SENTINELS_LIST' "$TMP_DIR/sentinel.yaml" \
| grep -Fq 'value: "false"'
render sentinel-full "$CHART_DIR" \
--set redis.architecture=replication \
--set redis.sentinel.enabled=true >"$TMP_DIR/sentinel-full.yaml"
grep -Fq 'bitnami/redis-sentinel@sha256:667bfef3e22ce9910bdcf304b1b7c659cfc15390a2d473187eb3caac2537d0e6' "$TMP_DIR/sentinel-full.yaml"
if grep -Eq 'image:.*:latest([@"[:space:]]|$)' "$TMP_DIR/sentinel-full.yaml"; then
fail "Sentinel workloads must not use mutable latest image tags"
fi
render external-sentinel "$CHART_DIR" \
--set postgresql.enabled=false \
@ -221,8 +235,13 @@ assert_rejected old-ingress-tls-object --set ingress.tls.enabled=true
assert_rejected reserved-oauth-ingress-path \
--set ingress.enabled=true \
--set-json 'ingress.hosts=[{"host":"skills.example.com","paths":[{"path":"/oauth2","pathType":"Prefix"}]}]'
assert_rejected reserved-oauth-ingress-child-path \
--set ingress.enabled=true \
--set-json 'ingress.hosts=[{"host":"skills.example.com","paths":[{"path":"/login/oauth2/code/github","pathType":"Prefix"}]}]'
assert_rejected invalid-s3-endpoint --set s3.endpoint=s3.amazonaws.com
assert_rejected invalid-s3-public-endpoint --set s3.publicEndpoint=cdn.example.com
assert_rejected invalid-s3-empty-authority --set-string 's3.endpoint=https://?'
assert_rejected invalid-s3-whitespace-authority --set-string 's3.publicEndpoint=https:// '
assert_rejected empty-ingress-hosts --set-json 'ingress.hosts=[]'
assert_rejected cert-manager-without-tls \
--set ingress.enabled=true \

View file

@ -0,0 +1,169 @@
#!/usr/bin/env bash
set -euo pipefail
CHART_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
TEST_VALUES="$CHART_DIR/tests/test-values.yaml"
NAMESPACE=${HELM_SMOKE_NAMESPACE:-skillhub-helm-smoke}
RELEASE=${HELM_SMOKE_RELEASE:-skillhub-smoke}
TIMEOUT=${HELM_SMOKE_TIMEOUT:-15m}
KEEP_ENVIRONMENT=${KEEP_HELM_SMOKE:-false}
TMP_DIR=$(mktemp -d)
PORT_FORWARD_PID=""
OWNS_NAMESPACE=false
fail() {
echo "ERROR: $*" >&2
exit 1
}
for command in helm kubectl curl jq sha256sum; do
command -v "$command" >/dev/null 2>&1 || fail "$command is required"
done
if kubectl get namespace "$NAMESPACE" >/dev/null 2>&1; then
fail "namespace $NAMESPACE already exists; choose an unused HELM_SMOKE_NAMESPACE"
fi
OWNS_NAMESPACE=true
stop_port_forward() {
if [[ -n "$PORT_FORWARD_PID" ]]; then
kill "$PORT_FORWARD_PID" >/dev/null 2>&1 || true
wait "$PORT_FORWARD_PID" >/dev/null 2>&1 || true
PORT_FORWARD_PID=""
fi
}
cleanup() {
local exit_code=$?
trap - EXIT
stop_port_forward
if (( exit_code != 0 )) && kubectl get namespace "$NAMESPACE" >/dev/null 2>&1; then
echo "Helm smoke failed; collecting non-secret diagnostics" >&2
helm status "$RELEASE" --namespace "$NAMESPACE" >&2 || true
kubectl get pods,pvc,deployments,statefulsets --namespace "$NAMESPACE" -o wide >&2 || true
kubectl get events --namespace "$NAMESPACE" --sort-by=.lastTimestamp >&2 || true
fi
if [[ "$KEEP_ENVIRONMENT" != "true" && "$OWNS_NAMESPACE" == "true" ]]; then
helm uninstall "$RELEASE" --namespace "$NAMESPACE" --wait >/dev/null 2>&1 || true
kubectl delete namespace "$NAMESPACE" --wait --timeout=5m >/dev/null 2>&1 || true
fi
rm -rf "$TMP_DIR"
exit "$exit_code"
}
trap cleanup EXIT
probe_service() {
local service=$1
local service_port=$2
local local_port=$3
local path=$4
local log_file="$TMP_DIR/${service}.port-forward.log"
stop_port_forward
kubectl port-forward \
--namespace "$NAMESPACE" \
"service/$service" \
"$local_port:$service_port" >"$log_file" 2>&1 &
PORT_FORWARD_PID=$!
for _ in $(seq 1 60); do
if curl --fail --silent --show-error "http://127.0.0.1:$local_port$path" >/dev/null; then
stop_port_forward
return 0
fi
if ! kill -0 "$PORT_FORWARD_PID" >/dev/null 2>&1; then
break
fi
sleep 1
done
cat "$log_file" >&2
fail "$service$path did not become healthy"
}
snapshot_secrets() {
local output=$1
: >"$output"
for secret in "$RELEASE-secret" "$RELEASE-postgresql" "$RELEASE-redis"; do
printf '%s ' "$secret" >>"$output"
kubectl get secret "$secret" --namespace "$NAMESPACE" -o json \
| jq -cS '.data' \
| sha256sum \
| awk '{print $1}' >>"$output"
done
}
snapshot_pvcs() {
local output=$1
kubectl get pvc --namespace "$NAMESPACE" -o json \
| jq -r '.items[] | [.metadata.name, .metadata.uid, .spec.volumeName] | @tsv' \
| sort >"$output"
[[ -s "$output" ]] || fail "Helm install did not create any PVCs"
}
assert_ready_and_healthy() {
kubectl wait pod \
--namespace "$NAMESPACE" \
--all \
--for=condition=Ready \
--timeout="$TIMEOUT"
probe_service "$RELEASE-server" 8080 18081 /actuator/health
probe_service "$RELEASE-web" 80 18080 /nginx-health
probe_service "$RELEASE-web" 80 18080 /api/v1/namespaces
probe_service "$RELEASE-scanner" 8000 18082 /health
local restarts
restarts=$(kubectl get pods --namespace "$NAMESPACE" -o json \
| jq '[.items[].status.containerStatuses[]?.restartCount] | add // 0')
[[ "$restarts" == "0" ]] || fail "workloads restarted $restarts time(s)"
}
helm dependency build "$CHART_DIR"
helm install "$RELEASE" "$CHART_DIR" \
--namespace "$NAMESPACE" \
--create-namespace \
--values "$TEST_VALUES" \
--set-string fullnameOverride="$RELEASE" \
--set-string publicBaseUrl=http://skillhub-smoke.local \
--wait \
--timeout "$TIMEOUT"
assert_ready_and_healthy
snapshot_secrets "$TMP_DIR/secrets-before"
snapshot_pvcs "$TMP_DIR/pvcs-before"
revision_before=$(helm history "$RELEASE" --namespace "$NAMESPACE" -o json \
| jq -r '.[-1].revision')
helm upgrade "$RELEASE" "$CHART_DIR" \
--namespace "$NAMESPACE" \
--reuse-values \
--set-string publicBaseUrl=https://skillhub-smoke.local \
--set-string server.podAnnotations.helm-smoke-revision=revision-2 \
--wait \
--timeout "$TIMEOUT"
assert_ready_and_healthy
snapshot_secrets "$TMP_DIR/secrets-after"
snapshot_pvcs "$TMP_DIR/pvcs-after"
revision_after=$(helm history "$RELEASE" --namespace "$NAMESPACE" -o json \
| jq -r '.[-1].revision')
(( revision_after == revision_before + 1 )) \
|| fail "Helm revision did not advance exactly once"
cmp "$TMP_DIR/secrets-before" "$TMP_DIR/secrets-after" \
|| fail "application or dependency Secret data changed during upgrade"
cmp "$TMP_DIR/pvcs-before" "$TMP_DIR/pvcs-after" \
|| fail "PVC identity or bound volume changed during upgrade"
public_base_url=$(kubectl get configmap "$RELEASE-config" \
--namespace "$NAMESPACE" \
-o json | jq -r '.data["public-base-url"]')
[[ "$public_base_url" == "https://skillhub-smoke.local" ]] \
|| fail "publicBaseUrl was not applied by the upgrade"
echo "Helm install/upgrade smoke passed"

View file

@ -114,8 +114,8 @@
"properties": {
"enabled": { "type": "boolean" },
"bucket": { "type": "string", "minLength": 1 },
"endpoint": { "type": "string", "pattern": "^(|https?://.+)$" },
"publicEndpoint": { "type": "string", "pattern": "^(|https?://.+)$" },
"endpoint": { "type": "string", "pattern": "^(|https?://(\\[[0-9A-Fa-f:.]+\\]|[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?)(:[0-9]{1,5})?([/?#]\\S*)?)$" },
"publicEndpoint": { "type": "string", "pattern": "^(|https?://(\\[[0-9A-Fa-f:.]+\\]|[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?)(:[0-9]{1,5})?([/?#]\\S*)?)$" },
"region": { "type": "string", "minLength": 1 },
"forcePathStyle": { "type": "boolean" },
"disableChunkedEncoding": { "type": "boolean" },

View file

@ -105,6 +105,11 @@ postgresql:
architecture: standalone
# Bitnami's current chart defaults to a mutable latest tag. Pin the image
# digest so installs and rollbacks remain reproducible.
image:
digest: "sha256:52e4eae10959a7ef4810f2d0c9ce656e9337090fd2427d8d15d2e1335ba4f8fb"
auth:
postgresPassword: ""
database: skillhub
@ -165,6 +170,8 @@ postgresql:
metrics:
enabled: true
image:
digest: "sha256:fbd5fbc3f350c793efef777b0710f17019e8dd7812fc7c7396b13aa5130f3bd9"
serviceMonitor:
enabled: false
@ -184,6 +191,10 @@ redis:
architecture: standalone
# Keep the bundled Redis runtime immutable for repeatable upgrades.
image:
digest: "sha256:5ff436b8a0dbe3808f03c7b682865cd30ddb1f08c1a8d17970ebf475f5689722"
auth:
enabled: true
password: ""
@ -228,6 +239,8 @@ redis:
sentinel:
enabled: false
masterSet: mymaster
image:
digest: "sha256:667bfef3e22ce9910bdcf304b1b7c659cfc15390a2d473187eb3caac2537d0e6"
service:
ports:
sentinel: 26379
@ -236,6 +249,8 @@ redis:
metrics:
enabled: true
image:
digest: "sha256:0468a9a14ebd96953f967f9f3e8673c3eb1ad1dd54d379fef15c3f3ee714c99e"
serviceMonitor:
enabled: false