skillhub/charts/skillhub/tests/configuration-contracts.sh
philsun 34f244e7a4 feat(web): support configurable base-path deployment
Signed-off-by: philsun <xinyi.sun@daocloud.io>
2026-08-05 12:50:26 +08:00

364 lines
19 KiB
Bash
Executable file

#!/usr/bin/env bash
set -euo pipefail
CHART_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
TEST_VALUES="$CHART_DIR/tests/test-values.yaml"
TMP_DIR=$(mktemp -d)
trap 'rm -rf "$TMP_DIR"' EXIT
fail() {
echo "ERROR: $*" >&2
exit 1
}
render() {
helm template "$@" -f "$TEST_VALUES"
}
assert_rejected() {
local name=$1
shift
if render "$name" "$CHART_DIR" "$@" >"$TMP_DIR/$name.yaml" 2>"$TMP_DIR/$name.err"; then
fail "$name should have been rejected"
fi
}
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:db2312d9b243afa8c3b3f5496e478d17d0dff9791d06f3b93b9567abd86ae92f' "$TMP_DIR/default.yaml"
grep -Fq 'bitnami/postgres-exporter@sha256:53ab72a1b940d7637e91619f1000da9ebef14bc7dad74321a78731d65c79f55b' "$TMP_DIR/default.yaml"
grep -Fq 'bitnami/redis@sha256:08863c2c3f4e051fb6139b38fa223e9c13be5033326a59bead182860d899bf98' "$TMP_DIR/default.yaml"
grep -Fq 'bitnami/redis-exporter@sha256:fb1dae6add1e1104989d086d9407f7d65f58968550aa5fddea20637a758c0773' "$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"
render custom-server-fsgroup "$CHART_DIR" \
--set server.podSecurityContext.fsGroup=2000 \
--set server.podSecurityContext.fsGroupChangePolicy=Always \
--show-only templates/server-deployment.yaml >"$TMP_DIR/custom-server-fsgroup.yaml"
grep -Fq 'fsGroup: 2000' "$TMP_DIR/custom-server-fsgroup.yaml"
grep -Fq 'fsGroupChangePolicy: Always' "$TMP_DIR/custom-server-fsgroup.yaml"
stable_args=(
--set-string secrets.bootstrapAdminPassword=stable-bootstrap-password
--set-string secrets.downloadAnonCookieSecret=stable-download-cookie-secret
--set-string postgresql.auth.postgresPassword=stable-postgres-password
--set-string postgresql.auth.password=stable-user-password
--set-string redis.auth.password=stable-redis-password
)
render stable "$CHART_DIR" "${stable_args[@]}" >"$TMP_DIR/stable-a.yaml"
render stable "$CHART_DIR" "${stable_args[@]}" >"$TMP_DIR/stable-b.yaml"
cmp "$TMP_DIR/stable-a.yaml" "$TMP_DIR/stable-b.yaml"
render private-registry "$CHART_DIR" \
--set server.dependencyWait.image.registry=registry.example.com \
--set server.dependencyWait.image.repository=library/busybox \
--show-only templates/server-deployment.yaml >"$TMP_DIR/private-registry.yaml"
grep -Fq 'image: "registry.example.com/library/busybox:1.37"' "$TMP_DIR/private-registry.yaml"
render postgresql-replication "$CHART_DIR" \
--set postgresql.architecture=replication >"$TMP_DIR/postgresql-replication.yaml"
if [[ $(grep -Fc 'name: POSTGRESQL_MAX_CONNECTIONS' "$TMP_DIR/postgresql-replication.yaml") -ne 2 ]]; then
fail "PostgreSQL primary and read replica must use the same max_connections setting"
fi
render custom "$CHART_DIR" \
--set postgresql.auth.existingSecret=custom-pg \
--set postgresql.auth.secretKeys.userPasswordKey=custom-pg-key \
--set redis.auth.existingSecret=custom-redis \
--set redis.auth.existingSecretPasswordKey=custom-redis-key \
--show-only templates/server-deployment.yaml >"$TMP_DIR/custom.yaml"
grep -Fq 'name: custom-pg' "$TMP_DIR/custom.yaml"
grep -Fq 'key: custom-pg-key' "$TMP_DIR/custom.yaml"
grep -Fq 'name: custom-redis' "$TMP_DIR/custom.yaml"
grep -Fq 'key: custom-redis-key' "$TMP_DIR/custom.yaml"
render postgresql-admin "$CHART_DIR" \
--set postgresql.auth.username=postgres \
--show-only templates/server-deployment.yaml >"$TMP_DIR/postgresql-admin.yaml"
grep -Fq 'value: "postgres"' "$TMP_DIR/postgresql-admin.yaml"
grep -Fq 'key: postgres-password' "$TMP_DIR/postgresql-admin.yaml"
render postgresql-admin-secret "$CHART_DIR" \
--set postgresql.auth.username=postgres \
--show-only charts/postgresql/templates/secrets.yaml >"$TMP_DIR/postgresql-admin-secret.yaml"
grep -Eq '^ postgres-password:' "$TMP_DIR/postgresql-admin-secret.yaml"
if grep -Eq '^ password:' "$TMP_DIR/postgresql-admin-secret.yaml"; then
fail "Bitnami PostgreSQL must not create a custom-user password key for username=postgres"
fi
render postgresql-admin-existing-secret "$CHART_DIR" \
--set postgresql.auth.username=postgres \
--set postgresql.auth.existingSecret=custom-pg-admin \
--set postgresql.auth.secretKeys.adminPasswordKey=custom-admin-key \
--show-only templates/server-deployment.yaml >"$TMP_DIR/postgresql-admin-existing-secret.yaml"
grep -Fq 'name: custom-pg-admin' "$TMP_DIR/postgresql-admin-existing-secret.yaml"
grep -Fq 'key: custom-admin-key' "$TMP_DIR/postgresql-admin-existing-secret.yaml"
render sentinel "$CHART_DIR" \
--set redis.architecture=replication \
--set redis.sentinel.enabled=true \
--show-only templates/server-deployment.yaml >"$TMP_DIR/sentinel.yaml"
grep -Fq 'value: "docker,redis-sentinel"' "$TMP_DIR/sentinel.yaml"
grep -Fq 'value: "mymaster"' "$TMP_DIR/sentinel.yaml"
grep -Fq '.svc.cluster.local:26379' "$TMP_DIR/sentinel.yaml"
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:ae75dd69c192a632bdeb21baa6721080be5b12347e52add922036398b47631da' "$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 \
--set externalDatabase.host=db.example.com \
--set redis.enabled=false \
--set externalRedis.username=redis-user \
--set externalRedis.password=redis-password \
--set externalRedis.sentinel.enabled=true \
--set externalRedis.sentinel.username=sentinel-user \
--set externalRedis.sentinel.password=sentinel-password \
--set-json 'externalRedis.sentinel.nodes=["sentinel-a:26379","sentinel-b:26379"]' \
--show-only templates/server-deployment.yaml >"$TMP_DIR/external-sentinel.yaml"
grep -Fq 'value: "sentinel-a"' "$TMP_DIR/external-sentinel.yaml"
grep -Fq 'name: SPRING_DATA_REDIS_PASSWORD' "$TMP_DIR/external-sentinel.yaml"
grep -Fq 'name: SPRING_DATA_REDIS_SENTINEL_PASSWORD' "$TMP_DIR/external-sentinel.yaml"
grep -A1 -F 'name: SPRING_DATA_REDIS_USERNAME' "$TMP_DIR/external-sentinel.yaml" \
| grep -Fq 'value: "redis-user"'
grep -A1 -F 'name: SPRING_DATA_REDIS_SENTINEL_USERNAME' "$TMP_DIR/external-sentinel.yaml" \
| grep -Fq 'value: "sentinel-user"'
if grep -Fq 'name: SKILLHUB_REDIS_SENTINEL_CHECK_SENTINELS_LIST' "$TMP_DIR/external-sentinel.yaml"; then
fail "external Sentinel must preserve Redisson address consistency checks by default"
fi
render external-cluster "$CHART_DIR" \
--set postgresql.enabled=false \
--set externalDatabase.host=db.example.com \
--set redis.enabled=false \
--set existingSecret=skillhub-production-secret \
--set externalRedis.username=skillhub \
--set externalRedis.tls.enabled=true \
--set externalRedis.connectTimeout=5s \
--set externalRedis.timeout=3s \
--set externalRedis.clientName=skillhub-server \
--set externalRedis.cluster.enabled=true \
--set externalRedis.cluster.maxRedirects=7 \
--set-json 'externalRedis.cluster.nodes=["redis-a.example.com:6379","redis-b.example.com:6380"]' \
--show-only templates/server-deployment.yaml >"$TMP_DIR/external-cluster.yaml"
grep -A1 -F 'name: REDIS_HOST' "$TMP_DIR/external-cluster.yaml" \
| grep -Fq 'value: "redis-a.example.com"'
grep -A1 -F 'name: REDIS_PORT' "$TMP_DIR/external-cluster.yaml" \
| grep -Fq 'value: "6379"'
grep -A1 -F 'name: SPRING_DATA_REDIS_CLUSTER_NODES' "$TMP_DIR/external-cluster.yaml" \
| grep -Fq 'value: "redis-a.example.com:6379,redis-b.example.com:6380"'
grep -A1 -F 'name: SPRING_DATA_REDIS_CLUSTER_MAX_REDIRECTS' "$TMP_DIR/external-cluster.yaml" \
| grep -Fq 'value: "7"'
grep -A1 -F 'name: SPRING_DATA_REDIS_USERNAME' "$TMP_DIR/external-cluster.yaml" \
| grep -Fq 'value: "skillhub"'
grep -A1 -F 'name: SPRING_DATA_REDIS_SSL_ENABLED' "$TMP_DIR/external-cluster.yaml" \
| grep -Fq 'value: "true"'
grep -A1 -F 'name: SPRING_DATA_REDIS_CONNECT_TIMEOUT' "$TMP_DIR/external-cluster.yaml" \
| grep -Fq 'value: "5s"'
grep -A1 -F 'name: SPRING_DATA_REDIS_TIMEOUT' "$TMP_DIR/external-cluster.yaml" \
| grep -Fq 'value: "3s"'
grep -A1 -F 'name: SPRING_DATA_REDIS_CLIENT_NAME' "$TMP_DIR/external-cluster.yaml" \
| grep -Fq 'value: "skillhub-server"'
grep -A4 -F 'name: SPRING_DATA_REDIS_PASSWORD' "$TMP_DIR/external-cluster.yaml" \
| grep -Fq 'name: skillhub-production-secret'
if grep -Fq 'name: SPRING_DATA_REDIS_HOST' "$TMP_DIR/external-cluster.yaml"; then
fail "external Redis Cluster must not render standalone host configuration"
fi
if grep -Fq 'name: SPRING_DATA_REDIS_SENTINEL_NODES' "$TMP_DIR/external-cluster.yaml"; then
fail "external Redis Cluster must not render Sentinel configuration"
fi
render special "$CHART_DIR" \
--set-string 'bootstrapAdmin.displayName=Ops: Admin' \
--show-only templates/configmap.yaml >"$TMP_DIR/special.yaml"
grep -Fq 'bootstrap-admin-display-name: "Ops: Admin"' "$TMP_DIR/special.yaml"
render device "$CHART_DIR" \
--set publicBaseUrl=https://skills.example.com \
--show-only templates/configmap.yaml >"$TMP_DIR/device.yaml"
grep -Fq 'device-auth-verification-uri: "https://skills.example.com/cli/auth"' "$TMP_DIR/device.yaml"
render tls "$CHART_DIR" \
--set ingress.enabled=true \
--set-json 'ingress.tls=[{"hosts":["skills.example.com"],"secretName":"skills-tls"}]' \
--show-only templates/configmap.yaml >"$TMP_DIR/tls.yaml"
grep -Fq 'session-cookie-secure: "true"' "$TMP_DIR/tls.yaml"
render tls "$CHART_DIR" \
--set ingress.enabled=true \
--set-json 'ingress.tls=[{"hosts":["skills.example.com"],"secretName":"skills-tls"}]' \
--show-only templates/ingress.yaml >"$TMP_DIR/tls-ingress.yaml"
for server_path in /api /oauth2 /login/oauth2 /.well-known; do
grep -Fq -- "- path: $server_path" "$TMP_DIR/tls-ingress.yaml"
done
if [[ $(grep -Fc 'name: tls-skillhub-server' "$TMP_DIR/tls-ingress.yaml") -ne 4 ]]; then
fail "API and OAuth ingress paths must route directly to the SkillHub server"
fi
render legacy-ingress "$CHART_DIR" \
--set ingress.enabled=true \
--set-string ingress.className= \
--set-json 'ingress.annotations={"kubernetes.io/ingress.class":"alb","alb.ingress.kubernetes.io/listen-ports":"[{\"HTTPS\":6443}]"}' \
--show-only templates/ingress.yaml >"$TMP_DIR/legacy-ingress.yaml"
grep -Fq 'kubernetes.io/ingress.class: alb' "$TMP_DIR/legacy-ingress.yaml"
grep -Fq 'alb.ingress.kubernetes.io/listen-ports:' "$TMP_DIR/legacy-ingress.yaml"
if grep -Fq 'ingressClassName:' "$TMP_DIR/legacy-ingress.yaml"; then
fail "empty ingress.className must omit spec.ingressClassName"
fi
render multi-host-ingress "$CHART_DIR" \
--set ingress.enabled=true \
--set ingress.certManager.enabled=true \
--set-json 'ingress.hosts=[{"host":"skills-a.example.com","paths":[{"path":"/","pathType":"Prefix"}]},{"host":"skills-b.example.com","paths":[{"path":"/portal","pathType":"Prefix"}]}]' \
--set-json 'ingress.tls=[{"hosts":["skills-a.example.com","skills-b.example.com"],"secretName":"skills-tls"}]' \
--show-only templates/ingress.yaml \
--show-only templates/certificate.yaml >"$TMP_DIR/multi-host-ingress.yaml"
if [[ $(grep -Fc 'skills-a.example.com' "$TMP_DIR/multi-host-ingress.yaml") -ne 3 ]]; then
fail "first ingress host must be rendered in rule, TLS and Certificate"
fi
if [[ $(grep -Fc 'skills-b.example.com' "$TMP_DIR/multi-host-ingress.yaml") -ne 3 ]]; then
fail "second ingress host must be rendered in rule, TLS and Certificate"
fi
render scanner-off "$CHART_DIR" \
--set scanner.enabled=false \
--set scanner.autoscaling.enabled=true \
--set scanner.podDisruptionBudget.enabled=true >"$TMP_DIR/scanner-off.yaml"
if awk '
$1 == "kind:" { kind=$2 }
kind ~ /^(Deployment|Service|HorizontalPodAutoscaler|PodDisruptionBudget)$/ &&
$1 == "name:" && $2 == "scanner-off-skillhub-scanner" { found=1 }
END { exit found ? 0 : 1 }
' "$TMP_DIR/scanner-off.yaml"; then
fail "disabled scanner rendered workload resources"
fi
render multi-rwx "$CHART_DIR" \
--set server.replicaCount=2 \
--set server.storage.accessMode=ReadWriteMany >"$TMP_DIR/multi-rwx.yaml"
grep -Fq -- '- ReadWriteMany' "$TMP_DIR/multi-rwx.yaml"
grep -Fq 'type: RollingUpdate' "$TMP_DIR/multi-rwx.yaml"
render s3-rolling "$CHART_DIR" \
--set s3.enabled=true \
--set s3.bucket=skillhub \
--set s3.endpoint=https://s3.example.com \
--set s3.accessKey=access-key \
--set s3.secretKey=secret-key \
--show-only templates/server-deployment.yaml >"$TMP_DIR/s3-rolling.yaml"
grep -Fq 'type: RollingUpdate' "$TMP_DIR/s3-rolling.yaml"
assert_rejected server-off --set server.enabled=false
assert_rejected direct-auth-without-provider \
--set auth.direct.enabled=true \
--set-string auth.direct.provider=
assert_rejected ingress-without-server-service --set ingress.enabled=true --set server.service.enabled=false
assert_rejected ingress-without-web-service --set ingress.enabled=true --set web.service.enabled=false
assert_rejected multi-without-rwx --set server.replicaCount=2
assert_rejected hpa-without-metrics \
--set server.autoscaling.enabled=true \
--set server.autoscaling.targetCPUUtilizationPercentage=0 \
--set server.autoscaling.targetMemoryUtilizationPercentage=0
assert_rejected old-postgres-env --set-json 'postgresql.primary.extraEnv=[{"name":"X","value":"Y"}]'
assert_rejected old-sentinel-password --set redis.auth.sentinelPassword=unused
assert_rejected old-sentinel-nodes --set redis.sentinel.nodes=unused
assert_rejected old-sentinel-service-switch --set redis.sentinel.service.enabled=false
assert_rejected internal-and-external-cluster \
--set externalRedis.cluster.enabled=true \
--set-json 'externalRedis.cluster.nodes=["redis-a.example.com:6379"]'
assert_rejected sentinel-and-cluster \
--set redis.enabled=false \
--set externalRedis.sentinel.enabled=true \
--set externalRedis.cluster.enabled=true \
--set-json 'externalRedis.sentinel.nodes=["sentinel-a.example.com:26379"]' \
--set-json 'externalRedis.cluster.nodes=["redis-a.example.com:6379"]'
assert_rejected cluster-without-nodes \
--set redis.enabled=false \
--set externalRedis.cluster.enabled=true
assert_rejected cluster-invalid-node \
--set redis.enabled=false \
--set externalRedis.cluster.enabled=true \
--set-json 'externalRedis.cluster.nodes=["redis-a.example.com"]'
assert_rejected cluster-invalid-port \
--set redis.enabled=false \
--set externalRedis.cluster.enabled=true \
--set-json 'externalRedis.cluster.nodes=["redis-a.example.com:65536"]'
assert_rejected invalid-fullname --set fullnameOverride=INVALID_NAME
assert_rejected old-ingress-host --set ingress.host=old.example.com
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 \
--set ingress.certManager.enabled=true \
--set-json 'ingress.tls=[]'
if helm template missing-credentials "$CHART_DIR" >"$TMP_DIR/missing-credentials.yaml" 2>"$TMP_DIR/missing-credentials.err"; then
fail "default rendering without stable credentials should have been rejected"
fi
# Sub-path deployment: base path and API prefix must flow from values into the
# config map and be injected into the web deployment.
grep -Fq 'web-base-path: ""' "$TMP_DIR/default.yaml" \
|| fail "default config map web-base-path must be empty so a fixed-base image is honored"
render subpath "$CHART_DIR" \
--set web.basePath=/portal/ \
--set web.apiBaseUrl=/portal >"$TMP_DIR/subpath.yaml"
grep -Fq 'web-base-path: "/portal/"' "$TMP_DIR/subpath.yaml" \
|| fail "config map must expose the configured web base path"
grep -Fq 'web-api-base-url: "/portal"' "$TMP_DIR/subpath.yaml" \
|| fail "config map must expose the configured web API base url"
grep -Fq 'name: SKILLHUB_WEB_BASE_PATH' "$TMP_DIR/subpath.yaml" \
|| fail "web deployment must set SKILLHUB_WEB_BASE_PATH"
grep -Fq 'key: web-base-path' "$TMP_DIR/subpath.yaml" \
|| fail "web deployment must source SKILLHUB_WEB_BASE_PATH from the config map"
grep -Fq 'key: web-api-base-url' "$TMP_DIR/subpath.yaml" \
|| fail "web deployment must source SKILLHUB_WEB_API_BASE_URL from the config map"
# A sub-path base must be consistent with publicBaseUrl and be a normalized path.
assert_rejected subpath-public-mismatch \
--set web.basePath=/portal/ \
--set-string publicBaseUrl=https://skills.example.com
assert_rejected subpath-dot-segment --set web.basePath=/foo/../bar/
assert_rejected subpath-missing-trailing --set-string web.basePath=/portal
assert_rejected subpath-api-base-mismatch \
--set web.basePath=/portal/ \
--set-string web.apiBaseUrl=/other \
--set-string publicBaseUrl=https://skills.example.com/portal
# A base path whose first segment is reserved by the server would shadow the
# server's own Nginx location and break the app.
assert_rejected subpath-reserved-api --set-string web.basePath=/api/
assert_rejected subpath-reserved-assets --set-string web.basePath=/assets/
assert_rejected subpath-reserved-well-known --set-string web.basePath=/.well-known/
assert_rejected subpath-reserved-nested --set-string web.basePath=/api/nested/
# publicBaseUrl is concatenated with paths (/cli/auth, /.well-known/clawhub.json),
# so a query or fragment corrupts the generated URLs. Reject it independently of
# web.basePath (these cases use the default root deployment).
assert_rejected public-base-url-query --set-string publicBaseUrl=https://skills.example.com/skillhub?ref=1
assert_rejected public-base-url-fragment --set-string publicBaseUrl=https://skills.example.com#frag
assert_rejected public-base-url-no-host --set-string publicBaseUrl=https://
echo "Helm configuration contract tests passed"