diff --git a/.github/workflows/pr-helm-chart.yml b/.github/workflows/pr-helm-chart.yml index c24b0377..3e87c9bf 100644 --- a/.github/workflows/pr-helm-chart.yml +++ b/.github/workflows/pr-helm-chart.yml @@ -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 diff --git a/README.md b/README.md index 2d5dec57..ff928fcf 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/charts/skillhub/README.md b/charts/skillhub/README.md index 592a0319..f4063034 100644 --- a/charts/skillhub/README.md +++ b/charts/skillhub/README.md @@ -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` 设为空,或改成私有仓库中该镜像 +的真实 digest;digest 非空时会优先于 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 对自定义镜像仓库和镜像名称的校验 diff --git a/charts/skillhub/templates/validate.yaml b/charts/skillhub/templates/validate.yaml index 5b1862f6..443b880f 100644 --- a/charts/skillhub/templates/validate.yaml +++ b/charts/skillhub/templates/validate.yaml @@ -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 -}} diff --git a/charts/skillhub/tests/configuration-contracts.sh b/charts/skillhub/tests/configuration-contracts.sh index 36348f32..61d75756 100755 --- a/charts/skillhub/tests/configuration-contracts.sh +++ b/charts/skillhub/tests/configuration-contracts.sh @@ -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 \ diff --git a/charts/skillhub/tests/install-upgrade-smoke.sh b/charts/skillhub/tests/install-upgrade-smoke.sh new file mode 100755 index 00000000..c7d3145e --- /dev/null +++ b/charts/skillhub/tests/install-upgrade-smoke.sh @@ -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" diff --git a/charts/skillhub/values.schema.json b/charts/skillhub/values.schema.json index a151d663..6e6497ad 100644 --- a/charts/skillhub/values.schema.json +++ b/charts/skillhub/values.schema.json @@ -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" }, diff --git a/charts/skillhub/values.yaml b/charts/skillhub/values.yaml index d70f2406..c3e70769 100644 --- a/charts/skillhub/values.yaml +++ b/charts/skillhub/values.yaml @@ -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