From 42da467336f79e6f7e78d2dd2aeb6d4786f60aa7 Mon Sep 17 00:00:00 2001 From: wurongjie Date: Thu, 9 Apr 2026 13:53:52 +0800 Subject: [PATCH 01/81] fix(nginx): use X-Forwarded-Proto header for proper protocol forwarding Replace $scheme with $http_x_forwarded_proto in proxy headers to correctly forward the original client protocol when behind a reverse proxy or load balancer. This fixes OAuth2 authentication issues where redirects would use the wrong protocol scheme. --- web/nginx.conf.template | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/web/nginx.conf.template b/web/nginx.conf.template index fe0300b6..be2a51a2 100644 --- a/web/nginx.conf.template +++ b/web/nginx.conf.template @@ -10,6 +10,11 @@ server { gzip_types text/plain text/css application/json application/javascript text/xml; gzip_min_length 1000; + set $proxy_x_forwarded_proto $scheme; + if ($http_x_forwarded_proto) { + set $proxy_x_forwarded_proto $http_x_forwarded_proto; + } + location / { try_files $uri $uri/ /index.html; } @@ -19,27 +24,27 @@ server { proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } location /oauth2/ { proxy_pass ${SKILLHUB_API_UPSTREAM}; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } location /login/oauth2/ { proxy_pass ${SKILLHUB_API_UPSTREAM}; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } location /.well-known/ { proxy_pass ${SKILLHUB_API_UPSTREAM}; proxy_set_header Host $host; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } location /assets/ { From dd1e87f1f3e7d399cb6c1a60916c78e39ecc448b Mon Sep 17 00:00:00 2001 From: jangrui Date: Sat, 16 May 2026 08:28:05 +0800 Subject: [PATCH 02/81] =?UTF-8?q?feat(chart):=20=E6=B7=BB=E5=8A=A0=20Skill?= =?UTF-8?q?Hub=20Helm=20Chart=20=E9=83=A8=E7=BD=B2=E6=96=B9=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 Helm Chart 支持完整的 SkillHub 私有化部署,包括: - PostgreSQL/Redis 内置 StatefulSet 及外部模式切换 - 零依赖设计,无需 Bitnami 子 Chart - 支持 standalone/cluster 数据库架构 - NodePort/LoadBalancer/ClusterIP 多种服务类型 - HPA、PDB、ServiceMonitor 完整运维支持 - cert-manager 证书自动签发 - initContainer 等待数据库和 Redis 就绪 - PVC 卸载保护 (helm.sh/resource-policy: keep) - GitHub Actions: PR 校验 + 发布到 GHCR OCI Signed-off-by: jangrui --- .github/workflows/pr-helm-chart.yml | 146 ++++++++ .github/workflows/publish-helm-chart.yml | 81 +++++ charts/skillhub/.helmignore | 24 ++ charts/skillhub/Chart.yaml | 13 + charts/skillhub/templates/_helpers.tpl | 127 +++++++ .../templates/backend-deployment.yaml | 236 +++++++++++++ charts/skillhub/templates/certificate.yaml | 17 + charts/skillhub/templates/configmap.yaml | 34 ++ .../templates/frontend-deployment.yaml | 54 +++ charts/skillhub/templates/hpa.yaml | 101 ++++++ charts/skillhub/templates/ingress.yaml | 47 +++ charts/skillhub/templates/pdb.yaml | 44 +++ .../templates/postgres-statefulset.yaml | 100 ++++++ charts/skillhub/templates/pvc.yaml | 19 ++ .../skillhub/templates/redis-statefulset.yaml | 83 +++++ .../templates/scanner-deployment.yaml | 66 ++++ charts/skillhub/templates/secret.yaml | 28 ++ charts/skillhub/templates/services.yaml | 83 +++++ charts/skillhub/values.yaml | 312 ++++++++++++++++++ 19 files changed, 1615 insertions(+) create mode 100644 .github/workflows/pr-helm-chart.yml create mode 100644 .github/workflows/publish-helm-chart.yml create mode 100644 charts/skillhub/.helmignore create mode 100644 charts/skillhub/Chart.yaml create mode 100644 charts/skillhub/templates/_helpers.tpl create mode 100644 charts/skillhub/templates/backend-deployment.yaml create mode 100644 charts/skillhub/templates/certificate.yaml create mode 100644 charts/skillhub/templates/configmap.yaml create mode 100644 charts/skillhub/templates/frontend-deployment.yaml create mode 100644 charts/skillhub/templates/hpa.yaml create mode 100644 charts/skillhub/templates/ingress.yaml create mode 100644 charts/skillhub/templates/pdb.yaml create mode 100644 charts/skillhub/templates/postgres-statefulset.yaml create mode 100644 charts/skillhub/templates/pvc.yaml create mode 100644 charts/skillhub/templates/redis-statefulset.yaml create mode 100644 charts/skillhub/templates/scanner-deployment.yaml create mode 100644 charts/skillhub/templates/secret.yaml create mode 100644 charts/skillhub/templates/services.yaml create mode 100644 charts/skillhub/values.yaml diff --git a/.github/workflows/pr-helm-chart.yml b/.github/workflows/pr-helm-chart.yml new file mode 100644 index 00000000..42e0b2c6 --- /dev/null +++ b/.github/workflows/pr-helm-chart.yml @@ -0,0 +1,146 @@ +name: PR Helm Chart + +on: + pull_request: + paths: + - charts/skillhub/** + types: + - opened + - synchronize + - reopened + - ready_for_review + workflow_dispatch: + +concurrency: + group: pr-helm-chart-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + lint: + name: Lint Chart + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} + runs-on: ubuntu-latest + defaults: + run: + working-directory: charts/skillhub + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Helm + uses: azure/setup-helm@v4 + with: + version: latest + + - name: Lint chart + run: helm lint + + - name: Validate chart metadata + run: | + CHART_VERSION=$(helm show chart . | grep '^version:' | awk '{print $2}') + APP_VERSION=$(helm show chart . | grep '^appVersion:' | awk '{print $2}') + echo "Chart version: $CHART_VERSION" + echo "App version: $APP_VERSION" + if [ -z "$CHART_VERSION" ]; then + echo "ERROR: Chart version is empty" + exit 1 + fi + + template: + name: Template Validation + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} + runs-on: ubuntu-latest + defaults: + run: + working-directory: charts/skillhub + + strategy: + fail-fast: false + matrix: + scenario: + - name: internal-default + description: 内置 PostgreSQL + Redis + args: "" + - name: external-db-redis + description: 外置 PostgreSQL + Redis + args: > + --set database.mode=external + --set redis.mode=external + - name: external-sentinel + description: 外置 DB + Redis 哨兵模式 + args: > + --set database.mode=external + --set redis.mode=external + --set redis.external.sentinel.enabled=true + --set redis.external.sentinel.nodes="{10.0.0.1:26379,10.0.0.2:26379}" + - name: ingress-tls-certmanager + description: Ingress + TLS + cert-manager + args: > + --set ingress.enabled=true + --set ingress.tls.enabled=true + --set ingress.certManager.enabled=true + - name: s3-storage + description: S3 存储 + args: > + --set storage.provider=s3 + --set storage.s3.bucket=test-bucket + --set storage.s3.endpoint=s3.amazonaws.com + --set storage.s3.region=us-east-1 + - name: external-secret + description: 引用已有 Secret + args: --set existingSecret=my-custom-secret + - name: scanner-disabled + description: 禁用 Scanner + args: --set scanner.enabled=false + - name: db-cluster + description: PostgreSQL cluster 模式 + args: --set database.architecture=cluster + - name: hpa-pdb + description: HPA + PDB 开启 + args: > + --set server.autoscaling.enabled=true + --set web.autoscaling.enabled=true + --set scanner.autoscaling.enabled=true + --set server.podDisruptionBudget.enabled=true + --set web.podDisruptionBudget.enabled=true + --set scanner.podDisruptionBudget.enabled=true + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Helm + uses: azure/setup-helm@v4 + with: + version: latest + + - name: Render template - ${{ matrix.scenario.name }} + run: | + echo "## ${{ matrix.scenario.description }}" + helm template test-release . ${{ matrix.scenario.args }} > /dev/null + echo "✅ Template rendered successfully" + + - name: Validate no empty resources + run: | + RESOURCES=$(helm template test-release . ${{ matrix.scenario.args }} | grep -c '^kind:') + echo "Rendered $RESOURCES resources for ${{ matrix.scenario.name }}" + if [ "$RESOURCES" -eq 0 ]; then + echo "ERROR: No resources rendered for ${{ matrix.scenario.name }}" + exit 1 + fi + + - name: Validate resource names are well-formed + run: | + helm template test-release . ${{ matrix.scenario.args }} | \ + grep -E '^ name:' | \ + while read -r line; do + if echo "$line" | grep -qP '\{\{'; then + echo "ERROR: Unrendered template in name: $line" + exit 1 + fi + done + echo "✅ All resource names properly rendered" diff --git a/.github/workflows/publish-helm-chart.yml b/.github/workflows/publish-helm-chart.yml new file mode 100644 index 00000000..7508143d --- /dev/null +++ b/.github/workflows/publish-helm-chart.yml @@ -0,0 +1,81 @@ +name: Publish Helm Chart + +on: + release: + types: [published] + workflow_dispatch: + +concurrency: + group: publish-helm-chart-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + packages: write + +jobs: + package: + name: Package and Publish + runs-on: ubuntu-latest + defaults: + run: + working-directory: charts/skillhub + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Helm + uses: azure/setup-helm@v4 + with: + version: latest + + - name: Extract chart version from tag + id: chartver + run: | + REF="${{ github.ref_name }}" + # Support helm-vX.Y.Z or just vX.Y.Z tags + if [[ "$REF" =~ ^helm-v?([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then + VERSION="${BASH_REMATCH[1]}" + elif [[ "$REF" =~ ^v?([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then + VERSION="${BASH_REMATCH[1]}" + else + # Fallback: use chart.yaml version + VERSION=$(helm show chart . | grep '^version:' | awk '{print $2}') + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "Packaging chart version: $VERSION" + + - name: Lint chart + run: helm lint + + - name: Package chart + run: | + helm package . --version "${{ steps.chartver.outputs.version }}" \ + --destination /tmp/helm-charts + echo "Packaged:" + ls -la /tmp/helm-charts/ + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Push chart to GHCR OCI + run: | + helm push /tmp/helm-charts/skillhub-${{ steps.chartver.outputs.version }}.tgz \ + oci://ghcr.io/${{ github.repository_owner }}/charts + + - name: Generate artifact attestation + uses: actions/attest-build-provenance@v2 + with: + subject-path: /tmp/helm-charts/skillhub-${{ steps.chartver.outputs.version }}.tgz + + - name: Upload chart artifact + uses: actions/upload-artifact@v4 + with: + name: skillhub-${{ steps.chartver.outputs.version }}.tgz + path: /tmp/helm-charts/skillhub-${{ steps.chartver.outputs.version }}.tgz + retention-days: 90 diff --git a/charts/skillhub/.helmignore b/charts/skillhub/.helmignore new file mode 100644 index 00000000..0df7bb7c --- /dev/null +++ b/charts/skillhub/.helmignore @@ -0,0 +1,24 @@ +# OS files +.DS_Store +Thumbs.db + +# Editors / IDEs +.idea/ +.vscode/ +*.swp +*.swo + +# Local tooling +.claude/ +CLAUDE.md + +# Git +.git/ +.gitignore +.gitattributes + +# CI +.github/ + +# Template artifacts +*.tgz diff --git a/charts/skillhub/Chart.yaml b/charts/skillhub/Chart.yaml new file mode 100644 index 00000000..dd34d1fc --- /dev/null +++ b/charts/skillhub/Chart.yaml @@ -0,0 +1,13 @@ +apiVersion: v2 +name: skillhub +description: Self-hosted, open-source agent skill registry for enterprises. +type: application +version: 0.1.0 +appVersion: 0.2.8 +keywords: + - skillhub + - ai + - skills +home: https://github.com/iflytek/skillhub +sources: + - https://github.com/iflytek/skillhub diff --git a/charts/skillhub/templates/_helpers.tpl b/charts/skillhub/templates/_helpers.tpl new file mode 100644 index 00000000..a70a01f1 --- /dev/null +++ b/charts/skillhub/templates/_helpers.tpl @@ -0,0 +1,127 @@ +{{- /* +SkillHub Helm Chart 模板辅助函数 +*/}} + +{{- /* 名称 */}} +{{- define "skillhub.name" -}} +{{- default "skillhub" .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{- /* 完整名称 */}} +{{- define "skillhub.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default "skillhub" .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{- /* Chart 标签 */}} +{{- define "skillhub.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{- /* 通用标签 */}} +{{- define "skillhub.labels" -}} +helm.sh/chart: {{ include "skillhub.chart" . }} +{{ include "skillhub.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +app.kubernetes.io/part-of: skillhub +{{- end }} + +{{- /* 选择器标签 */}} +{{- define "skillhub.selectorLabels" -}} +app.kubernetes.io/name: {{ include "skillhub.name" . }} +{{- end }} + +{{- /* 组件标签 */}} +{{- define "skillhub.server.labels" -}} +{{ include "skillhub.labels" . }} +app.kubernetes.io/component: server +{{- end }} +{{- define "skillhub.server.selectorLabels" -}} +{{ include "skillhub.selectorLabels" . }} +app.kubernetes.io/component: server +{{- end }} + +{{- define "skillhub.web.labels" -}} +{{ include "skillhub.labels" . }} +app.kubernetes.io/component: web +{{- end }} +{{- define "skillhub.web.selectorLabels" -}} +{{ include "skillhub.selectorLabels" . }} +app.kubernetes.io/component: web +{{- end }} + +{{- define "skillhub.scanner.labels" -}} +{{ include "skillhub.labels" . }} +app.kubernetes.io/component: scanner +{{- end }} +{{- define "skillhub.scanner.selectorLabels" -}} +{{ include "skillhub.selectorLabels" . }} +app.kubernetes.io/component: scanner +{{- end }} + +{{- /* 镜像地址 */}} +{{- define "skillhub.image" -}} +{{- $registry := .registry | default .global.registry }} +{{- printf "%s/%s:%s" $registry .name .tag }} +{{- end }} + +{{- /* JDBC Host */}} +{{- define "skillhub.jdbcHost" -}} +{{- if eq .Values.database.mode "internal" -}} +{{ include "skillhub.fullname" . }}-postgres +{{- else -}} +{{ .Values.database.external.host }} +{{- end -}} +{{- end }} + +{{- /* JDBC Port */}} +{{- define "skillhub.jdbcPort" -}} +{{- if eq .Values.database.mode "internal" -}}5432{{- else -}} +{{ .Values.database.external.port | default "5432" }} +{{- end -}} +{{- end }} + +{{- /* Secret 名称 */}} +{{- define "skillhub.secretName" -}} +{{- .Values.existingSecret | default (printf "%s-secret" (include "skillhub.fullname" .)) }} +{{- end }} + +{{- /* Redis Host */}} +{{- define "skillhub.redisHost" -}} +{{- if eq .Values.redis.mode "internal" -}} +{{ include "skillhub.fullname" . }}-redis +{{- else -}} +{{ .Values.redis.external.host }} +{{- end -}} +{{- end }} + +{{- /* Redis Port */}} +{{- define "skillhub.redisPort" -}} +{{- if eq .Values.redis.mode "internal" -}}6379{{- else -}} +{{ .Values.redis.external.port | default "6379" }} +{{- end -}} +{{- end }} + +{{- /* 数据库 JDBC URL */}} +{{- define "skillhub.jdbcUrl" -}} +{{- if eq .Values.database.mode "internal" -}} +jdbc:postgresql://{{ include "skillhub.fullname" . }}-postgres:5432/skillhub +{{- else -}} +{{- if .Values.database.external.jdbcUrl -}} +{{ .Values.database.external.jdbcUrl }} +{{- else -}} +jdbc:postgresql://{{ .Values.database.external.host }}:{{ .Values.database.external.port }}/{{ .Values.database.external.database }}{{ if .Values.database.external.parameters }}?{{ .Values.database.external.parameters }}{{ end }} +{{- end }} +{{- end }} +{{- end }} diff --git a/charts/skillhub/templates/backend-deployment.yaml b/charts/skillhub/templates/backend-deployment.yaml new file mode 100644 index 00000000..b0a1d832 --- /dev/null +++ b/charts/skillhub/templates/backend-deployment.yaml @@ -0,0 +1,236 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "skillhub.fullname" . }}-server + labels: + {{- include "skillhub.server.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + {{- include "skillhub.server.selectorLabels" . | nindent 6 }} + template: + metadata: + labels: + {{- include "skillhub.server.selectorLabels" . | nindent 8 }} + annotations: + {{- toYaml .Values.server.podAnnotations | nindent 8 }} + spec: + {{- $secrets := .Values.server.imagePullSecrets | default .Values.global.imagePullSecrets }} + {{- if $secrets }} + imagePullSecrets: + {{- toYaml $secrets | nindent 8 }} + {{- end }} + initContainers: + - name: wait-for-dependencies + image: busybox:latest + env: + - name: DB_HOST + value: {{ include "skillhub.jdbcHost" . }} + - name: DB_PORT + value: {{ include "skillhub.jdbcPort" . | quote }} + - name: REDIS_HOST + value: {{ include "skillhub.redisHost" . }} + - name: REDIS_PORT + value: {{ include "skillhub.redisPort" . | quote }} + command: + - sh + - -c + - | + echo "Waiting for PostgreSQL at ${DB_HOST}:${DB_PORT}..." + until nc -z -w 2 "${DB_HOST}" "${DB_PORT}"; do sleep 2; done + echo "PostgreSQL is ready!" + echo "Waiting for Redis at ${REDIS_HOST}:${REDIS_PORT}..." + until nc -z -w 2 "${REDIS_HOST}" "${REDIS_PORT}"; do sleep 2; done + echo "Redis is ready!" + containers: + - name: server + image: {{ .Values.images.registry }}/skillhub-server:{{ .Values.images.tag }} + imagePullPolicy: {{ .Values.images.pullPolicy }} + ports: + - containerPort: {{ .Values.service.serverPort }} + name: http + env: + - name: SPRING_PROFILES_ACTIVE + value: {{ .Values.springProfilesActive }} + + # Database + - name: SPRING_DATASOURCE_URL + valueFrom: + secretKeyRef: + name: {{ include "skillhub.secretName" . }} + key: spring-datasource-url + - name: SPRING_DATASOURCE_USERNAME + valueFrom: + secretKeyRef: + name: {{ include "skillhub.secretName" . }} + key: spring-datasource-username + - name: SPRING_DATASOURCE_PASSWORD + valueFrom: + secretKeyRef: + name: {{ include "skillhub.secretName" . }} + key: spring-datasource-password + + # Redis + - name: SPRING_DATA_REDIS_HOST + valueFrom: + configMapKeyRef: + name: {{ include "skillhub.fullname" . }}-config + key: redis-host + - name: SPRING_DATA_REDIS_PORT + valueFrom: + configMapKeyRef: + name: {{ include "skillhub.fullname" . }}-config + key: redis-port + + {{- if and (eq .Values.redis.mode "external") .Values.redis.external.password }} + - name: SPRING_DATA_REDIS_PASSWORD + value: {{ .Values.redis.external.password }} + {{- end }} + + {{- if .Values.redis.external.sentinel.enabled }} + - name: SPRING_DATA_REDIS_SENTINEL_MASTER + value: {{ .Values.redis.external.sentinel.masterSet }} + - name: SPRING_DATA_REDIS_SENTINEL_NODES + value: {{ join "," .Values.redis.external.sentinel.nodes }} + {{- end }} + + # Storage + - name: STORAGE_BASE_PATH + valueFrom: + configMapKeyRef: + name: {{ include "skillhub.fullname" . }}-config + key: storage-base-path + - name: SKILLHUB_STORAGE_PROVIDER + valueFrom: + configMapKeyRef: + name: {{ include "skillhub.fullname" . }}-config + key: skillhub-storage-provider + + {{- if eq .Values.storage.provider "s3" }} + - name: SKILLHUB_S3_BUCKET + value: {{ .Values.storage.s3.bucket }} + - name: SKILLHUB_S3_ENDPOINT + value: {{ .Values.storage.s3.endpoint }} + - name: SKILLHUB_S3_REGION + value: {{ .Values.storage.s3.region }} + {{- if .Values.storage.s3.accessKey }} + - name: SKILLHUB_S3_ACCESS_KEY + value: {{ .Values.storage.s3.accessKey }} + {{- end }} + {{- if .Values.storage.s3.secretKey }} + - name: SKILLHUB_S3_SECRET_KEY + value: {{ .Values.storage.s3.secretKey }} + {{- end }} + {{- end }} + + # Scanner + - name: SKILLHUB_SECURITY_SCANNER_ENABLED + valueFrom: + configMapKeyRef: + name: {{ include "skillhub.fullname" . }}-config + key: skill-scanner-enabled + - name: SKILLHUB_SECURITY_SCANNER_URL + valueFrom: + configMapKeyRef: + name: {{ include "skillhub.fullname" . }}-config + key: skill-scanner-url + - name: SKILLHUB_SECURITY_SCANNER_MODE + valueFrom: + configMapKeyRef: + name: {{ include "skillhub.fullname" . }}-config + key: skill-scanner-mode + + # Session + - name: SESSION_COOKIE_SECURE + valueFrom: + configMapKeyRef: + name: {{ include "skillhub.fullname" . }}-config + key: session-cookie-secure + + # Bootstrap Admin + - name: BOOTSTRAP_ADMIN_ENABLED + valueFrom: + configMapKeyRef: + name: {{ include "skillhub.fullname" . }}-config + key: bootstrap-admin-enabled + - name: BOOTSTRAP_ADMIN_USER_ID + valueFrom: + configMapKeyRef: + name: {{ include "skillhub.fullname" . }}-config + key: bootstrap-admin-user-id + - name: BOOTSTRAP_ADMIN_USERNAME + valueFrom: + configMapKeyRef: + name: {{ include "skillhub.fullname" . }}-config + key: bootstrap-admin-username + - name: BOOTSTRAP_ADMIN_DISPLAY_NAME + valueFrom: + configMapKeyRef: + name: {{ include "skillhub.fullname" . }}-config + key: bootstrap-admin-display-name + - name: BOOTSTRAP_ADMIN_EMAIL + valueFrom: + configMapKeyRef: + name: {{ include "skillhub.fullname" . }}-config + key: bootstrap-admin-email + - name: BOOTSTRAP_ADMIN_PASSWORD + valueFrom: + secretKeyRef: + name: {{ include "skillhub.secretName" . }} + key: bootstrap-admin-password + optional: true + + # OAuth2 GitHub (optional) + - name: OAUTH2_GITHUB_CLIENT_ID + valueFrom: + secretKeyRef: + name: {{ include "skillhub.secretName" . }} + key: oauth2-github-client-id + optional: true + - name: OAUTH2_GITHUB_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: {{ include "skillhub.secretName" . }} + key: oauth2-github-client-secret + optional: true + + {{- if .Values.server.javaOpts }} + - name: JAVA_OPTS + value: {{ .Values.server.javaOpts }} + {{- end }} + + {{- with .Values.server.extraEnv }} + {{- toYaml . | nindent 12 }} + {{- end }} + + volumeMounts: + - name: skillhub-storage + mountPath: /var/lib/skillhub/storage + + resources: + {{- toYaml .Values.server.resources | nindent 12 }} + + startupProbe: + {{- toYaml .Values.server.probes.startup | nindent 12 }} + readinessProbe: + {{- toYaml .Values.server.probes.readiness | nindent 12 }} + livenessProbe: + {{- toYaml .Values.server.probes.liveness | nindent 12 }} + + volumes: + - name: skillhub-storage + persistentVolumeClaim: + claimName: {{ include "skillhub.fullname" . }}-storage-pvc + {{- with .Values.server.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.server.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.server.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/charts/skillhub/templates/certificate.yaml b/charts/skillhub/templates/certificate.yaml new file mode 100644 index 00000000..95543ed3 --- /dev/null +++ b/charts/skillhub/templates/certificate.yaml @@ -0,0 +1,17 @@ +{{- if and .Values.ingress.enabled .Values.ingress.certManager.enabled }} +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: {{ include "skillhub.fullname" . }}-tls + labels: + {{- include "skillhub.labels" . | nindent 4 }} +spec: + secretName: {{ include "skillhub.fullname" . }}-tls + duration: 2160h + renewBefore: 360h + dnsNames: + - {{ .Values.ingress.host }} + issuerRef: + name: {{ .Values.ingress.certManager.issuerName }} + kind: {{ .Values.ingress.certManager.issuerKind }} +{{- end }} diff --git a/charts/skillhub/templates/configmap.yaml b/charts/skillhub/templates/configmap.yaml new file mode 100644 index 00000000..a7600e95 --- /dev/null +++ b/charts/skillhub/templates/configmap.yaml @@ -0,0 +1,34 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "skillhub.fullname" . }}-config + labels: + {{- include "skillhub.labels" . | nindent 4 }} +data: + # Redis 配置 + redis-host: {{ include "skillhub.redisHost" . }} + redis-port: {{ include "skillhub.redisPort" . | quote }} + + # 存储路径 + storage-base-path: /var/lib/skillhub/storage + + # 存储提供者: local | s3 + skillhub-storage-provider: {{ .Values.storage.provider }} + + # 技能扫描器 + skill-scanner-enabled: {{ .Values.scanner.enabled | quote }} + skill-scanner-url: http://{{ include "skillhub.fullname" . }}-scanner:8000 + skill-scanner-mode: upload + + # Bootstrap 管理员 + bootstrap-admin-enabled: {{ .Values.bootstrapAdmin.enabled | quote }} + bootstrap-admin-user-id: {{ .Values.bootstrapAdmin.userId }} + bootstrap-admin-username: {{ .Values.bootstrapAdmin.username }} + bootstrap-admin-display-name: {{ .Values.bootstrapAdmin.displayName }} + bootstrap-admin-email: {{ .Values.bootstrapAdmin.email }} + + # Session + session-cookie-secure: {{ .Values.session.cookieSecure | quote }} + + # Spring Profiles + spring-profiles-active: {{ .Values.springProfilesActive }} diff --git a/charts/skillhub/templates/frontend-deployment.yaml b/charts/skillhub/templates/frontend-deployment.yaml new file mode 100644 index 00000000..9b2942dd --- /dev/null +++ b/charts/skillhub/templates/frontend-deployment.yaml @@ -0,0 +1,54 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "skillhub.fullname" . }}-web + labels: + {{- include "skillhub.web.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + {{- include "skillhub.web.selectorLabels" . | nindent 6 }} + template: + metadata: + labels: + {{- include "skillhub.web.selectorLabels" . | nindent 8 }} + annotations: + {{- toYaml .Values.web.podAnnotations | nindent 8 }} + spec: + {{- $secrets := .Values.web.imagePullSecrets | default .Values.global.imagePullSecrets }} + {{- if $secrets }} + imagePullSecrets: + {{- toYaml $secrets | nindent 8 }} + {{- end }} + containers: + - name: web + image: {{ .Values.images.registry }}/skillhub-web:{{ .Values.images.tag }} + imagePullPolicy: {{ .Values.images.pullPolicy }} + env: + - name: SKILLHUB_API_UPSTREAM + value: http://{{ include "skillhub.fullname" . }}-server:{{ .Values.service.serverPort }} + {{- with .Values.web.extraEnv }} + {{- toYaml . | nindent 12 }} + {{- end }} + ports: + - containerPort: {{ .Values.service.webPort }} + name: http + resources: + {{- toYaml .Values.web.resources | nindent 12 }} + readinessProbe: + {{- toYaml .Values.web.probes.readiness | nindent 12 }} + livenessProbe: + {{- toYaml .Values.web.probes.liveness | nindent 12 }} + {{- with .Values.web.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.web.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.web.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/charts/skillhub/templates/hpa.yaml b/charts/skillhub/templates/hpa.yaml new file mode 100644 index 00000000..0e2de933 --- /dev/null +++ b/charts/skillhub/templates/hpa.yaml @@ -0,0 +1,101 @@ +{{- if .Values.server.autoscaling.enabled }} +--- +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: {{ include "skillhub.fullname" . }}-server + labels: + {{- include "skillhub.server.labels" . | nindent 4 }} +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ include "skillhub.fullname" . }}-server + minReplicas: {{ .Values.server.autoscaling.minReplicas }} + maxReplicas: {{ .Values.server.autoscaling.maxReplicas }} + metrics: + {{- if .Values.server.autoscaling.targetCPUUtilizationPercentage }} + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: {{ .Values.server.autoscaling.targetCPUUtilizationPercentage }} + {{- end }} + {{- if .Values.server.autoscaling.targetMemoryUtilizationPercentage }} + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: {{ .Values.server.autoscaling.targetMemoryUtilizationPercentage }} + {{- end }} +{{- end }} + +{{- if .Values.web.autoscaling.enabled }} +--- +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: {{ include "skillhub.fullname" . }}-web + labels: + {{- include "skillhub.web.labels" . | nindent 4 }} +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ include "skillhub.fullname" . }}-web + minReplicas: {{ .Values.web.autoscaling.minReplicas }} + maxReplicas: {{ .Values.web.autoscaling.maxReplicas }} + metrics: + {{- if .Values.web.autoscaling.targetCPUUtilizationPercentage }} + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: {{ .Values.web.autoscaling.targetCPUUtilizationPercentage }} + {{- end }} + {{- if .Values.web.autoscaling.targetMemoryUtilizationPercentage }} + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: {{ .Values.web.autoscaling.targetMemoryUtilizationPercentage }} + {{- end }} +{{- end }} + +{{- if and .Values.scanner.enabled .Values.scanner.autoscaling.enabled }} +--- +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: {{ include "skillhub.fullname" . }}-scanner + labels: + {{- include "skillhub.scanner.labels" . | nindent 4 }} +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ include "skillhub.fullname" . }}-scanner + minReplicas: {{ .Values.scanner.autoscaling.minReplicas }} + maxReplicas: {{ .Values.scanner.autoscaling.maxReplicas }} + metrics: + {{- if .Values.scanner.autoscaling.targetCPUUtilizationPercentage }} + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: {{ .Values.scanner.autoscaling.targetCPUUtilizationPercentage }} + {{- end }} + {{- if .Values.scanner.autoscaling.targetMemoryUtilizationPercentage }} + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: {{ .Values.scanner.autoscaling.targetMemoryUtilizationPercentage }} + {{- end }} +{{- end }} diff --git a/charts/skillhub/templates/ingress.yaml b/charts/skillhub/templates/ingress.yaml new file mode 100644 index 00000000..30bded0f --- /dev/null +++ b/charts/skillhub/templates/ingress.yaml @@ -0,0 +1,47 @@ +{{- if .Values.ingress.enabled }} +{{- $secretName := .Values.ingress.tls.secretName | default (printf "%s-tls" (include "skillhub.fullname" .)) }} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "skillhub.fullname" . }} + labels: + {{- include "skillhub.labels" . | nindent 4 }} + annotations: + {{- if .Values.ingress.annotations }} + {{- toYaml .Values.ingress.annotations | nindent 4 }} + {{- end }} + {{- if .Values.ingress.certManager.enabled }} + {{- if eq .Values.ingress.certManager.issuerKind "ClusterIssuer" }} + cert-manager.io/cluster-issuer: {{ .Values.ingress.certManager.issuerName }} + {{- else }} + cert-manager.io/issuer: {{ .Values.ingress.certManager.issuerName }} + {{- end }} + cert-manager.io/issuer-kind: {{ .Values.ingress.certManager.issuerKind }} + {{- end }} +spec: + ingressClassName: {{ .Values.ingress.className }} + {{- if or .Values.ingress.tls.enabled .Values.ingress.certManager.enabled }} + tls: + - hosts: + - {{ .Values.ingress.host }} + secretName: {{ $secretName }} + {{- end }} + rules: + - host: {{ .Values.ingress.host }} + http: + paths: + - path: /api + pathType: Prefix + backend: + service: + name: {{ include "skillhub.fullname" . }}-server + port: + number: {{ .Values.service.serverPort }} + - path: / + pathType: Prefix + backend: + service: + name: {{ include "skillhub.fullname" . }}-web + port: + number: {{ .Values.service.webPort }} +{{- end }} diff --git a/charts/skillhub/templates/pdb.yaml b/charts/skillhub/templates/pdb.yaml new file mode 100644 index 00000000..ed705197 --- /dev/null +++ b/charts/skillhub/templates/pdb.yaml @@ -0,0 +1,44 @@ +{{- if .Values.server.podDisruptionBudget.enabled }} +--- +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: {{ include "skillhub.fullname" . }}-server + labels: + {{- include "skillhub.server.labels" . | nindent 4 }} +spec: + selector: + matchLabels: + {{- include "skillhub.server.selectorLabels" . | nindent 6 }} + minAvailable: {{ .Values.server.podDisruptionBudget.minAvailable }} +{{- end }} + +{{- if .Values.web.podDisruptionBudget.enabled }} +--- +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: {{ include "skillhub.fullname" . }}-web + labels: + {{- include "skillhub.web.labels" . | nindent 4 }} +spec: + selector: + matchLabels: + {{- include "skillhub.web.selectorLabels" . | nindent 6 }} + minAvailable: {{ .Values.web.podDisruptionBudget.minAvailable }} +{{- end }} + +{{- if and .Values.scanner.enabled .Values.scanner.podDisruptionBudget.enabled }} +--- +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: {{ include "skillhub.fullname" . }}-scanner + labels: + {{- include "skillhub.scanner.labels" . | nindent 4 }} +spec: + selector: + matchLabels: + {{- include "skillhub.scanner.selectorLabels" . | nindent 6 }} + minAvailable: {{ .Values.scanner.podDisruptionBudget.minAvailable }} +{{- end }} diff --git a/charts/skillhub/templates/postgres-statefulset.yaml b/charts/skillhub/templates/postgres-statefulset.yaml new file mode 100644 index 00000000..49c38654 --- /dev/null +++ b/charts/skillhub/templates/postgres-statefulset.yaml @@ -0,0 +1,100 @@ +{{- if eq .Values.database.mode "internal" }} +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: {{ include "skillhub.fullname" . }}-postgres + labels: + {{- include "skillhub.labels" . | nindent 4 }} + app.kubernetes.io/component: database +spec: + serviceName: {{ include "skillhub.fullname" . }}-postgres + replicas: {{ if eq .Values.database.architecture "cluster" }}3{{ else }}1{{ end }} + selector: + matchLabels: + {{- include "skillhub.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: database + template: + metadata: + labels: + {{- include "skillhub.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: database + spec: + containers: + - name: postgres + image: {{ .Values.database.internal.registry }}/{{ .Values.database.internal.image }} + ports: + - containerPort: 5432 + name: postgres + env: + - name: POSTGRES_DB + value: skillhub + - name: POSTGRES_USER + valueFrom: + secretKeyRef: + name: {{ include "skillhub.secretName" . }} + key: spring-datasource-username + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: {{ include "skillhub.secretName" . }} + key: spring-datasource-password + - name: PGDATA + value: /var/lib/postgresql/data/pgdata + volumeMounts: + - name: postgres-data + mountPath: /var/lib/postgresql/data + resources: + {{- toYaml .Values.database.internal.resources | nindent 12 }} + readinessProbe: + exec: + command: + - pg_isready + - -U + - skillhub + - -h + - localhost + initialDelaySeconds: 10 + periodSeconds: 10 + livenessProbe: + exec: + command: + - pg_isready + - -U + - skillhub + - -h + - localhost + initialDelaySeconds: 30 + periodSeconds: 15 + volumeClaimTemplates: + - metadata: + name: postgres-data + labels: + {{- include "skillhub.labels" . | nindent 10 }} + spec: + accessModes: + - {{ .Values.storage.local.accessMode }} + {{- if .Values.database.internal.storageClassName }} + storageClassName: {{ .Values.database.internal.storageClassName }} + {{- end }} + resources: + requests: + storage: {{ .Values.database.internal.storage }} +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ include "skillhub.fullname" . }}-postgres + labels: + {{- include "skillhub.labels" . | nindent 4 }} + app.kubernetes.io/component: database +spec: + type: ClusterIP + ports: + - port: 5432 + targetPort: postgres + name: postgres + selector: + {{- include "skillhub.selectorLabels" . | nindent 4 }} + app.kubernetes.io/component: database +{{- end }} diff --git a/charts/skillhub/templates/pvc.yaml b/charts/skillhub/templates/pvc.yaml new file mode 100644 index 00000000..0b5b49c7 --- /dev/null +++ b/charts/skillhub/templates/pvc.yaml @@ -0,0 +1,19 @@ +{{- if eq .Values.storage.provider "local" }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "skillhub.fullname" . }}-storage-pvc + labels: + {{- include "skillhub.labels" . | nindent 4 }} + annotations: + helm.sh/resource-policy: keep +spec: + accessModes: + - {{ .Values.storage.local.accessMode }} + {{- if .Values.storage.local.storageClassName }} + storageClassName: {{ .Values.storage.local.storageClassName }} + {{- end }} + resources: + requests: + storage: {{ .Values.storage.local.storage }} +{{- end }} diff --git a/charts/skillhub/templates/redis-statefulset.yaml b/charts/skillhub/templates/redis-statefulset.yaml new file mode 100644 index 00000000..1a581509 --- /dev/null +++ b/charts/skillhub/templates/redis-statefulset.yaml @@ -0,0 +1,83 @@ +{{- if eq .Values.redis.mode "internal" }} +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: {{ include "skillhub.fullname" . }}-redis + labels: + {{- include "skillhub.labels" . | nindent 4 }} + app.kubernetes.io/component: cache +spec: + serviceName: {{ include "skillhub.fullname" . }}-redis + replicas: 1 + selector: + matchLabels: + {{- include "skillhub.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: cache + template: + metadata: + labels: + {{- include "skillhub.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: cache + spec: + containers: + - name: redis + image: {{ .Values.redis.internal.registry }}/{{ .Values.redis.internal.image }} + ports: + - containerPort: 6379 + name: redis + command: + - redis-server + - --appendonly + - "yes" + volumeMounts: + - name: redis-data + mountPath: /data + resources: + {{- toYaml .Values.redis.internal.resources | nindent 12 }} + readinessProbe: + exec: + command: + - redis-cli + - ping + initialDelaySeconds: 5 + periodSeconds: 10 + livenessProbe: + exec: + command: + - redis-cli + - ping + initialDelaySeconds: 10 + periodSeconds: 15 + volumeClaimTemplates: + - metadata: + name: redis-data + labels: + {{- include "skillhub.labels" . | nindent 10 }} + spec: + accessModes: + - {{ .Values.storage.local.accessMode }} + {{- if .Values.redis.internal.storageClassName }} + storageClassName: {{ .Values.redis.internal.storageClassName }} + {{- end }} + resources: + requests: + storage: {{ .Values.redis.internal.storage }} +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ include "skillhub.fullname" . }}-redis + labels: + {{- include "skillhub.labels" . | nindent 4 }} + app.kubernetes.io/component: cache +spec: + type: ClusterIP + ports: + - port: 6379 + targetPort: redis + name: redis + selector: + {{- include "skillhub.selectorLabels" . | nindent 4 }} + app.kubernetes.io/component: cache +{{- end }} diff --git a/charts/skillhub/templates/scanner-deployment.yaml b/charts/skillhub/templates/scanner-deployment.yaml new file mode 100644 index 00000000..5269783d --- /dev/null +++ b/charts/skillhub/templates/scanner-deployment.yaml @@ -0,0 +1,66 @@ +{{- if .Values.scanner.enabled }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "skillhub.fullname" . }}-scanner + labels: + {{- include "skillhub.scanner.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + {{- include "skillhub.scanner.selectorLabels" . | nindent 6 }} + template: + metadata: + labels: + {{- include "skillhub.scanner.selectorLabels" . | nindent 8 }} + annotations: + {{- toYaml .Values.scanner.podAnnotations | nindent 8 }} + spec: + {{- $secrets := .Values.scanner.imagePullSecrets | default .Values.global.imagePullSecrets }} + {{- if $secrets }} + imagePullSecrets: + {{- toYaml $secrets | nindent 8 }} + {{- end }} + containers: + - name: scanner + image: {{ .Values.images.registry }}/skillhub-scanner:{{ .Values.images.tag }} + imagePullPolicy: {{ .Values.images.pullPolicy }} + ports: + - containerPort: {{ .Values.service.scannerPort }} + name: http + env: + - name: SKILL_SCANNER_LLM_API_KEY + valueFrom: + secretKeyRef: + name: {{ include "skillhub.secretName" . }} + key: skill-scanner-llm-api-key + optional: true + - name: SKILL_SCANNER_LLM_MODEL + valueFrom: + secretKeyRef: + name: {{ include "skillhub.secretName" . }} + key: skill-scanner-llm-model + optional: true + {{- with .Values.scanner.extraEnv }} + {{- toYaml . | nindent 12 }} + {{- end }} + resources: + {{- toYaml .Values.scanner.resources | nindent 12 }} + readinessProbe: + {{- toYaml .Values.scanner.probes.readiness | nindent 12 }} + livenessProbe: + {{- toYaml .Values.scanner.probes.liveness | nindent 12 }} + {{- with .Values.scanner.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.scanner.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.scanner.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} +{{- end }} diff --git a/charts/skillhub/templates/secret.yaml b/charts/skillhub/templates/secret.yaml new file mode 100644 index 00000000..8ef69132 --- /dev/null +++ b/charts/skillhub/templates/secret.yaml @@ -0,0 +1,28 @@ +{{- if not .Values.existingSecret }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "skillhub.fullname" . }}-secret + labels: + {{- include "skillhub.labels" . | nindent 4 }} +type: Opaque +stringData: + spring-datasource-url: {{ include "skillhub.jdbcUrl" . | quote }} + spring-datasource-username: {{ if eq .Values.database.mode "internal" }}skillhub{{ else }}{{ .Values.database.external.username }}{{ end }} + spring-datasource-password: {{ if eq .Values.database.mode "internal" }}{{ default (randAlphaNum 16) .Values.secrets.springDatasourcePassword }}{{ else }}{{ .Values.database.external.password }}{{ end }} + + bootstrap-admin-password: {{ .Values.secrets.bootstrapAdminPassword | default .Values.bootstrapAdmin.password | default (randAlphaNum 16) }} + + {{- if .Values.secrets.oauth2GithubClientId }} + oauth2-github-client-id: {{ .Values.secrets.oauth2GithubClientId }} + {{- end }} + {{- if .Values.secrets.oauth2GithubClientSecret }} + oauth2-github-client-secret: {{ .Values.secrets.oauth2GithubClientSecret }} + {{- end }} + {{- if .Values.secrets.scannerLlmApiKey }} + skill-scanner-llm-api-key: {{ .Values.secrets.scannerLlmApiKey }} + {{- end }} + {{- if .Values.secrets.scannerLlmModel }} + skill-scanner-llm-model: {{ .Values.secrets.scannerLlmModel }} + {{- end }} +{{- end }} diff --git a/charts/skillhub/templates/services.yaml b/charts/skillhub/templates/services.yaml new file mode 100644 index 00000000..2ac0e120 --- /dev/null +++ b/charts/skillhub/templates/services.yaml @@ -0,0 +1,83 @@ +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ include "skillhub.fullname" . }}-server + labels: + {{- include "skillhub.server.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + {{- if eq .Values.service.type "LoadBalancer" }} + {{- if .Values.service.loadBalancerIP }} + loadBalancerIP: {{ .Values.service.loadBalancerIP }} + {{- end }} + {{- if .Values.service.loadBalancerSourceRanges }} + loadBalancerSourceRanges: + {{- toYaml .Values.service.loadBalancerSourceRanges | nindent 4 }} + {{- end }} + {{- end }} + ports: + - name: http + port: {{ .Values.service.serverPort }} + targetPort: http + {{- if and (eq .Values.service.type "NodePort") .Values.service.serverNodePort }} + nodePort: {{ .Values.service.serverNodePort }} + {{- end }} + selector: + {{- include "skillhub.server.selectorLabels" . | nindent 4 }} +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ include "skillhub.fullname" . }}-web + labels: + {{- include "skillhub.web.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + {{- if eq .Values.service.type "LoadBalancer" }} + {{- if .Values.service.loadBalancerIP }} + loadBalancerIP: {{ .Values.service.loadBalancerIP }} + {{- end }} + {{- if .Values.service.loadBalancerSourceRanges }} + loadBalancerSourceRanges: + {{- toYaml .Values.service.loadBalancerSourceRanges | nindent 4 }} + {{- end }} + {{- end }} + ports: + - name: http + port: {{ .Values.service.webPort }} + targetPort: http + {{- if and (eq .Values.service.type "NodePort") .Values.service.webNodePort }} + nodePort: {{ .Values.service.webNodePort }} + {{- end }} + selector: + {{- include "skillhub.web.selectorLabels" . | nindent 4 }} +{{- if .Values.scanner.enabled }} +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ include "skillhub.fullname" . }}-scanner + labels: + {{- include "skillhub.scanner.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + {{- if eq .Values.service.type "LoadBalancer" }} + {{- if .Values.service.loadBalancerIP }} + loadBalancerIP: {{ .Values.service.loadBalancerIP }} + {{- end }} + {{- if .Values.service.loadBalancerSourceRanges }} + loadBalancerSourceRanges: + {{- toYaml .Values.service.loadBalancerSourceRanges | nindent 4 }} + {{- end }} + {{- end }} + ports: + - name: http + port: {{ .Values.service.scannerPort }} + targetPort: http + {{- if and (eq .Values.service.type "NodePort") .Values.service.scannerNodePort }} + nodePort: {{ .Values.service.scannerNodePort }} + {{- end }} + selector: + {{- include "skillhub.scanner.selectorLabels" . | nindent 4 }} +{{- end }} diff --git a/charts/skillhub/values.yaml b/charts/skillhub/values.yaml new file mode 100644 index 00000000..181b99e9 --- /dev/null +++ b/charts/skillhub/values.yaml @@ -0,0 +1,312 @@ +# ============================================================================ +# SkillHub Helm Chart 全局配置 +# ============================================================================ + +# ============================================================================ +# Global +# ============================================================================ +global: + imageRegistry: "" + imagePullSecrets: [] + +# ============================================================================ +# 镜像配置 +# ============================================================================ +images: + registry: ghcr.io/iflytek + tag: latest + pullPolicy: IfNotPresent + +# ============================================================================ +# 副本数 +# ============================================================================ +replicaCount: 1 + +nameOverride: "" +fullnameOverride: "" + +# ============================================================================ +# 服务配置 +# ============================================================================ +service: + # ClusterIP | NodePort | LoadBalancer + type: ClusterIP + serverPort: 8080 + webPort: 80 + scannerPort: 8000 + # type: NodePort 时指定 nodePort(不指定则由集群分配) + serverNodePort: "" + webNodePort: "" + scannerNodePort: "" + # type: LoadBalancer 时可选固定 IP + loadBalancerIP: "" + loadBalancerSourceRanges: [] + +# ============================================================================ +# Ingress 配置 +# ============================================================================ +ingress: + enabled: false + className: nginx + host: skills.example.com + annotations: + nginx.ingress.kubernetes.io/proxy-body-size: 100m + tls: + enabled: false + secretName: "" + certManager: + enabled: false + issuerName: letsencrypt-prod + issuerKind: ClusterIssuer + +# ============================================================================ +# 应用组件配置 +# ============================================================================ +server: + resources: + requests: + cpu: 500m + memory: 512Mi + limits: + cpu: 1000m + memory: 1Gi + javaOpts: "" + extraEnv: [] + podAnnotations: {} + imagePullSecrets: [] + nodeSelector: {} + tolerations: [] + affinity: {} + # HPA(自动扩缩容) + autoscaling: + enabled: false + minReplicas: 1 + maxReplicas: 10 + targetCPUUtilizationPercentage: 80 + targetMemoryUtilizationPercentage: 80 + # PDB(自愿干扰预算,多副本时保证最少可用实例) + podDisruptionBudget: + enabled: false + minAvailable: 1 + probes: + startup: + httpGet: + path: /actuator/health + port: http + initialDelaySeconds: 10 + periodSeconds: 10 + failureThreshold: 30 + readiness: + httpGet: + path: /actuator/health + port: http + initialDelaySeconds: 20 + periodSeconds: 10 + liveness: + httpGet: + path: /actuator/health + port: http + initialDelaySeconds: 30 + periodSeconds: 15 + +web: + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 200m + memory: 256Mi + extraEnv: [] + podAnnotations: {} + imagePullSecrets: [] + nodeSelector: {} + tolerations: [] + affinity: {} + # HPA(自动扩缩容) + autoscaling: + enabled: false + minReplicas: 1 + maxReplicas: 5 + targetCPUUtilizationPercentage: 80 + targetMemoryUtilizationPercentage: 80 + # PDB(自愿干扰预算,多副本时保证最少可用实例) + podDisruptionBudget: + enabled: false + minAvailable: 1 + probes: + readiness: + httpGet: + path: /nginx-health + port: http + initialDelaySeconds: 5 + periodSeconds: 10 + liveness: + httpGet: + path: /nginx-health + port: http + initialDelaySeconds: 10 + periodSeconds: 15 + +scanner: + enabled: true + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: 500m + memory: 512Mi + extraEnv: [] + podAnnotations: {} + imagePullSecrets: [] + nodeSelector: {} + tolerations: [] + affinity: {} + # HPA(自动扩缩容) + autoscaling: + enabled: false + minReplicas: 1 + maxReplicas: 5 + targetCPUUtilizationPercentage: 80 + targetMemoryUtilizationPercentage: 80 + # PDB(自愿干扰预算,多副本时保证最少可用实例) + podDisruptionBudget: + enabled: false + minAvailable: 1 + probes: + readiness: + httpGet: + path: /health + port: http + initialDelaySeconds: 10 + periodSeconds: 10 + liveness: + httpGet: + path: /health + port: http + initialDelaySeconds: 20 + periodSeconds: 15 + +# ============================================================================ +# 数据库配置(PostgreSQL) +# ============================================================================ +# mode: internal(内置)| external(外置) +# architecture: standalone(单实例)| cluster(高可用集群) +# - standalone + internal: 部署单实例 PostgreSQL +# - standalone + external: 连接外置单节点 PostgreSQL +# - cluster 时建议使用 external 模式 +database: + mode: internal + architecture: standalone + + internal: + image: postgres:16-alpine + registry: docker.io + storage: 10Gi + storageClassName: "" + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: 500m + memory: 512Mi + + external: + host: postgres.example.com + port: 5432 + database: skillhub + username: skillhub + password: "" + parameters: "" + # 自定义 JDBC URL(非空时覆盖 host/port/database/parameters 的拼接结果) + # 用于连接外部 PostgreSQL 集群(如 Patroni、JDBC 多主机等) + jdbcUrl: "" + +# ============================================================================ +# Redis 配置 +# ============================================================================ +# mode: internal(内置单实例)| external(外置) +# 注意: Redis Cluster 模式不支持 +redis: + mode: internal + + internal: + image: redis:7-alpine + registry: docker.io + storage: 5Gi + storageClassName: "" + resources: + requests: + cpu: 50m + memory: 128Mi + limits: + cpu: 200m + memory: 256Mi + + external: + host: redis.example.com + port: 6379 + password: "" + sentinel: + enabled: false + masterSet: mymaster + nodes: [] + +# ============================================================================ +# 存储配置 +# ============================================================================ +storage: + provider: local + local: + # 多副本 (replicaCount > 1) 时必须设为 ReadWriteMany,底层需支持 RWX(如 NFS/Longhorn) + accessMode: ReadWriteOnce + storage: 10Gi + storageClassName: "" + s3: + bucket: skillhub-storage + endpoint: "" + region: "" + accessKey: "" + secretKey: "" + +# ============================================================================ +# Bootstrap 管理员 +# ============================================================================ +bootstrapAdmin: + enabled: true + userId: docker-admin + username: admin + displayName: "Platform Admin" + email: admin@example.com + password: "" + +# ============================================================================ +# Session 配置 +# ============================================================================ +session: + cookieSecure: false + +# ============================================================================ +# Spring Profiles +# ============================================================================ +springProfilesActive: docker + +# ============================================================================ +# Secret 配置 +# ============================================================================ +# 使用已有 Secret(优先级高于下方 secrets.* 字段) +# 设置后 chart 不会创建 Secret,而是直接引用该名称 +existingSecret: "" + +secrets: + springDatasourceUrl: "" + springDatasourceUsername: "" + springDatasourcePassword: "" + bootstrapAdminPassword: "" + oauth2GithubClientId: "" + oauth2GithubClientSecret: "" + scannerLlmApiKey: "" + scannerLlmModel: "" + From 8a378018620513bdd47ba39179de0c0ca5083df2 Mon Sep 17 00:00:00 2001 From: jangrui Date: Sat, 16 May 2026 08:40:58 +0800 Subject: [PATCH 03/81] =?UTF-8?q?fix(chart):=20=E4=BF=AE=E5=A4=8D=E5=AE=89?= =?UTF-8?q?=E5=85=A8=E3=80=81=E5=85=BC=E5=AE=B9=E5=8F=8A=E5=8F=AF=E7=BB=B4?= =?UTF-8?q?=E6=8A=A4=E6=80=A7=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - secret.yaml: lookup 检查现有 Secret 避免 upgrade 重新生成密码 - secret.yaml: Redis/S3 凭据通过 Secret 引用,移除明文环境变量 - backend/frontend/scanner: 新增 checksum 注解,配置变更自动触发滚动更新 - backend/frontend/scanner: 镜像地址支持 global.imageRegistry 覆盖 - postgres: internal 模式仅支持单副本,移除伪集群配置 - postgres: 探针用户名改用 POSTGRES_USER 环境变量 - values.yaml: accessMode 默认 ReadWriteMany,tag 指定 v0.2.8 Signed-off-by: jangrui --- .../templates/backend-deployment.yaml | 32 +++++++---- .../templates/frontend-deployment.yaml | 8 ++- .../templates/postgres-statefulset.yaml | 18 +++---- .../templates/scanner-deployment.yaml | 8 ++- charts/skillhub/templates/secret.yaml | 53 +++++++++++++++++-- charts/skillhub/values.yaml | 4 +- 6 files changed, 93 insertions(+), 30 deletions(-) diff --git a/charts/skillhub/templates/backend-deployment.yaml b/charts/skillhub/templates/backend-deployment.yaml index b0a1d832..a215afae 100644 --- a/charts/skillhub/templates/backend-deployment.yaml +++ b/charts/skillhub/templates/backend-deployment.yaml @@ -14,7 +14,11 @@ spec: labels: {{- include "skillhub.server.selectorLabels" . | nindent 8 }} annotations: - {{- toYaml .Values.server.podAnnotations | nindent 8 }} + checksum/config: {{ toYaml (dict "redis" .Values.redis "storage" .Values.storage "database" .Values.database "session" .Values.session "springProfilesActive" .Values.springProfilesActive "bootstrapAdmin" .Values.bootstrapAdmin) | sha256sum }} + checksum/secret: {{ toYaml (dict "secrets" .Values.secrets "bootstrapAdmin" .Values.bootstrapAdmin) | sha256sum }} + {{- range $key, $val := .Values.server.podAnnotations }} + {{ $key }}: {{ $val }} + {{- end }} spec: {{- $secrets := .Values.server.imagePullSecrets | default .Values.global.imagePullSecrets }} {{- if $secrets }} @@ -45,7 +49,7 @@ spec: echo "Redis is ready!" containers: - name: server - image: {{ .Values.images.registry }}/skillhub-server:{{ .Values.images.tag }} + image: {{ .Values.global.imageRegistry | default .Values.images.registry }}/skillhub-server:{{ .Values.images.tag }} imagePullPolicy: {{ .Values.images.pullPolicy }} ports: - containerPort: {{ .Values.service.serverPort }} @@ -83,9 +87,13 @@ spec: name: {{ include "skillhub.fullname" . }}-config key: redis-port - {{- if and (eq .Values.redis.mode "external") .Values.redis.external.password }} + {{- if eq .Values.redis.mode "external" }} - name: SPRING_DATA_REDIS_PASSWORD - value: {{ .Values.redis.external.password }} + valueFrom: + secretKeyRef: + name: {{ include "skillhub.secretName" . }} + key: redis-password + optional: true {{- end }} {{- if .Values.redis.external.sentinel.enabled }} @@ -114,14 +122,18 @@ spec: value: {{ .Values.storage.s3.endpoint }} - name: SKILLHUB_S3_REGION value: {{ .Values.storage.s3.region }} - {{- if .Values.storage.s3.accessKey }} - name: SKILLHUB_S3_ACCESS_KEY - value: {{ .Values.storage.s3.accessKey }} - {{- end }} - {{- if .Values.storage.s3.secretKey }} + valueFrom: + secretKeyRef: + name: {{ include "skillhub.secretName" . }} + key: s3-access-key + optional: true - name: SKILLHUB_S3_SECRET_KEY - value: {{ .Values.storage.s3.secretKey }} - {{- end }} + valueFrom: + secretKeyRef: + name: {{ include "skillhub.secretName" . }} + key: s3-secret-key + optional: true {{- end }} # Scanner diff --git a/charts/skillhub/templates/frontend-deployment.yaml b/charts/skillhub/templates/frontend-deployment.yaml index 9b2942dd..9129a00a 100644 --- a/charts/skillhub/templates/frontend-deployment.yaml +++ b/charts/skillhub/templates/frontend-deployment.yaml @@ -14,7 +14,11 @@ spec: labels: {{- include "skillhub.web.selectorLabels" . | nindent 8 }} annotations: - {{- toYaml .Values.web.podAnnotations | nindent 8 }} + checksum/config: {{ toYaml (dict "redis" .Values.redis "storage" .Values.storage "database" .Values.database "session" .Values.session "springProfilesActive" .Values.springProfilesActive "bootstrapAdmin" .Values.bootstrapAdmin) | sha256sum }} + checksum/secret: {{ toYaml (dict "secrets" .Values.secrets "bootstrapAdmin" .Values.bootstrapAdmin) | sha256sum }} + {{- range $key, $val := .Values.web.podAnnotations }} + {{ $key }}: {{ $val }} + {{- end }} spec: {{- $secrets := .Values.web.imagePullSecrets | default .Values.global.imagePullSecrets }} {{- if $secrets }} @@ -23,7 +27,7 @@ spec: {{- end }} containers: - name: web - image: {{ .Values.images.registry }}/skillhub-web:{{ .Values.images.tag }} + image: {{ .Values.global.imageRegistry | default .Values.images.registry }}/skillhub-web:{{ .Values.images.tag }} imagePullPolicy: {{ .Values.images.pullPolicy }} env: - name: SKILLHUB_API_UPSTREAM diff --git a/charts/skillhub/templates/postgres-statefulset.yaml b/charts/skillhub/templates/postgres-statefulset.yaml index 49c38654..d7616741 100644 --- a/charts/skillhub/templates/postgres-statefulset.yaml +++ b/charts/skillhub/templates/postgres-statefulset.yaml @@ -9,7 +9,7 @@ metadata: app.kubernetes.io/component: database spec: serviceName: {{ include "skillhub.fullname" . }}-postgres - replicas: {{ if eq .Values.database.architecture "cluster" }}3{{ else }}1{{ end }} + replicas: 1 selector: matchLabels: {{- include "skillhub.selectorLabels" . | nindent 6 }} @@ -49,21 +49,17 @@ spec: readinessProbe: exec: command: - - pg_isready - - -U - - skillhub - - -h - - localhost + - sh + - -c + - pg_isready -U "${POSTGRES_USER}" -h localhost initialDelaySeconds: 10 periodSeconds: 10 livenessProbe: exec: command: - - pg_isready - - -U - - skillhub - - -h - - localhost + - sh + - -c + - pg_isready -U "${POSTGRES_USER}" -h localhost initialDelaySeconds: 30 periodSeconds: 15 volumeClaimTemplates: diff --git a/charts/skillhub/templates/scanner-deployment.yaml b/charts/skillhub/templates/scanner-deployment.yaml index 5269783d..461d846b 100644 --- a/charts/skillhub/templates/scanner-deployment.yaml +++ b/charts/skillhub/templates/scanner-deployment.yaml @@ -15,7 +15,11 @@ spec: labels: {{- include "skillhub.scanner.selectorLabels" . | nindent 8 }} annotations: - {{- toYaml .Values.scanner.podAnnotations | nindent 8 }} + checksum/config: {{ toYaml (dict "redis" .Values.redis "storage" .Values.storage "database" .Values.database "session" .Values.session "springProfilesActive" .Values.springProfilesActive "bootstrapAdmin" .Values.bootstrapAdmin) | sha256sum }} + checksum/secret: {{ toYaml (dict "secrets" .Values.secrets "bootstrapAdmin" .Values.bootstrapAdmin) | sha256sum }} + {{- range $key, $val := .Values.scanner.podAnnotations }} + {{ $key }}: {{ $val }} + {{- end }} spec: {{- $secrets := .Values.scanner.imagePullSecrets | default .Values.global.imagePullSecrets }} {{- if $secrets }} @@ -24,7 +28,7 @@ spec: {{- end }} containers: - name: scanner - image: {{ .Values.images.registry }}/skillhub-scanner:{{ .Values.images.tag }} + image: {{ .Values.global.imageRegistry | default .Values.images.registry }}/skillhub-scanner:{{ .Values.images.tag }} imagePullPolicy: {{ .Values.images.pullPolicy }} ports: - containerPort: {{ .Values.service.scannerPort }} diff --git a/charts/skillhub/templates/secret.yaml b/charts/skillhub/templates/secret.yaml index 8ef69132..1a739137 100644 --- a/charts/skillhub/templates/secret.yaml +++ b/charts/skillhub/templates/secret.yaml @@ -1,17 +1,64 @@ {{- if not .Values.existingSecret }} +{{- $secretName := printf "%s-secret" (include "skillhub.fullname" .) }} +{{- $existingSecret := (lookup "v1" "Secret" .Release.Namespace $secretName) }} apiVersion: v1 kind: Secret metadata: - name: {{ include "skillhub.fullname" . }}-secret + name: {{ $secretName }} labels: {{- include "skillhub.labels" . | nindent 4 }} type: Opaque stringData: spring-datasource-url: {{ include "skillhub.jdbcUrl" . | quote }} spring-datasource-username: {{ if eq .Values.database.mode "internal" }}skillhub{{ else }}{{ .Values.database.external.username }}{{ end }} - spring-datasource-password: {{ if eq .Values.database.mode "internal" }}{{ default (randAlphaNum 16) .Values.secrets.springDatasourcePassword }}{{ else }}{{ .Values.database.external.password }}{{ end }} - bootstrap-admin-password: {{ .Values.secrets.bootstrapAdminPassword | default .Values.bootstrapAdmin.password | default (randAlphaNum 16) }} + {{- $dsPwd := "" }} + {{- if $existingSecret }} + {{- $dsPwd = $existingSecret.data.springDatasourcePassword | b64dec }} + {{- else if eq .Values.database.mode "internal" }} + {{- $dsPwd = default (randAlphaNum 16) .Values.secrets.springDatasourcePassword }} + {{- else }} + {{- $dsPwd = .Values.database.external.password }} + {{- end }} + spring-datasource-password: {{ $dsPwd | quote }} + + {{- $redisPwd := "" }} + {{- if $existingSecret }} + {{- $redisPwd = $existingSecret.data.redisPassword | b64dec }} + {{- else }} + {{- $redisPwd = .Values.redis.external.password | default "" }} + {{- end }} + {{- if $redisPwd }} + redis-password: {{ $redisPwd | quote }} + {{- end }} + + {{- $s3Key := "" }} + {{- if $existingSecret }} + {{- $s3Key = $existingSecret.data.s3AccessKey | b64dec }} + {{- else }} + {{- $s3Key = .Values.storage.s3.accessKey | default "" }} + {{- end }} + {{- if $s3Key }} + s3-access-key: {{ $s3Key | quote }} + {{- end }} + + {{- $s3Secret := "" }} + {{- if $existingSecret }} + {{- $s3Secret = $existingSecret.data.s3SecretKey | b64dec }} + {{- else }} + {{- $s3Secret = .Values.storage.s3.secretKey | default "" }} + {{- end }} + {{- if $s3Secret }} + s3-secret-key: {{ $s3Secret | quote }} + {{- end }} + + {{- $baPwd := "" }} + {{- if $existingSecret }} + {{- $baPwd = $existingSecret.data.bootstrapAdminPassword | b64dec }} + {{- else }} + {{- $baPwd = .Values.secrets.bootstrapAdminPassword | default .Values.bootstrapAdmin.password | default (randAlphaNum 16) }} + {{- end }} + bootstrap-admin-password: {{ $baPwd | quote }} {{- if .Values.secrets.oauth2GithubClientId }} oauth2-github-client-id: {{ .Values.secrets.oauth2GithubClientId }} diff --git a/charts/skillhub/values.yaml b/charts/skillhub/values.yaml index 181b99e9..81328495 100644 --- a/charts/skillhub/values.yaml +++ b/charts/skillhub/values.yaml @@ -14,7 +14,7 @@ global: # ============================================================================ images: registry: ghcr.io/iflytek - tag: latest + tag: v0.2.8 pullPolicy: IfNotPresent # ============================================================================ @@ -261,7 +261,7 @@ storage: provider: local local: # 多副本 (replicaCount > 1) 时必须设为 ReadWriteMany,底层需支持 RWX(如 NFS/Longhorn) - accessMode: ReadWriteOnce + accessMode: ReadWriteMany storage: 10Gi storageClassName: "" s3: From 58bb06299381c7ab7f0c36b91519fe94e3b763cf Mon Sep 17 00:00:00 2001 From: jangrui Date: Sat, 16 May 2026 08:53:38 +0800 Subject: [PATCH 04/81] =?UTF-8?q?feat(chart):=20=E9=95=9C=E5=83=8F=20tag?= =?UTF-8?q?=20=E4=B8=8E=20Chart.yaml=20appVersion=20=E8=81=94=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit values.yaml 中 images.tag 留空时自动取 Chart.yaml 的 appVersion, 格式为 v{appVersion}(如 0.2.8 → v0.2.8)。 用户仍可通过 --set images.tag=xxx 显式覆盖。 Signed-off-by: jangrui --- charts/skillhub/templates/backend-deployment.yaml | 2 +- charts/skillhub/templates/frontend-deployment.yaml | 2 +- charts/skillhub/templates/scanner-deployment.yaml | 2 +- charts/skillhub/values.yaml | 3 ++- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/charts/skillhub/templates/backend-deployment.yaml b/charts/skillhub/templates/backend-deployment.yaml index a215afae..ff597477 100644 --- a/charts/skillhub/templates/backend-deployment.yaml +++ b/charts/skillhub/templates/backend-deployment.yaml @@ -49,7 +49,7 @@ spec: echo "Redis is ready!" containers: - name: server - image: {{ .Values.global.imageRegistry | default .Values.images.registry }}/skillhub-server:{{ .Values.images.tag }} + image: {{ .Values.global.imageRegistry | default .Values.images.registry }}/skillhub-server:{{ .Values.images.tag | default (printf "v%s" .Chart.AppVersion) }} imagePullPolicy: {{ .Values.images.pullPolicy }} ports: - containerPort: {{ .Values.service.serverPort }} diff --git a/charts/skillhub/templates/frontend-deployment.yaml b/charts/skillhub/templates/frontend-deployment.yaml index 9129a00a..808654f4 100644 --- a/charts/skillhub/templates/frontend-deployment.yaml +++ b/charts/skillhub/templates/frontend-deployment.yaml @@ -27,7 +27,7 @@ spec: {{- end }} containers: - name: web - image: {{ .Values.global.imageRegistry | default .Values.images.registry }}/skillhub-web:{{ .Values.images.tag }} + image: {{ .Values.global.imageRegistry | default .Values.images.registry }}/skillhub-web:{{ .Values.images.tag | default (printf "v%s" .Chart.AppVersion) }} imagePullPolicy: {{ .Values.images.pullPolicy }} env: - name: SKILLHUB_API_UPSTREAM diff --git a/charts/skillhub/templates/scanner-deployment.yaml b/charts/skillhub/templates/scanner-deployment.yaml index 461d846b..6e91920d 100644 --- a/charts/skillhub/templates/scanner-deployment.yaml +++ b/charts/skillhub/templates/scanner-deployment.yaml @@ -28,7 +28,7 @@ spec: {{- end }} containers: - name: scanner - image: {{ .Values.global.imageRegistry | default .Values.images.registry }}/skillhub-scanner:{{ .Values.images.tag }} + image: {{ .Values.global.imageRegistry | default .Values.images.registry }}/skillhub-scanner:{{ .Values.images.tag | default (printf "v%s" .Chart.AppVersion) }} imagePullPolicy: {{ .Values.images.pullPolicy }} ports: - containerPort: {{ .Values.service.scannerPort }} diff --git a/charts/skillhub/values.yaml b/charts/skillhub/values.yaml index 81328495..d2cd5d21 100644 --- a/charts/skillhub/values.yaml +++ b/charts/skillhub/values.yaml @@ -14,7 +14,8 @@ global: # ============================================================================ images: registry: ghcr.io/iflytek - tag: v0.2.8 + # 留空时自动使用 Chart.yaml 中的 appVersion(带 v 前缀) + tag: "" pullPolicy: IfNotPresent # ============================================================================ From 0ddb39208624a33208c123d825bef3d59114dcc2 Mon Sep 17 00:00:00 2001 From: jangrui Date: Sat, 16 May 2026 09:59:33 +0800 Subject: [PATCH 05/81] =?UTF-8?q?feat(chart):=20=E6=B7=BB=E5=8A=A0=20Helm?= =?UTF-8?q?=20Chart=20=E5=8F=91=E5=B8=83=E5=B7=A5=E4=BD=9C=E6=B5=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: jangrui --- .github/workflows/publish-helm-chart.yml | 6 +- .github/workflows/release-chart.yml | 89 ++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/release-chart.yml diff --git a/.github/workflows/publish-helm-chart.yml b/.github/workflows/publish-helm-chart.yml index 7508143d..ae34678d 100644 --- a/.github/workflows/publish-helm-chart.yml +++ b/.github/workflows/publish-helm-chart.yml @@ -34,9 +34,9 @@ jobs: id: chartver run: | REF="${{ github.ref_name }}" - # Support helm-vX.Y.Z or just vX.Y.Z tags - if [[ "$REF" =~ ^helm-v?([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then - VERSION="${BASH_REMATCH[1]}" + # Support helm-vX.Y.Z, chart-vX.Y.Z, or just vX.Y.Z tags + if [[ "$REF" =~ ^(helm|chart)-v?([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then + VERSION="${BASH_REMATCH[2]}" elif [[ "$REF" =~ ^v?([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then VERSION="${BASH_REMATCH[1]}" else diff --git a/.github/workflows/release-chart.yml b/.github/workflows/release-chart.yml new file mode 100644 index 00000000..03cec094 --- /dev/null +++ b/.github/workflows/release-chart.yml @@ -0,0 +1,89 @@ +name: Release Helm Chart + +on: + push: + tags: + - 'v*' + workflow_dispatch: + +concurrency: + group: release-helm-chart + cancel-in-progress: true + +permissions: + contents: write + packages: write + +jobs: + release: + if: ${{ !startsWith(github.ref_name, 'chart-v') }} + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Helm + uses: azure/setup-helm@v4 + with: + version: latest + + - name: Extract version + id: version + run: | + REF="${{ github.ref_name }}" + APP="${REF#v}" + echo "app=$APP" >> "$GITHUB_OUTPUT" + + - name: Update Chart.yaml appVersion + id: chart + working-directory: charts/skillhub + run: | + CHART_VER=$(helm show chart . | grep '^version:' | awk '{print $2}') + echo "chartVersion=$CHART_VER" >> "$GITHUB_OUTPUT" + echo "appVersion=${{ steps.version.outputs.app }}" >> "$GITHUB_OUTPUT" + + sed -i "s/^appVersion:.*/appVersion: ${{ steps.version.outputs.app }}/" Chart.yaml + + - name: Validate chart + working-directory: charts/skillhub + run: helm lint + + - name: Commit and push to main + run: | + git config user.name "skillhub-bot" + git config user.email "bot@skillhub.dev" + git checkout -b release-tmp + git add charts/skillhub/Chart.yaml + git commit -m "chore: update appVersion to ${{ steps.version.outputs.app }}" + git push origin HEAD:main + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Package and publish chart + working-directory: charts/skillhub + run: | + helm package . --version "${{ steps.chart.outputs.chartVersion }}" \ + --destination /tmp/helm-charts + helm push /tmp/helm-charts/skillhub-${{ steps.chart.outputs.chartVersion }}.tgz \ + oci://ghcr.io/${{ github.repository_owner }}/charts + + - name: Generate artifact attestation + uses: actions/attest-build-provenance@v2 + with: + subject-path: /tmp/helm-charts/skillhub-${{ steps.chart.outputs.chartVersion }}.tgz + + - name: Upload chart artifact + uses: actions/upload-artifact@v4 + with: + name: skillhub-${{ steps.chart.outputs.chartVersion }}.tgz + path: /tmp/helm-charts/skillhub-${{ steps.chart.outputs.chartVersion }}.tgz + retention-days: 90 + From 396ae4a55f0596f19395c9f1664d5ddd0c2c7110 Mon Sep 17 00:00:00 2001 From: jangrui Date: Mon, 18 May 2026 01:15:01 +0800 Subject: [PATCH 06/81] =?UTF-8?q?feat(chart):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E7=BB=84=E4=BB=B6=E7=BA=A7=E9=95=9C=E5=83=8F=E6=A0=87=E7=AD=BE?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E4=BB=A5=E6=94=AF=E6=8C=81=E4=B8=AA=E6=80=A7?= =?UTF-8?q?=E5=8C=96=E9=95=9C=E5=83=8F=E7=89=88=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: jangrui --- charts/skillhub/templates/backend-deployment.yaml | 2 +- charts/skillhub/templates/frontend-deployment.yaml | 2 +- charts/skillhub/templates/scanner-deployment.yaml | 2 +- charts/skillhub/values.yaml | 9 +++++++++ 4 files changed, 12 insertions(+), 3 deletions(-) diff --git a/charts/skillhub/templates/backend-deployment.yaml b/charts/skillhub/templates/backend-deployment.yaml index ff597477..f277681e 100644 --- a/charts/skillhub/templates/backend-deployment.yaml +++ b/charts/skillhub/templates/backend-deployment.yaml @@ -49,7 +49,7 @@ spec: echo "Redis is ready!" containers: - name: server - image: {{ .Values.global.imageRegistry | default .Values.images.registry }}/skillhub-server:{{ .Values.images.tag | default (printf "v%s" .Chart.AppVersion) }} + image: {{ .Values.global.imageRegistry | default .Values.images.registry }}/skillhub-server:{{ .Values.server.image.tag | default .Values.images.tag | default (printf "v%s" .Chart.AppVersion) }} imagePullPolicy: {{ .Values.images.pullPolicy }} ports: - containerPort: {{ .Values.service.serverPort }} diff --git a/charts/skillhub/templates/frontend-deployment.yaml b/charts/skillhub/templates/frontend-deployment.yaml index 808654f4..89fe2f05 100644 --- a/charts/skillhub/templates/frontend-deployment.yaml +++ b/charts/skillhub/templates/frontend-deployment.yaml @@ -27,7 +27,7 @@ spec: {{- end }} containers: - name: web - image: {{ .Values.global.imageRegistry | default .Values.images.registry }}/skillhub-web:{{ .Values.images.tag | default (printf "v%s" .Chart.AppVersion) }} + image: {{ .Values.global.imageRegistry | default .Values.images.registry }}/skillhub-web:{{ .Values.web.image.tag | default .Values.images.tag | default (printf "v%s" .Chart.AppVersion) }} imagePullPolicy: {{ .Values.images.pullPolicy }} env: - name: SKILLHUB_API_UPSTREAM diff --git a/charts/skillhub/templates/scanner-deployment.yaml b/charts/skillhub/templates/scanner-deployment.yaml index 6e91920d..a3496fb9 100644 --- a/charts/skillhub/templates/scanner-deployment.yaml +++ b/charts/skillhub/templates/scanner-deployment.yaml @@ -28,7 +28,7 @@ spec: {{- end }} containers: - name: scanner - image: {{ .Values.global.imageRegistry | default .Values.images.registry }}/skillhub-scanner:{{ .Values.images.tag | default (printf "v%s" .Chart.AppVersion) }} + image: {{ .Values.global.imageRegistry | default .Values.images.registry }}/skillhub-scanner:{{ .Values.scanner.image.tag | default .Values.images.tag | default (printf "v%s" .Chart.AppVersion) }} imagePullPolicy: {{ .Values.images.pullPolicy }} ports: - containerPort: {{ .Values.service.scannerPort }} diff --git a/charts/skillhub/values.yaml b/charts/skillhub/values.yaml index d2cd5d21..015c913e 100644 --- a/charts/skillhub/values.yaml +++ b/charts/skillhub/values.yaml @@ -64,6 +64,9 @@ ingress: # 应用组件配置 # ============================================================================ server: + # 组件级镜像标签(留空时使用全局 images.tag) + image: + tag: "" resources: requests: cpu: 500m @@ -111,6 +114,9 @@ server: periodSeconds: 15 web: + # 组件级镜像标签(留空时使用全局 images.tag) + image: + tag: "" resources: requests: cpu: 100m @@ -151,6 +157,9 @@ web: scanner: enabled: true + # 组件级镜像标签(留空时使用全局 images.tag) + image: + tag: "" resources: requests: cpu: 100m From 6ed5fb34dc6be4dcabc052ba036d751040fa1b7b Mon Sep 17 00:00:00 2001 From: jangrui Date: Mon, 18 May 2026 11:05:21 +0800 Subject: [PATCH 07/81] =?UTF-8?q?commit=20-m=20"fix(ci):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20helm=20lint=20=E7=BC=BA=E5=B0=91=E8=B7=AF=E5=BE=84?= =?UTF-8?q?=E5=8F=82=E6=95=B0=E5=92=8C=20grep=20-c=20=E5=9C=A8=20bash=20-e?= =?UTF-8?q?=20=E4=B8=8B=E7=9A=84=E9=80=80=E5=87=BA=E7=A0=81=E9=97=AE?= =?UTF-8?q?=E9=A2=98"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: jangrui --- .github/workflows/pr-helm-chart.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pr-helm-chart.yml b/.github/workflows/pr-helm-chart.yml index 42e0b2c6..a3e880bf 100644 --- a/.github/workflows/pr-helm-chart.yml +++ b/.github/workflows/pr-helm-chart.yml @@ -37,7 +37,7 @@ jobs: version: latest - name: Lint chart - run: helm lint + run: helm lint . - name: Validate chart metadata run: | @@ -126,7 +126,7 @@ jobs: - name: Validate no empty resources run: | - RESOURCES=$(helm template test-release . ${{ matrix.scenario.args }} | grep -c '^kind:') + RESOURCES=$(helm template test-release . ${{ matrix.scenario.args }} 2>/dev/null | grep -c '^kind:' || true) echo "Rendered $RESOURCES resources for ${{ matrix.scenario.name }}" if [ "$RESOURCES" -eq 0 ]; then echo "ERROR: No resources rendered for ${{ matrix.scenario.name }}" From 5909bc1a1a63afe1848e8cacf2c168ee7a74a3ab Mon Sep 17 00:00:00 2001 From: jangrui Date: Mon, 18 May 2026 16:54:00 +0800 Subject: [PATCH 08/81] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20Helm=EF=BC=9A?= =?UTF-8?q?=E8=A7=A3=E5=86=B3=20CI=20=E6=B5=81=E6=B0=B4=E7=BA=BF=E6=95=85?= =?UTF-8?q?=E9=9A=9C=E4=B8=8E=E6=A8=A1=E6=9D=BF=E8=BE=B9=E7=95=8C=E5=9C=BA?= =?UTF-8?q?=E6=99=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复 S3 存储模式下卷挂载条件渲染 - CI 多行参数不再被 YAML 尾随换行符截断 - 移除未使用的 database.architecture 字段 - 简化 Helm Chart 发布工作流 Signed-off-by: jangrui --- .github/workflows/pr-helm-chart.yml | 13 ++- .github/workflows/publish-chart.yml | 61 +++++++++++++ .github/workflows/publish-helm-chart.yml | 81 ----------------- .github/workflows/release-chart.yml | 89 ------------------- charts/skillhub/Chart.yaml | 4 +- .../templates/backend-deployment.yaml | 4 + charts/skillhub/values.yaml | 9 +- 7 files changed, 75 insertions(+), 186 deletions(-) create mode 100644 .github/workflows/publish-chart.yml delete mode 100644 .github/workflows/publish-helm-chart.yml delete mode 100644 .github/workflows/release-chart.yml diff --git a/.github/workflows/pr-helm-chart.yml b/.github/workflows/pr-helm-chart.yml index a3e880bf..d8b1cf62 100644 --- a/.github/workflows/pr-helm-chart.yml +++ b/.github/workflows/pr-helm-chart.yml @@ -67,25 +67,25 @@ jobs: args: "" - name: external-db-redis description: 外置 PostgreSQL + Redis - args: > + args: >- --set database.mode=external --set redis.mode=external - name: external-sentinel description: 外置 DB + Redis 哨兵模式 - args: > + args: >- --set database.mode=external --set redis.mode=external --set redis.external.sentinel.enabled=true --set redis.external.sentinel.nodes="{10.0.0.1:26379,10.0.0.2:26379}" - name: ingress-tls-certmanager description: Ingress + TLS + cert-manager - args: > + args: >- --set ingress.enabled=true --set ingress.tls.enabled=true --set ingress.certManager.enabled=true - name: s3-storage description: S3 存储 - args: > + args: >- --set storage.provider=s3 --set storage.s3.bucket=test-bucket --set storage.s3.endpoint=s3.amazonaws.com @@ -96,12 +96,9 @@ jobs: - name: scanner-disabled description: 禁用 Scanner args: --set scanner.enabled=false - - name: db-cluster - description: PostgreSQL cluster 模式 - args: --set database.architecture=cluster - name: hpa-pdb description: HPA + PDB 开启 - args: > + args: >- --set server.autoscaling.enabled=true --set web.autoscaling.enabled=true --set scanner.autoscaling.enabled=true diff --git a/.github/workflows/publish-chart.yml b/.github/workflows/publish-chart.yml new file mode 100644 index 00000000..b3b4acc7 --- /dev/null +++ b/.github/workflows/publish-chart.yml @@ -0,0 +1,61 @@ +name: Publish Helm Chart + +on: + release: + types: [published] + workflow_dispatch: + +concurrency: + group: publish-chart-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + packages: write + +jobs: + release: + runs-on: ubuntu-latest + defaults: + run: + working-directory: charts/skillhub + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Helm + uses: azure/setup-helm@v4 + with: + version: latest + + - name: Parse version from tag + id: ver + run: | + REF="${{ github.ref_name }}" + # 兼容 v0.2.9、chart-v0.2.9、helm-v0.2.9 三种标签格式 + if [[ "$REF" =~ ^(helm|chart)-v([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then + VER="${BASH_REMATCH[2]}" + else + VER="${REF#v}" + fi + echo "version=$VER" >> "$GITHUB_OUTPUT" + + - name: Lint chart + run: helm lint . + + - name: Package and push + run: | + helm package . \ + --version "${{ steps.ver.outputs.version }}" \ + --app-version "${{ steps.ver.outputs.version }}" \ + --destination /tmp/helm-charts + helm push /tmp/helm-charts/skillhub-${{ steps.ver.outputs.version }}.tgz \ + oci://ghcr.io/${{ github.repository_owner }}/charts + + - name: Upload chart artifact + uses: actions/upload-artifact@v4 + with: + name: skillhub-${{ steps.ver.outputs.version }}.tgz + path: /tmp/helm-charts/skillhub-${{ steps.ver.outputs.version }}.tgz + retention-days: 90 \ No newline at end of file diff --git a/.github/workflows/publish-helm-chart.yml b/.github/workflows/publish-helm-chart.yml deleted file mode 100644 index ae34678d..00000000 --- a/.github/workflows/publish-helm-chart.yml +++ /dev/null @@ -1,81 +0,0 @@ -name: Publish Helm Chart - -on: - release: - types: [published] - workflow_dispatch: - -concurrency: - group: publish-helm-chart-${{ github.ref }} - cancel-in-progress: true - -permissions: - contents: read - packages: write - -jobs: - package: - name: Package and Publish - runs-on: ubuntu-latest - defaults: - run: - working-directory: charts/skillhub - - steps: - - name: Check out repository - uses: actions/checkout@v4 - - - name: Set up Helm - uses: azure/setup-helm@v4 - with: - version: latest - - - name: Extract chart version from tag - id: chartver - run: | - REF="${{ github.ref_name }}" - # Support helm-vX.Y.Z, chart-vX.Y.Z, or just vX.Y.Z tags - if [[ "$REF" =~ ^(helm|chart)-v?([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then - VERSION="${BASH_REMATCH[2]}" - elif [[ "$REF" =~ ^v?([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then - VERSION="${BASH_REMATCH[1]}" - else - # Fallback: use chart.yaml version - VERSION=$(helm show chart . | grep '^version:' | awk '{print $2}') - fi - echo "version=$VERSION" >> "$GITHUB_OUTPUT" - echo "Packaging chart version: $VERSION" - - - name: Lint chart - run: helm lint - - - name: Package chart - run: | - helm package . --version "${{ steps.chartver.outputs.version }}" \ - --destination /tmp/helm-charts - echo "Packaged:" - ls -la /tmp/helm-charts/ - - - name: Log in to GHCR - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Push chart to GHCR OCI - run: | - helm push /tmp/helm-charts/skillhub-${{ steps.chartver.outputs.version }}.tgz \ - oci://ghcr.io/${{ github.repository_owner }}/charts - - - name: Generate artifact attestation - uses: actions/attest-build-provenance@v2 - with: - subject-path: /tmp/helm-charts/skillhub-${{ steps.chartver.outputs.version }}.tgz - - - name: Upload chart artifact - uses: actions/upload-artifact@v4 - with: - name: skillhub-${{ steps.chartver.outputs.version }}.tgz - path: /tmp/helm-charts/skillhub-${{ steps.chartver.outputs.version }}.tgz - retention-days: 90 diff --git a/.github/workflows/release-chart.yml b/.github/workflows/release-chart.yml deleted file mode 100644 index 03cec094..00000000 --- a/.github/workflows/release-chart.yml +++ /dev/null @@ -1,89 +0,0 @@ -name: Release Helm Chart - -on: - push: - tags: - - 'v*' - workflow_dispatch: - -concurrency: - group: release-helm-chart - cancel-in-progress: true - -permissions: - contents: write - packages: write - -jobs: - release: - if: ${{ !startsWith(github.ref_name, 'chart-v') }} - runs-on: ubuntu-latest - - steps: - - name: Check out repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Set up Helm - uses: azure/setup-helm@v4 - with: - version: latest - - - name: Extract version - id: version - run: | - REF="${{ github.ref_name }}" - APP="${REF#v}" - echo "app=$APP" >> "$GITHUB_OUTPUT" - - - name: Update Chart.yaml appVersion - id: chart - working-directory: charts/skillhub - run: | - CHART_VER=$(helm show chart . | grep '^version:' | awk '{print $2}') - echo "chartVersion=$CHART_VER" >> "$GITHUB_OUTPUT" - echo "appVersion=${{ steps.version.outputs.app }}" >> "$GITHUB_OUTPUT" - - sed -i "s/^appVersion:.*/appVersion: ${{ steps.version.outputs.app }}/" Chart.yaml - - - name: Validate chart - working-directory: charts/skillhub - run: helm lint - - - name: Commit and push to main - run: | - git config user.name "skillhub-bot" - git config user.email "bot@skillhub.dev" - git checkout -b release-tmp - git add charts/skillhub/Chart.yaml - git commit -m "chore: update appVersion to ${{ steps.version.outputs.app }}" - git push origin HEAD:main - - - name: Log in to GHCR - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Package and publish chart - working-directory: charts/skillhub - run: | - helm package . --version "${{ steps.chart.outputs.chartVersion }}" \ - --destination /tmp/helm-charts - helm push /tmp/helm-charts/skillhub-${{ steps.chart.outputs.chartVersion }}.tgz \ - oci://ghcr.io/${{ github.repository_owner }}/charts - - - name: Generate artifact attestation - uses: actions/attest-build-provenance@v2 - with: - subject-path: /tmp/helm-charts/skillhub-${{ steps.chart.outputs.chartVersion }}.tgz - - - name: Upload chart artifact - uses: actions/upload-artifact@v4 - with: - name: skillhub-${{ steps.chart.outputs.chartVersion }}.tgz - path: /tmp/helm-charts/skillhub-${{ steps.chart.outputs.chartVersion }}.tgz - retention-days: 90 - diff --git a/charts/skillhub/Chart.yaml b/charts/skillhub/Chart.yaml index dd34d1fc..c149c49d 100644 --- a/charts/skillhub/Chart.yaml +++ b/charts/skillhub/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: skillhub description: Self-hosted, open-source agent skill registry for enterprises. type: application -version: 0.1.0 -appVersion: 0.2.8 +version: 0.2.9 +appVersion: 0.2.9 keywords: - skillhub - ai diff --git a/charts/skillhub/templates/backend-deployment.yaml b/charts/skillhub/templates/backend-deployment.yaml index f277681e..677d9a37 100644 --- a/charts/skillhub/templates/backend-deployment.yaml +++ b/charts/skillhub/templates/backend-deployment.yaml @@ -216,9 +216,11 @@ spec: {{- toYaml . | nindent 12 }} {{- end }} + {{- if eq .Values.storage.provider "local" }} volumeMounts: - name: skillhub-storage mountPath: /var/lib/skillhub/storage + {{- end }} resources: {{- toYaml .Values.server.resources | nindent 12 }} @@ -230,10 +232,12 @@ spec: livenessProbe: {{- toYaml .Values.server.probes.liveness | nindent 12 }} + {{- if eq .Values.storage.provider "local" }} volumes: - name: skillhub-storage persistentVolumeClaim: claimName: {{ include "skillhub.fullname" . }}-storage-pvc + {{- end }} {{- with .Values.server.nodeSelector }} nodeSelector: {{- toYaml . | nindent 8 }} diff --git a/charts/skillhub/values.yaml b/charts/skillhub/values.yaml index 015c913e..5edd8153 100644 --- a/charts/skillhub/values.yaml +++ b/charts/skillhub/values.yaml @@ -201,14 +201,11 @@ scanner: # ============================================================================ # 数据库配置(PostgreSQL) # ============================================================================ -# mode: internal(内置)| external(外置) -# architecture: standalone(单实例)| cluster(高可用集群) -# - standalone + internal: 部署单实例 PostgreSQL -# - standalone + external: 连接外置单节点 PostgreSQL -# - cluster 时建议使用 external 模式 +# mode: internal(内置单实例)| external(外置) +# 内置模式部署单实例 PostgreSQL。 +# 如需高可用集群,请使用 external 模式连接外部集群。 database: mode: internal - architecture: standalone internal: image: postgres:16-alpine From 906c7f9884d275863f5ef43fbc102b6f3e96a514 Mon Sep 17 00:00:00 2001 From: jangrui Date: Mon, 1 Jun 2026 06:54:58 +0800 Subject: [PATCH 09/81] =?UTF-8?q?feat(chart):=20=E9=9B=86=E6=88=90=20Bitna?= =?UTF-8?q?mi=20=E7=BB=84=E4=BB=B6=E5=B9=B6=E9=87=8D=E6=9E=84=E9=AB=98?= =?UTF-8?q?=E5=8F=AF=E7=94=A8=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 使用 Bitnami PostgreSQL/Redis subchart 替代内置 StatefulSet - 新增 sentinel 模式密码分离(redis-sentinel-password) - 修复证书 secretName 与 Ingress 动态一致性 - 清理 ConfigMap 未引用字段,Service 模板去重 - CI 矩阵修复 sentinel 参数并扩展至 9 场景 - 命名空间硬编码替换为动态 $.Release.Namespace Signed-off-by: jangrui --- .github/workflows/pr-helm-chart.yml | 70 +-- .github/workflows/publish-chart.yml | 8 +- charts/skillhub/.helmignore | 3 - charts/skillhub/Chart.lock | 9 + charts/skillhub/Chart.yaml | 17 +- charts/skillhub/README.md | 161 ++++++ charts/skillhub/charts/postgresql-18.6.10.tgz | Bin 0 -> 89599 bytes charts/skillhub/charts/redis-25.5.3.tgz | Bin 0 -> 104594 bytes charts/skillhub/templates/_helpers.tpl | 148 ++++-- charts/skillhub/templates/certificate.yaml | 5 +- charts/skillhub/templates/configmap.yaml | 21 +- charts/skillhub/templates/hpa.yaml | 89 +--- charts/skillhub/templates/ingress.yaml | 4 +- charts/skillhub/templates/pdb.yaml | 41 +- .../templates/postgres-statefulset.yaml | 96 ---- charts/skillhub/templates/pvc.yaml | 20 +- .../skillhub/templates/redis-statefulset.yaml | 83 --- .../templates/scanner-deployment.yaml | 13 +- charts/skillhub/templates/secret.yaml | 106 ++-- ...deployment.yaml => server-deployment.yaml} | 88 +++- charts/skillhub/templates/services.yaml | 82 ++- ...nd-deployment.yaml => web-deployment.yaml} | 15 +- charts/skillhub/values.yaml | 471 +++++++++++------- 23 files changed, 845 insertions(+), 705 deletions(-) create mode 100644 charts/skillhub/Chart.lock create mode 100644 charts/skillhub/README.md create mode 100644 charts/skillhub/charts/postgresql-18.6.10.tgz create mode 100644 charts/skillhub/charts/redis-25.5.3.tgz delete mode 100644 charts/skillhub/templates/postgres-statefulset.yaml delete mode 100644 charts/skillhub/templates/redis-statefulset.yaml rename charts/skillhub/templates/{backend-deployment.yaml => server-deployment.yaml} (68%) rename charts/skillhub/templates/{frontend-deployment.yaml => web-deployment.yaml} (66%) diff --git a/.github/workflows/pr-helm-chart.yml b/.github/workflows/pr-helm-chart.yml index d8b1cf62..52fa7366 100644 --- a/.github/workflows/pr-helm-chart.yml +++ b/.github/workflows/pr-helm-chart.yml @@ -62,21 +62,28 @@ jobs: fail-fast: false matrix: scenario: - - name: internal-default - description: 内置 PostgreSQL + Redis + - name: bitnami-default + description: Bitnami 默认配置 args: "" - name: external-db-redis - description: 外置 PostgreSQL + Redis + description: 外部 PostgreSQL + Redis args: >- - --set database.mode=external - --set redis.mode=external - - name: external-sentinel - description: 外置 DB + Redis 哨兵模式 + --set postgresql.enabled=false + --set redis.enabled=false + --set externalDatabase.host=postgres.example.com + --set externalDatabase.password=secret + --set externalRedis.host=redis.example.com + --set externalRedis.password=secret + - name: postgresql-replication + description: PostgreSQL 主从 + Redis 主从 args: >- - --set database.mode=external - --set redis.mode=external - --set redis.external.sentinel.enabled=true - --set redis.external.sentinel.nodes="{10.0.0.1:26379,10.0.0.2:26379}" + --set postgresql.architecture=replication + --set redis.architecture=replication + - name: redis-sentinel + description: Redis 哨兵模式 + args: >- + --set redis.architecture=replication + --set redis.sentinel.enabled=true - name: ingress-tls-certmanager description: Ingress + TLS + cert-manager args: >- @@ -86,18 +93,20 @@ jobs: - name: s3-storage description: S3 存储 args: >- - --set storage.provider=s3 - --set storage.s3.bucket=test-bucket - --set storage.s3.endpoint=s3.amazonaws.com - --set storage.s3.region=us-east-1 + --set s3.enabled=true + --set s3.bucket=test-bucket + --set s3.endpoint=s3.amazonaws.com + --set s3.region=us-east-1 - name: external-secret - description: 引用已有 Secret - args: --set existingSecret=my-custom-secret + description: 外部 Secret + args: >- + --set existingSecret=my-custom-secret - name: scanner-disabled description: 禁用 Scanner - args: --set scanner.enabled=false + args: >- + --set scanner.enabled=false - name: hpa-pdb - description: HPA + PDB 开启 + description: HPA + PDB args: >- --set server.autoscaling.enabled=true --set web.autoscaling.enabled=true @@ -118,26 +127,21 @@ jobs: - name: Render template - ${{ matrix.scenario.name }} run: | echo "## ${{ matrix.scenario.description }}" - helm template test-release . ${{ matrix.scenario.args }} > /dev/null + helm template test-release . ${{ matrix.scenario.args }} > /tmp/rendered.yaml echo "✅ Template rendered successfully" - - name: Validate no empty resources + - name: Validate resources run: | - RESOURCES=$(helm template test-release . ${{ matrix.scenario.args }} 2>/dev/null | grep -c '^kind:' || true) + RESOURCES=$(grep -c '^kind:' /tmp/rendered.yaml || true) echo "Rendered $RESOURCES resources for ${{ matrix.scenario.name }}" if [ "$RESOURCES" -eq 0 ]; then echo "ERROR: No resources rendered for ${{ matrix.scenario.name }}" exit 1 fi - - - name: Validate resource names are well-formed - run: | - helm template test-release . ${{ matrix.scenario.args }} | \ - grep -E '^ name:' | \ - while read -r line; do - if echo "$line" | grep -qP '\{\{'; then - echo "ERROR: Unrendered template in name: $line" - exit 1 - fi - done + grep -E '^ name:' /tmp/rendered.yaml | while read -r line; do + if echo "$line" | grep -qP '\{\{'; then + echo "ERROR: Unrendered template in name: $line" + exit 1 + fi + done echo "✅ All resource names properly rendered" diff --git a/.github/workflows/publish-chart.yml b/.github/workflows/publish-chart.yml index b3b4acc7..1e4994ae 100644 --- a/.github/workflows/publish-chart.yml +++ b/.github/workflows/publish-chart.yml @@ -29,6 +29,12 @@ jobs: with: version: latest + - name: Verify dependencies + run: helm dependency build . + + - name: Login to GHCR + run: echo "${{ secrets.GITHUB_TOKEN }}" | helm registry login ghcr.io -u ${{ github.actor }} --password-stdin + - name: Parse version from tag id: ver run: | @@ -58,4 +64,4 @@ jobs: with: name: skillhub-${{ steps.ver.outputs.version }}.tgz path: /tmp/helm-charts/skillhub-${{ steps.ver.outputs.version }}.tgz - retention-days: 90 \ No newline at end of file + retention-days: 90 diff --git a/charts/skillhub/.helmignore b/charts/skillhub/.helmignore index 0df7bb7c..55d10a21 100644 --- a/charts/skillhub/.helmignore +++ b/charts/skillhub/.helmignore @@ -19,6 +19,3 @@ CLAUDE.md # CI .github/ - -# Template artifacts -*.tgz diff --git a/charts/skillhub/Chart.lock b/charts/skillhub/Chart.lock new file mode 100644 index 00000000..fc2e72e3 --- /dev/null +++ b/charts/skillhub/Chart.lock @@ -0,0 +1,9 @@ +dependencies: +- name: postgresql + repository: oci://registry-1.docker.io/bitnamicharts + version: 18.6.10 +- name: redis + repository: oci://registry-1.docker.io/bitnamicharts + version: 25.5.3 +digest: sha256:20336709650cc49c81b8b4afdac0efeeea00cb88ff87820be9272ef5a7d545cc +generated: "2026-05-31T08:35:16.614393+08:00" diff --git a/charts/skillhub/Chart.yaml b/charts/skillhub/Chart.yaml index c149c49d..ce981b6a 100644 --- a/charts/skillhub/Chart.yaml +++ b/charts/skillhub/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: skillhub description: Self-hosted, open-source agent skill registry for enterprises. type: application -version: 0.2.9 -appVersion: 0.2.9 +version: 0.3.0 +appVersion: 0.3.0 keywords: - skillhub - ai @@ -11,3 +11,16 @@ keywords: home: https://github.com/iflytek/skillhub sources: - https://github.com/iflytek/skillhub + +dependencies: + # PostgreSQL - Bitnami 官方 chart,支持 HA、备份、监控 + - name: postgresql + version: "18.6.10" + repository: "oci://registry-1.docker.io/bitnamicharts" + condition: postgresql.enabled + + # Redis - Bitnami 官方 chart,支持集群模式、哨兵模式 + - name: redis + version: "25.5.3" + repository: "oci://registry-1.docker.io/bitnamicharts" + condition: redis.enabled diff --git a/charts/skillhub/README.md b/charts/skillhub/README.md new file mode 100644 index 00000000..b74d6985 --- /dev/null +++ b/charts/skillhub/README.md @@ -0,0 +1,161 @@ +# SkillHub Helm Chart + +企业级 AI 技能中心私有化部署方案,基于 Kubernetes 和 Helm。 + +## 特性 + +- **微服务架构**:Server(Spring Boot)、Web(Nginx)、Scanner 分离部署 +- **高可用**:支持 HPA 自动扩缩容、PDB Pod 中断预算 +- **数据层**:使用 Bitnami PostgreSQL/Redis,支持主从复制、哨兵模式 +- **安全**:TLS 证书管理、Secret 密码保护、NetworkPolicy +- **可观测性**:内置 Prometheus metrics exporter + +## 快速开始 + +### 前置要求 + +- Kubernetes 1.24+ +- Helm 3.8+ +- kubectl configured + +### 安装 + +```bash +kubectl create namespace skillhub + +helm -n skillhub upgrade -i skillhub ./charts/skillhub \ + --set bootstrapAdmin.password=your-secure-password +``` + +### 高可用模式 + +```bash +helm -n skillhub upgrade -i skillhub ./charts/skillhub \ + --set bootstrapAdmin.password=your-secure-password \ + --set postgresql.architecture=replication \ + --set redis.architecture=replication +``` + +### 外部数据库模式 + +```bash +helm -n skillhub upgrade -i skillhub ./charts/skillhub \ + --set bootstrapAdmin.password=your-secure-password \ + --set postgresql.enabled=false \ + --set redis.enabled=false \ + --set externalDatabase.host=postgres.example.com \ + --set externalDatabase.port=5432 \ + --set externalDatabase.database=skillhub \ + --set externalDatabase.username=skillhub \ + --set externalDatabase.password=your-db-password \ + --set externalRedis.host=redis.example.com \ + --set externalRedis.port=6379 \ + --set externalRedis.password=your-redis-password +``` + +## 配置参考 + +### 副本数配置 + +| 参数 | 描述 | 默认值 | +|------|------|--------| +| `server.replicaCount` | Server 副本数 | `1` | +| `web.replicaCount` | Web 副本数 | `1` | +| `scanner.replicaCount` | Scanner 副本数 | `1` | + +```bash +# 差异化副本配置 +helm -n skillhub upgrade -i skillhub ./charts/skillhub \ + --set server.replicaCount=3 \ + --set web.replicaCount=2 \ + --set scanner.replicaCount=1 +``` + +### 服务配置 + +| 参数 | 描述 | 默认值 | +|------|------|--------| +| `server.service.type` | Server Service 类型 | `ClusterIP` | +| `server.service.port` | Server 端口 | `8080` | +| `web.service.type` | Web Service 类型 | `ClusterIP` | +| `web.service.port` | Web 端口 | `80` | +| `scanner.service.port` | Scanner 端口 | `8000` | + +### 数据库配置 + +| 参数 | 描述 | 默认值 | +|------|------|--------| +| `postgresql.enabled` | 启用内置 PostgreSQL | `true` | +| `postgresql.architecture` | 架构模式 | `standalone` | +| `redis.enabled` | 启用内置 Redis | `true` | +| `redis.architecture` | 架构模式 | `standalone` | + +### 存储配置 + +| 参数 | 描述 | 默认值 | +|------|------|--------| +| `server.storage.accessMode` | 访问模式:ReadWriteOnce(单副本)或 ReadWriteMany(多副本) | `""` | +| `server.storage.size` | PVC 大小 | `10Gi` | +| `server.storage.storageClassName` | StorageClass | `""` | + +```bash +# 默认使用本地 PVC +helm -n skillhub upgrade -i skillhub ./charts/skillhub \ + --set bootstrapAdmin.password=your-secure-password +``` + +### S3 对象存储 + +`s3.enabled=true` 时,不创建 PVC,应用使用 S3 作为存储后端。 + +| 参数 | 描述 | 默认值 | +|------|------|--------| +| `s3.enabled` | 启用 S3 | `false` | +| `s3.bucket` | Bucket 名称 | `skillhub-storage` | +| `s3.endpoint` | S3 端点 | `""` | +| `s3.region` | 区域 | `us-east-1` | +| `s3.accessKey` | Access Key | `""` | +| `s3.secretKey` | Secret Key | `""` | + +```bash +helm -n skillhub upgrade -i skillhub ./charts/skillhub \ + --set bootstrapAdmin.password=your-secure-password \ + --set s3.enabled=true \ + --set s3.bucket=your-bucket \ + --set s3.endpoint=s3.amazonaws.com \ + --set s3.region=us-east-1 \ + --set s3.accessKey=your-access-key \ + --set s3.secretKey=your-secret-key +``` + +### Ingress + TLS + +```bash +helm -n skillhub upgrade -i skillhub ./charts/skillhub \ + --set ingress.enabled=true \ + --set ingress.host=skills.example.com \ + --set ingress.tls.enabled=true \ + --set ingress.certManager.enabled=true +``` + +### 自动扩缩容 + +```bash +helm -n skillhub upgrade -i skillhub ./charts/skillhub \ + --set server.autoscaling.enabled=true \ + --set server.autoscaling.minReplicas=2 \ + --set server.autoscaling.maxReplicas=10 +``` + +## 卸载 + +```bash +helm -n skillhub uninstall skillhub +``` + +## 依赖 + +| 依赖 | 版本 | +|------|------| +| postgresql | 18.6.10 | +| redis | 25.5.3 | diff --git a/charts/skillhub/charts/postgresql-18.6.10.tgz b/charts/skillhub/charts/postgresql-18.6.10.tgz new file mode 100644 index 0000000000000000000000000000000000000000..cb6f57c3242900e4ff28bbd0a53a1840820f8bfa GIT binary patch literal 89599 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�OuVA9)@w-MF%?qq$p$RHWm?^FoY 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�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 AA9h=6i(WkBb%bJhezs8fR)KdmN3 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%0D$wmkMse92F=F)Y!Ic>H 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 literal 0 HcmV?d00001 diff --git a/charts/skillhub/templates/_helpers.tpl b/charts/skillhub/templates/_helpers.tpl index a70a01f1..824363d4 100644 --- a/charts/skillhub/templates/_helpers.tpl +++ b/charts/skillhub/templates/_helpers.tpl @@ -70,25 +70,110 @@ app.kubernetes.io/component: scanner app.kubernetes.io/component: scanner {{- end }} -{{- /* 镜像地址 */}} -{{- define "skillhub.image" -}} -{{- $registry := .registry | default .global.registry }} -{{- printf "%s/%s:%s" $registry .name .tag }} -{{- end }} - -{{- /* JDBC Host */}} -{{- define "skillhub.jdbcHost" -}} -{{- if eq .Values.database.mode "internal" -}} -{{ include "skillhub.fullname" . }}-postgres +{{- /* PostgreSQL Host */}} +{{- define "skillhub.postgresql.host" -}} +{{- if .Values.postgresql.enabled -}} +{{- $prefix := printf "%s-postgresql" (include "skillhub.fullname" .) -}} +{{- if eq .Values.postgresql.architecture "replication" -}} +{{- printf "%s-primary" $prefix -}} {{- else -}} -{{ .Values.database.external.host }} +{{- $prefix -}} +{{- end -}} +{{- else -}} +{{- .Values.externalDatabase.host -}} {{- end -}} {{- end }} -{{- /* JDBC Port */}} -{{- define "skillhub.jdbcPort" -}} -{{- if eq .Values.database.mode "internal" -}}5432{{- else -}} -{{ .Values.database.external.port | default "5432" }} +{{- /* PostgreSQL Port */}} +{{- define "skillhub.postgresql.port" -}} +{{- if .Values.postgresql.enabled -}} +{{- print "5432" -}} +{{- else -}} +{{- .Values.externalDatabase.port | default 5432 | int -}} +{{- end -}} +{{- end }} + +{{- /* PostgreSQL Database */}} +{{- define "skillhub.postgresql.database" -}} +{{- if .Values.postgresql.enabled -}} +{{- .Values.postgresql.auth.database -}} +{{- else -}} +{{- .Values.externalDatabase.database -}} +{{- end -}} +{{- end }} + +{{- /* PostgreSQL Username */}} +{{- define "skillhub.postgresql.username" -}} +{{- if .Values.postgresql.enabled -}} +{{- .Values.postgresql.auth.username -}} +{{- else -}} +{{- .Values.externalDatabase.username -}} +{{- end -}} +{{- end }} + +{{- /* PostgreSQL Secret Name */}} +{{- define "skillhub.postgresql.secretName" -}} +{{- if .Values.postgresql.enabled -}} +{{- printf "%s-postgresql" (include "skillhub.fullname" .) -}} +{{- else -}} +{{- include "skillhub.secretName" . -}} +{{- end -}} +{{- end }} + +{{- /* PostgreSQL JDBC URL */}} +{{- define "skillhub.jdbcUrl" -}} +{{- if .Values.postgresql.enabled -}} +{{- printf "jdbc:postgresql://%s:5432/%s" (include "skillhub.postgresql.host" .) .Values.postgresql.auth.database -}} +{{- else -}} +{{- if .Values.externalDatabase.jdbcUrl -}} +{{- .Values.externalDatabase.jdbcUrl -}} +{{- else -}} +{{- printf "jdbc:postgresql://%s:%d/%s" .Values.externalDatabase.host (.Values.externalDatabase.port | default 5432 | int) .Values.externalDatabase.database -}} +{{- end -}} +{{- end -}} +{{- end }} + +{{- /* Redis Sentinel 节点列表(Redisson 需要具体 pod FQDN,格式: {pod}.{headless-svc}.{ns}.svc.cluster.local) */}} +{{- define "skillhub.redis.sentinel.nodes" -}} +{{- $prefix := printf "%s-redis-node" (include "skillhub.fullname" .) -}} +{{- $headless := printf "%s-redis-headless" (include "skillhub.fullname" .) -}} +{{- $port := include "skillhub.redis.port" . -}} +{{- $replicas := .Values.redis.replica.replicaCount | default 2 | int -}} +{{- $first := true -}}{{- range $i := until $replicas -}}{{- if not $first -}},{{- end -}}{{ $prefix }}-{{ $i }}.{{ $headless }}.{{ $.Release.Namespace }}.svc.cluster.local:{{ $port }}{{- $first = false -}}{{- end -}} +{{- end }} + +{{- /* Redis Host */}} +{{- define "skillhub.redis.host" -}} +{{- if .Values.redis.enabled -}} +{{- if .Values.redis.sentinel.enabled -}} +{{- printf "%s-redis" (include "skillhub.fullname" .) -}} +{{- else -}} +{{- printf "%s-redis-master" (include "skillhub.fullname" .) -}} +{{- end -}} +{{- else -}} +{{- .Values.externalRedis.host -}} +{{- end -}} +{{- end }} + +{{- /* Redis Port */}} +{{- define "skillhub.redis.port" -}} +{{- if .Values.redis.enabled -}} +{{- if .Values.redis.sentinel.enabled -}} +{{- .Values.redis.sentinel.service.ports.sentinel | default 26379 -}} +{{- else -}} +{{- print "6379" -}} +{{- end -}} +{{- else -}} +{{- .Values.externalRedis.port | default 6379 | int -}} +{{- end -}} +{{- end }} + +{{- /* Redis Password Secret Name */}} +{{- define "skillhub.redis.secretName" -}} +{{- if .Values.redis.enabled -}} +{{- printf "%s-redis" (include "skillhub.fullname" .) -}} +{{- else -}} +{{- include "skillhub.secretName" . -}} {{- end -}} {{- end }} @@ -97,31 +182,20 @@ app.kubernetes.io/component: scanner {{- .Values.existingSecret | default (printf "%s-secret" (include "skillhub.fullname" .)) }} {{- end }} -{{- /* Redis Host */}} -{{- define "skillhub.redisHost" -}} -{{- if eq .Values.redis.mode "internal" -}} -{{ include "skillhub.fullname" . }}-redis +{{- /* PostgreSQL Service 名称(用于 server initContainer 等待) */}} +{{- define "skillhub.postgresql.serviceName" -}} +{{- if .Values.postgresql.enabled -}} +{{- include "skillhub.postgresql.host" . -}} {{- else -}} -{{ .Values.redis.external.host }} +{{- .Values.externalDatabase.host -}} {{- end -}} {{- end }} -{{- /* Redis Port */}} -{{- define "skillhub.redisPort" -}} -{{- if eq .Values.redis.mode "internal" -}}6379{{- else -}} -{{ .Values.redis.external.port | default "6379" }} +{{- /* Redis Service 名称(用于 server initContainer 等待) */}} +{{- define "skillhub.redis.serviceName" -}} +{{- if .Values.redis.enabled -}} +{{- include "skillhub.redis.host" . -}} +{{- else -}} +{{- .Values.externalRedis.host -}} {{- end -}} {{- end }} - -{{- /* 数据库 JDBC URL */}} -{{- define "skillhub.jdbcUrl" -}} -{{- if eq .Values.database.mode "internal" -}} -jdbc:postgresql://{{ include "skillhub.fullname" . }}-postgres:5432/skillhub -{{- else -}} -{{- if .Values.database.external.jdbcUrl -}} -{{ .Values.database.external.jdbcUrl }} -{{- else -}} -jdbc:postgresql://{{ .Values.database.external.host }}:{{ .Values.database.external.port }}/{{ .Values.database.external.database }}{{ if .Values.database.external.parameters }}?{{ .Values.database.external.parameters }}{{ end }} -{{- end }} -{{- end }} -{{- end }} diff --git a/charts/skillhub/templates/certificate.yaml b/charts/skillhub/templates/certificate.yaml index 95543ed3..bd886df4 100644 --- a/charts/skillhub/templates/certificate.yaml +++ b/charts/skillhub/templates/certificate.yaml @@ -1,12 +1,13 @@ +{{- $secretName := .Values.ingress.tls.secretName | default (printf "%s-tls" (include "skillhub.fullname" .)) }} {{- if and .Values.ingress.enabled .Values.ingress.certManager.enabled }} apiVersion: cert-manager.io/v1 kind: Certificate metadata: - name: {{ include "skillhub.fullname" . }}-tls + name: {{ $secretName }}-cert labels: {{- include "skillhub.labels" . | nindent 4 }} spec: - secretName: {{ include "skillhub.fullname" . }}-tls + secretName: {{ $secretName }} duration: 2160h renewBefore: 360h dnsNames: diff --git a/charts/skillhub/templates/configmap.yaml b/charts/skillhub/templates/configmap.yaml index a7600e95..aee9b16c 100644 --- a/charts/skillhub/templates/configmap.yaml +++ b/charts/skillhub/templates/configmap.yaml @@ -1,3 +1,6 @@ +{{- /* +SkillHub 应用 ConfigMap +*/}} apiVersion: v1 kind: ConfigMap metadata: @@ -6,18 +9,25 @@ metadata: {{- include "skillhub.labels" . | nindent 4 }} data: # Redis 配置 - redis-host: {{ include "skillhub.redisHost" . }} - redis-port: {{ include "skillhub.redisPort" . | quote }} + redis-host: {{ include "skillhub.redis.host" . }} + redis-port: {{ include "skillhub.redis.port" . | quote }} # 存储路径 storage-base-path: /var/lib/skillhub/storage # 存储提供者: local | s3 - skillhub-storage-provider: {{ .Values.storage.provider }} + skillhub-storage-provider: {{ if .Values.s3.enabled }}s3{{ else }}local{{ end }} + + {{- if .Values.s3.enabled }} + # S3 配置 + s3-bucket: {{ .Values.s3.bucket }} + s3-endpoint: {{ .Values.s3.endpoint }} + s3-region: {{ .Values.s3.region }} + {{- end }} # 技能扫描器 skill-scanner-enabled: {{ .Values.scanner.enabled | quote }} - skill-scanner-url: http://{{ include "skillhub.fullname" . }}-scanner:8000 + skill-scanner-url: http://{{ include "skillhub.fullname" . }}-scanner:{{ .Values.scanner.service.port }} skill-scanner-mode: upload # Bootstrap 管理员 @@ -29,6 +39,3 @@ data: # Session session-cookie-secure: {{ .Values.session.cookieSecure | quote }} - - # Spring Profiles - spring-profiles-active: {{ .Values.springProfilesActive }} diff --git a/charts/skillhub/templates/hpa.yaml b/charts/skillhub/templates/hpa.yaml index 0e2de933..15fca857 100644 --- a/charts/skillhub/templates/hpa.yaml +++ b/charts/skillhub/templates/hpa.yaml @@ -1,101 +1,36 @@ -{{- if .Values.server.autoscaling.enabled }} +{{- range $name := list "server" "web" "scanner" }} +{{- $component := index $.Values $name }} +{{- if and (default true $component.enabled) $component.autoscaling.enabled }} --- apiVersion: autoscaling/v2 kind: HorizontalPodAutoscaler metadata: - name: {{ include "skillhub.fullname" . }}-server + name: {{ include "skillhub.fullname" $ }}-{{ $name }} labels: - {{- include "skillhub.server.labels" . | nindent 4 }} + {{- include (printf "skillhub.%s.labels" $name) $ | nindent 4 }} spec: scaleTargetRef: apiVersion: apps/v1 kind: Deployment - name: {{ include "skillhub.fullname" . }}-server - minReplicas: {{ .Values.server.autoscaling.minReplicas }} - maxReplicas: {{ .Values.server.autoscaling.maxReplicas }} + name: {{ include "skillhub.fullname" $ }}-{{ $name }} + minReplicas: {{ $component.autoscaling.minReplicas }} + maxReplicas: {{ $component.autoscaling.maxReplicas }} metrics: - {{- if .Values.server.autoscaling.targetCPUUtilizationPercentage }} + {{- if $component.autoscaling.targetCPUUtilizationPercentage }} - type: Resource resource: name: cpu target: type: Utilization - averageUtilization: {{ .Values.server.autoscaling.targetCPUUtilizationPercentage }} + averageUtilization: {{ $component.autoscaling.targetCPUUtilizationPercentage }} {{- end }} - {{- if .Values.server.autoscaling.targetMemoryUtilizationPercentage }} + {{- if $component.autoscaling.targetMemoryUtilizationPercentage }} - type: Resource resource: name: memory target: type: Utilization - averageUtilization: {{ .Values.server.autoscaling.targetMemoryUtilizationPercentage }} + averageUtilization: {{ $component.autoscaling.targetMemoryUtilizationPercentage }} {{- end }} {{- end }} - -{{- if .Values.web.autoscaling.enabled }} ---- -apiVersion: autoscaling/v2 -kind: HorizontalPodAutoscaler -metadata: - name: {{ include "skillhub.fullname" . }}-web - labels: - {{- include "skillhub.web.labels" . | nindent 4 }} -spec: - scaleTargetRef: - apiVersion: apps/v1 - kind: Deployment - name: {{ include "skillhub.fullname" . }}-web - minReplicas: {{ .Values.web.autoscaling.minReplicas }} - maxReplicas: {{ .Values.web.autoscaling.maxReplicas }} - metrics: - {{- if .Values.web.autoscaling.targetCPUUtilizationPercentage }} - - type: Resource - resource: - name: cpu - target: - type: Utilization - averageUtilization: {{ .Values.web.autoscaling.targetCPUUtilizationPercentage }} - {{- end }} - {{- if .Values.web.autoscaling.targetMemoryUtilizationPercentage }} - - type: Resource - resource: - name: memory - target: - type: Utilization - averageUtilization: {{ .Values.web.autoscaling.targetMemoryUtilizationPercentage }} - {{- end }} -{{- end }} - -{{- if and .Values.scanner.enabled .Values.scanner.autoscaling.enabled }} ---- -apiVersion: autoscaling/v2 -kind: HorizontalPodAutoscaler -metadata: - name: {{ include "skillhub.fullname" . }}-scanner - labels: - {{- include "skillhub.scanner.labels" . | nindent 4 }} -spec: - scaleTargetRef: - apiVersion: apps/v1 - kind: Deployment - name: {{ include "skillhub.fullname" . }}-scanner - minReplicas: {{ .Values.scanner.autoscaling.minReplicas }} - maxReplicas: {{ .Values.scanner.autoscaling.maxReplicas }} - metrics: - {{- if .Values.scanner.autoscaling.targetCPUUtilizationPercentage }} - - type: Resource - resource: - name: cpu - target: - type: Utilization - averageUtilization: {{ .Values.scanner.autoscaling.targetCPUUtilizationPercentage }} - {{- end }} - {{- if .Values.scanner.autoscaling.targetMemoryUtilizationPercentage }} - - type: Resource - resource: - name: memory - target: - type: Utilization - averageUtilization: {{ .Values.scanner.autoscaling.targetMemoryUtilizationPercentage }} - {{- end }} {{- end }} diff --git a/charts/skillhub/templates/ingress.yaml b/charts/skillhub/templates/ingress.yaml index 30bded0f..00a8b602 100644 --- a/charts/skillhub/templates/ingress.yaml +++ b/charts/skillhub/templates/ingress.yaml @@ -36,12 +36,12 @@ spec: service: name: {{ include "skillhub.fullname" . }}-server port: - number: {{ .Values.service.serverPort }} + number: {{ .Values.server.service.port }} - path: / pathType: Prefix backend: service: name: {{ include "skillhub.fullname" . }}-web port: - number: {{ .Values.service.webPort }} + number: {{ .Values.web.service.port }} {{- end }} diff --git a/charts/skillhub/templates/pdb.yaml b/charts/skillhub/templates/pdb.yaml index ed705197..f5138066 100644 --- a/charts/skillhub/templates/pdb.yaml +++ b/charts/skillhub/templates/pdb.yaml @@ -1,44 +1,17 @@ -{{- if .Values.server.podDisruptionBudget.enabled }} +{{- range $name := list "server" "web" "scanner" }} +{{- $component := index $.Values $name }} +{{- if and (default true $component.enabled) $component.podDisruptionBudget.enabled }} --- apiVersion: policy/v1 kind: PodDisruptionBudget metadata: - name: {{ include "skillhub.fullname" . }}-server + name: {{ include "skillhub.fullname" $ }}-{{ $name }} labels: - {{- include "skillhub.server.labels" . | nindent 4 }} + {{- include (printf "skillhub.%s.labels" $name) $ | nindent 4 }} spec: selector: matchLabels: - {{- include "skillhub.server.selectorLabels" . | nindent 6 }} - minAvailable: {{ .Values.server.podDisruptionBudget.minAvailable }} + {{- include (printf "skillhub.%s.selectorLabels" $name) $ | nindent 6 }} + minAvailable: {{ $component.podDisruptionBudget.minAvailable }} {{- end }} - -{{- if .Values.web.podDisruptionBudget.enabled }} ---- -apiVersion: policy/v1 -kind: PodDisruptionBudget -metadata: - name: {{ include "skillhub.fullname" . }}-web - labels: - {{- include "skillhub.web.labels" . | nindent 4 }} -spec: - selector: - matchLabels: - {{- include "skillhub.web.selectorLabels" . | nindent 6 }} - minAvailable: {{ .Values.web.podDisruptionBudget.minAvailable }} -{{- end }} - -{{- if and .Values.scanner.enabled .Values.scanner.podDisruptionBudget.enabled }} ---- -apiVersion: policy/v1 -kind: PodDisruptionBudget -metadata: - name: {{ include "skillhub.fullname" . }}-scanner - labels: - {{- include "skillhub.scanner.labels" . | nindent 4 }} -spec: - selector: - matchLabels: - {{- include "skillhub.scanner.selectorLabels" . | nindent 6 }} - minAvailable: {{ .Values.scanner.podDisruptionBudget.minAvailable }} {{- end }} diff --git a/charts/skillhub/templates/postgres-statefulset.yaml b/charts/skillhub/templates/postgres-statefulset.yaml deleted file mode 100644 index d7616741..00000000 --- a/charts/skillhub/templates/postgres-statefulset.yaml +++ /dev/null @@ -1,96 +0,0 @@ -{{- if eq .Values.database.mode "internal" }} ---- -apiVersion: apps/v1 -kind: StatefulSet -metadata: - name: {{ include "skillhub.fullname" . }}-postgres - labels: - {{- include "skillhub.labels" . | nindent 4 }} - app.kubernetes.io/component: database -spec: - serviceName: {{ include "skillhub.fullname" . }}-postgres - replicas: 1 - selector: - matchLabels: - {{- include "skillhub.selectorLabels" . | nindent 6 }} - app.kubernetes.io/component: database - template: - metadata: - labels: - {{- include "skillhub.selectorLabels" . | nindent 8 }} - app.kubernetes.io/component: database - spec: - containers: - - name: postgres - image: {{ .Values.database.internal.registry }}/{{ .Values.database.internal.image }} - ports: - - containerPort: 5432 - name: postgres - env: - - name: POSTGRES_DB - value: skillhub - - name: POSTGRES_USER - valueFrom: - secretKeyRef: - name: {{ include "skillhub.secretName" . }} - key: spring-datasource-username - - name: POSTGRES_PASSWORD - valueFrom: - secretKeyRef: - name: {{ include "skillhub.secretName" . }} - key: spring-datasource-password - - name: PGDATA - value: /var/lib/postgresql/data/pgdata - volumeMounts: - - name: postgres-data - mountPath: /var/lib/postgresql/data - resources: - {{- toYaml .Values.database.internal.resources | nindent 12 }} - readinessProbe: - exec: - command: - - sh - - -c - - pg_isready -U "${POSTGRES_USER}" -h localhost - initialDelaySeconds: 10 - periodSeconds: 10 - livenessProbe: - exec: - command: - - sh - - -c - - pg_isready -U "${POSTGRES_USER}" -h localhost - initialDelaySeconds: 30 - periodSeconds: 15 - volumeClaimTemplates: - - metadata: - name: postgres-data - labels: - {{- include "skillhub.labels" . | nindent 10 }} - spec: - accessModes: - - {{ .Values.storage.local.accessMode }} - {{- if .Values.database.internal.storageClassName }} - storageClassName: {{ .Values.database.internal.storageClassName }} - {{- end }} - resources: - requests: - storage: {{ .Values.database.internal.storage }} ---- -apiVersion: v1 -kind: Service -metadata: - name: {{ include "skillhub.fullname" . }}-postgres - labels: - {{- include "skillhub.labels" . | nindent 4 }} - app.kubernetes.io/component: database -spec: - type: ClusterIP - ports: - - port: 5432 - targetPort: postgres - name: postgres - selector: - {{- include "skillhub.selectorLabels" . | nindent 4 }} - app.kubernetes.io/component: database -{{- end }} diff --git a/charts/skillhub/templates/pvc.yaml b/charts/skillhub/templates/pvc.yaml index 0b5b49c7..3881cbbe 100644 --- a/charts/skillhub/templates/pvc.yaml +++ b/charts/skillhub/templates/pvc.yaml @@ -1,19 +1,27 @@ -{{- if eq .Values.storage.provider "local" }} +{{- if and .Values.server.enabled (not .Values.s3.enabled) }} apiVersion: v1 kind: PersistentVolumeClaim metadata: - name: {{ include "skillhub.fullname" . }}-storage-pvc + name: {{ include "skillhub.fullname" . }}-server-data labels: {{- include "skillhub.labels" . | nindent 4 }} annotations: helm.sh/resource-policy: keep spec: + {{- $accessMode := .Values.server.storage.accessMode }} + {{- if not $accessMode }} + {{- if or (gt (.Values.server.replicaCount | int) 1) (and .Values.server.autoscaling.enabled (gt (.Values.server.autoscaling.maxReplicas | int) 1)) }} + {{- $accessMode = "ReadWriteMany" }} + {{- else }} + {{- $accessMode = "ReadWriteOnce" }} + {{- end }} + {{- end }} accessModes: - - {{ .Values.storage.local.accessMode }} - {{- if .Values.storage.local.storageClassName }} - storageClassName: {{ .Values.storage.local.storageClassName }} + - {{ $accessMode }} + {{- if .Values.server.storage.storageClassName }} + storageClassName: {{ .Values.server.storage.storageClassName }} {{- end }} resources: requests: - storage: {{ .Values.storage.local.storage }} + storage: {{ .Values.server.storage.size }} {{- end }} diff --git a/charts/skillhub/templates/redis-statefulset.yaml b/charts/skillhub/templates/redis-statefulset.yaml deleted file mode 100644 index 1a581509..00000000 --- a/charts/skillhub/templates/redis-statefulset.yaml +++ /dev/null @@ -1,83 +0,0 @@ -{{- if eq .Values.redis.mode "internal" }} ---- -apiVersion: apps/v1 -kind: StatefulSet -metadata: - name: {{ include "skillhub.fullname" . }}-redis - labels: - {{- include "skillhub.labels" . | nindent 4 }} - app.kubernetes.io/component: cache -spec: - serviceName: {{ include "skillhub.fullname" . }}-redis - replicas: 1 - selector: - matchLabels: - {{- include "skillhub.selectorLabels" . | nindent 6 }} - app.kubernetes.io/component: cache - template: - metadata: - labels: - {{- include "skillhub.selectorLabels" . | nindent 8 }} - app.kubernetes.io/component: cache - spec: - containers: - - name: redis - image: {{ .Values.redis.internal.registry }}/{{ .Values.redis.internal.image }} - ports: - - containerPort: 6379 - name: redis - command: - - redis-server - - --appendonly - - "yes" - volumeMounts: - - name: redis-data - mountPath: /data - resources: - {{- toYaml .Values.redis.internal.resources | nindent 12 }} - readinessProbe: - exec: - command: - - redis-cli - - ping - initialDelaySeconds: 5 - periodSeconds: 10 - livenessProbe: - exec: - command: - - redis-cli - - ping - initialDelaySeconds: 10 - periodSeconds: 15 - volumeClaimTemplates: - - metadata: - name: redis-data - labels: - {{- include "skillhub.labels" . | nindent 10 }} - spec: - accessModes: - - {{ .Values.storage.local.accessMode }} - {{- if .Values.redis.internal.storageClassName }} - storageClassName: {{ .Values.redis.internal.storageClassName }} - {{- end }} - resources: - requests: - storage: {{ .Values.redis.internal.storage }} ---- -apiVersion: v1 -kind: Service -metadata: - name: {{ include "skillhub.fullname" . }}-redis - labels: - {{- include "skillhub.labels" . | nindent 4 }} - app.kubernetes.io/component: cache -spec: - type: ClusterIP - ports: - - port: 6379 - targetPort: redis - name: redis - selector: - {{- include "skillhub.selectorLabels" . | nindent 4 }} - app.kubernetes.io/component: cache -{{- end }} diff --git a/charts/skillhub/templates/scanner-deployment.yaml b/charts/skillhub/templates/scanner-deployment.yaml index a3496fb9..5e466c39 100644 --- a/charts/skillhub/templates/scanner-deployment.yaml +++ b/charts/skillhub/templates/scanner-deployment.yaml @@ -6,7 +6,9 @@ metadata: labels: {{- include "skillhub.scanner.labels" . | nindent 4 }} spec: - replicas: {{ .Values.replicaCount }} + {{- if not .Values.scanner.autoscaling.enabled }} + replicas: {{ .Values.scanner.replicaCount }} + {{- end }} selector: matchLabels: {{- include "skillhub.scanner.selectorLabels" . | nindent 6 }} @@ -15,23 +17,22 @@ spec: labels: {{- include "skillhub.scanner.selectorLabels" . | nindent 8 }} annotations: - checksum/config: {{ toYaml (dict "redis" .Values.redis "storage" .Values.storage "database" .Values.database "session" .Values.session "springProfilesActive" .Values.springProfilesActive "bootstrapAdmin" .Values.bootstrapAdmin) | sha256sum }} - checksum/secret: {{ toYaml (dict "secrets" .Values.secrets "bootstrapAdmin" .Values.bootstrapAdmin) | sha256sum }} + checksum/config: {{ toYaml (dict "scanner" .Values.scanner) | sha256sum }} {{- range $key, $val := .Values.scanner.podAnnotations }} {{ $key }}: {{ $val }} {{- end }} spec: - {{- $secrets := .Values.scanner.imagePullSecrets | default .Values.global.imagePullSecrets }} + {{- $secrets := .Values.scanner.imagePullSecrets }} {{- if $secrets }} imagePullSecrets: {{- toYaml $secrets | nindent 8 }} {{- end }} containers: - name: scanner - image: {{ .Values.global.imageRegistry | default .Values.images.registry }}/skillhub-scanner:{{ .Values.scanner.image.tag | default .Values.images.tag | default (printf "v%s" .Chart.AppVersion) }} + image: {{ .Values.scanner.image.registry | default .Values.images.registry }}/skillhub-scanner:{{ .Values.scanner.image.tag | default .Values.images.tag | default (printf "v%s" .Chart.AppVersion) }} imagePullPolicy: {{ .Values.images.pullPolicy }} ports: - - containerPort: {{ .Values.service.scannerPort }} + - containerPort: {{ .Values.scanner.service.port }} name: http env: - name: SKILL_SCANNER_LLM_API_KEY diff --git a/charts/skillhub/templates/secret.yaml b/charts/skillhub/templates/secret.yaml index 1a739137..eb568b82 100644 --- a/charts/skillhub/templates/secret.yaml +++ b/charts/skillhub/templates/secret.yaml @@ -1,6 +1,15 @@ +{{- /* +SkillHub 应用 Secret +- 内置 PostgreSQL/Redis:密码由 Bitnami 管理,从对应 Secret 读取 +- 外部 PostgreSQL/Redis:密码从 values 或 existingSecret 读取 +*/}} {{- if not .Values.existingSecret }} -{{- $secretName := printf "%s-secret" (include "skillhub.fullname" .) }} -{{- $existingSecret := (lookup "v1" "Secret" .Release.Namespace $secretName) }} +{{- $secretName := include "skillhub.secretName" . }} +{{- $postgresSecretName := include "skillhub.postgresql.secretName" . }} +{{- $redisSecretName := include "skillhub.redis.secretName" . }} +{{- $postgresSecret := (lookup "v1" "Secret" $.Release.Namespace $postgresSecretName) }} +{{- $redisSecret := (lookup "v1" "Secret" $.Release.Namespace $redisSecretName) }} +{{- $appSecret := (lookup "v1" "Secret" $.Release.Namespace $secretName) }} apiVersion: v1 kind: Secret metadata: @@ -9,67 +18,76 @@ metadata: {{- include "skillhub.labels" . | nindent 4 }} type: Opaque stringData: + # 数据库连接 URL spring-datasource-url: {{ include "skillhub.jdbcUrl" . | quote }} - spring-datasource-username: {{ if eq .Values.database.mode "internal" }}skillhub{{ else }}{{ .Values.database.external.username }}{{ end }} + spring-datasource-username: {{ include "skillhub.postgresql.username" . | quote }} - {{- $dsPwd := "" }} - {{- if $existingSecret }} - {{- $dsPwd = $existingSecret.data.springDatasourcePassword | b64dec }} - {{- else if eq .Values.database.mode "internal" }} - {{- $dsPwd = default (randAlphaNum 16) .Values.secrets.springDatasourcePassword }} + # 数据库密码 + # 优先级: lookup PG Secret → externalDatabase.password → postgresql.auth.password + {{- if and $postgresSecret (index $postgresSecret.data "password") }} + spring-datasource-password: {{ index $postgresSecret.data "password" | b64dec | quote }} + {{- else if not .Values.postgresql.enabled }} + spring-datasource-password: {{ .Values.externalDatabase.password | quote }} {{- else }} - {{- $dsPwd = .Values.database.external.password }} - {{- end }} - spring-datasource-password: {{ $dsPwd | quote }} - - {{- $redisPwd := "" }} - {{- if $existingSecret }} - {{- $redisPwd = $existingSecret.data.redisPassword | b64dec }} - {{- else }} - {{- $redisPwd = .Values.redis.external.password | default "" }} - {{- end }} - {{- if $redisPwd }} - redis-password: {{ $redisPwd | quote }} + spring-datasource-password: {{ .Values.secrets.springDatasourcePassword | default .Values.postgresql.auth.password | quote }} {{- end }} - {{- $s3Key := "" }} - {{- if $existingSecret }} - {{- $s3Key = $existingSecret.data.s3AccessKey | b64dec }} - {{- else }} - {{- $s3Key = .Values.storage.s3.accessKey | default "" }} + # Redis 密码 + # 优先级: lookup Redis Secret → externalRedis.password → redis.auth.password + {{- if $redisSecret }} + {{- if index $redisSecret.data "redis-password" }} + redis-password: {{ index $redisSecret.data "redis-password" | b64dec | quote }} {{- end }} - {{- if $s3Key }} - s3-access-key: {{ $s3Key | quote }} + {{- else if not .Values.redis.enabled }} + redis-password: {{ .Values.externalRedis.password | default "" | quote }} + {{- else if .Values.redis.auth.password }} + redis-password: {{ .Values.redis.auth.password | quote }} {{- end }} - {{- $s3Secret := "" }} - {{- if $existingSecret }} - {{- $s3Secret = $existingSecret.data.s3SecretKey | b64dec }} + # Redis Sentinel 密码(仅 sentinel 模式下生效) + # 优先级: lookup Bitnami Secret → sentinelPassword → auth.password → externalRedis.password + {{- if and .Values.redis.enabled .Values.redis.sentinel.enabled }} + {{- if and $redisSecret (index $redisSecret.data "redis-sentinel-password") }} + redis-sentinel-password: {{ index $redisSecret.data "redis-sentinel-password" | b64dec | quote }} {{- else }} - {{- $s3Secret = .Values.storage.s3.secretKey | default "" }} + redis-sentinel-password: {{ .Values.redis.auth.sentinelPassword | default .Values.redis.auth.password | quote }} {{- end }} - {{- if $s3Secret }} - s3-secret-key: {{ $s3Secret | quote }} + {{- else if and (not .Values.redis.enabled) .Values.externalRedis.sentinel.enabled }} + redis-sentinel-password: {{ .Values.externalRedis.password | default "" | quote }} {{- end }} - - {{- $baPwd := "" }} - {{- if $existingSecret }} - {{- $baPwd = $existingSecret.data.bootstrapAdminPassword | b64dec }} - {{- else }} - {{- $baPwd = .Values.secrets.bootstrapAdminPassword | default .Values.bootstrapAdmin.password | default (randAlphaNum 16) }} + # Bootstrap 管理员密码 + # 优先级: secrets.bootstrapAdminPassword → bootstrapAdmin.password → 集群已有 Secret → 随机生成 + {{- $baPwd := .Values.secrets.bootstrapAdminPassword | default .Values.bootstrapAdmin.password | default "" }} + {{- if not $baPwd }} + {{- if $appSecret }} + {{- $baPwd = index $appSecret.data "bootstrap-admin-password" | default "" | b64dec }} + {{- end }} + {{- if not $baPwd }} + {{- $baPwd = randAlphaNum 16 }} + {{- end }} {{- end }} bootstrap-admin-password: {{ $baPwd | quote }} - + # OAuth2 GitHub (optional) {{- if .Values.secrets.oauth2GithubClientId }} - oauth2-github-client-id: {{ .Values.secrets.oauth2GithubClientId }} + oauth2-github-client-id: {{ .Values.secrets.oauth2GithubClientId | quote }} {{- end }} {{- if .Values.secrets.oauth2GithubClientSecret }} - oauth2-github-client-secret: {{ .Values.secrets.oauth2GithubClientSecret }} + oauth2-github-client-secret: {{ .Values.secrets.oauth2GithubClientSecret | quote }} {{- end }} + + # Scanner LLM 配置 (optional) {{- if .Values.secrets.scannerLlmApiKey }} - skill-scanner-llm-api-key: {{ .Values.secrets.scannerLlmApiKey }} + skill-scanner-llm-api-key: {{ .Values.secrets.scannerLlmApiKey | quote }} {{- end }} {{- if .Values.secrets.scannerLlmModel }} - skill-scanner-llm-model: {{ .Values.secrets.scannerLlmModel }} + skill-scanner-llm-model: {{ .Values.secrets.scannerLlmModel | quote }} + {{- end }} + + # S3 配置 (optional) + {{- if .Values.s3.accessKey }} + s3-access-key: {{ .Values.s3.accessKey | quote }} + {{- end }} + {{- if .Values.s3.secretKey }} + s3-secret-key: {{ .Values.s3.secretKey | quote }} {{- end }} {{- end }} diff --git a/charts/skillhub/templates/backend-deployment.yaml b/charts/skillhub/templates/server-deployment.yaml similarity index 68% rename from charts/skillhub/templates/backend-deployment.yaml rename to charts/skillhub/templates/server-deployment.yaml index 677d9a37..ac854665 100644 --- a/charts/skillhub/templates/backend-deployment.yaml +++ b/charts/skillhub/templates/server-deployment.yaml @@ -1,3 +1,4 @@ +{{- if .Values.server.enabled }} apiVersion: apps/v1 kind: Deployment metadata: @@ -5,7 +6,9 @@ metadata: labels: {{- include "skillhub.server.labels" . | nindent 4 }} spec: - replicas: {{ .Values.replicaCount }} + {{- if not .Values.server.autoscaling.enabled }} + replicas: {{ .Values.server.replicaCount }} + {{- end }} selector: matchLabels: {{- include "skillhub.server.selectorLabels" . | nindent 6 }} @@ -14,29 +17,29 @@ spec: labels: {{- include "skillhub.server.selectorLabels" . | nindent 8 }} annotations: - checksum/config: {{ toYaml (dict "redis" .Values.redis "storage" .Values.storage "database" .Values.database "session" .Values.session "springProfilesActive" .Values.springProfilesActive "bootstrapAdmin" .Values.bootstrapAdmin) | sha256sum }} + checksum/config: {{ toYaml (dict "redisHost" (include "skillhub.redis.host" .) "redisPort" (include "skillhub.redis.port" .) "storage" .Values.server.storage "s3" .Values.s3 "session" .Values.session "springProfilesActive" .Values.springProfilesActive "bootstrapAdmin" .Values.bootstrapAdmin "scannerEnabled" .Values.scanner.enabled "scannerPort" .Values.scanner.service.port) | sha256sum }} checksum/secret: {{ toYaml (dict "secrets" .Values.secrets "bootstrapAdmin" .Values.bootstrapAdmin) | sha256sum }} {{- range $key, $val := .Values.server.podAnnotations }} {{ $key }}: {{ $val }} {{- end }} spec: - {{- $secrets := .Values.server.imagePullSecrets | default .Values.global.imagePullSecrets }} + {{- $secrets := .Values.server.imagePullSecrets }} {{- if $secrets }} imagePullSecrets: {{- toYaml $secrets | nindent 8 }} {{- end }} initContainers: - name: wait-for-dependencies - image: busybox:latest + image: busybox:1.37 env: - name: DB_HOST - value: {{ include "skillhub.jdbcHost" . }} + value: {{ include "skillhub.postgresql.serviceName" . }} - name: DB_PORT - value: {{ include "skillhub.jdbcPort" . | quote }} + value: {{ include "skillhub.postgresql.port" . | quote }} - name: REDIS_HOST - value: {{ include "skillhub.redisHost" . }} + value: {{ include "skillhub.redis.serviceName" . }} - name: REDIS_PORT - value: {{ include "skillhub.redisPort" . | quote }} + value: {{ include "skillhub.redis.port" . | quote }} command: - sh - -c @@ -49,14 +52,20 @@ spec: echo "Redis is ready!" containers: - name: server - image: {{ .Values.global.imageRegistry | default .Values.images.registry }}/skillhub-server:{{ .Values.server.image.tag | default .Values.images.tag | default (printf "v%s" .Chart.AppVersion) }} + image: {{ .Values.server.image.registry | default .Values.images.registry }}/skillhub-server:{{ .Values.server.image.tag | default .Values.images.tag | default (printf "v%s" .Chart.AppVersion) }} imagePullPolicy: {{ .Values.images.pullPolicy }} ports: - - containerPort: {{ .Values.service.serverPort }} + - containerPort: {{ .Values.server.service.port }} name: http env: - name: SPRING_PROFILES_ACTIVE - value: {{ .Values.springProfilesActive }} + {{- $profiles := .Values.springProfilesActive }} + {{- if and .Values.redis.enabled .Values.redis.sentinel.enabled }} + {{- $profiles = printf "%s,redis-sentinel" $profiles }} + {{- else if and (not .Values.redis.enabled) .Values.externalRedis.sentinel.enabled }} + {{- $profiles = printf "%s,redis-sentinel" $profiles }} + {{- end }} + value: {{ $profiles }} # Database - name: SPRING_DATASOURCE_URL @@ -76,6 +85,17 @@ spec: key: spring-datasource-password # Redis + {{- if and .Values.redis.enabled .Values.redis.sentinel.enabled }} + - name: SPRING_DATA_REDIS_SENTINEL_MASTER + value: {{ .Values.redis.sentinel.masterSet | default "mymaster" | quote }} + - name: SPRING_DATA_REDIS_SENTINEL_NODES + value: {{ include "skillhub.redis.sentinel.nodes" . }} + {{- else if and (not .Values.redis.enabled) .Values.externalRedis.sentinel.enabled }} + - name: SPRING_DATA_REDIS_SENTINEL_MASTER + value: {{ .Values.externalRedis.sentinel.masterSet | default "mymaster" | quote }} + - name: SPRING_DATA_REDIS_SENTINEL_NODES + value: {{ join "," .Values.externalRedis.sentinel.nodes }} + {{- else }} - name: SPRING_DATA_REDIS_HOST valueFrom: configMapKeyRef: @@ -86,8 +106,20 @@ spec: configMapKeyRef: name: {{ include "skillhub.fullname" . }}-config key: redis-port + {{- end }} - {{- if eq .Values.redis.mode "external" }} + {{- if or (and .Values.redis.enabled .Values.redis.sentinel.enabled) (and (not .Values.redis.enabled) .Values.externalRedis.sentinel.enabled) }} + - name: SPRING_DATA_REDIS_SENTINEL_PASSWORD + valueFrom: + secretKeyRef: + name: {{ include "skillhub.secretName" . }} + {{- if and (not .Values.redis.enabled) .Values.externalRedis.sentinel.enabled }} + key: redis-sentinel-password + {{- else }} + key: redis-password + {{- end }} + optional: true + {{- else if or .Values.redis.enabled .Values.externalRedis.password }} - name: SPRING_DATA_REDIS_PASSWORD valueFrom: secretKeyRef: @@ -96,13 +128,6 @@ spec: optional: true {{- end }} - {{- if .Values.redis.external.sentinel.enabled }} - - name: SPRING_DATA_REDIS_SENTINEL_MASTER - value: {{ .Values.redis.external.sentinel.masterSet }} - - name: SPRING_DATA_REDIS_SENTINEL_NODES - value: {{ join "," .Values.redis.external.sentinel.nodes }} - {{- end }} - # Storage - name: STORAGE_BASE_PATH valueFrom: @@ -115,13 +140,22 @@ spec: name: {{ include "skillhub.fullname" . }}-config key: skillhub-storage-provider - {{- if eq .Values.storage.provider "s3" }} + {{- if .Values.s3.enabled }} - name: SKILLHUB_S3_BUCKET - value: {{ .Values.storage.s3.bucket }} + valueFrom: + configMapKeyRef: + name: {{ include "skillhub.fullname" . }}-config + key: s3-bucket - name: SKILLHUB_S3_ENDPOINT - value: {{ .Values.storage.s3.endpoint }} + valueFrom: + configMapKeyRef: + name: {{ include "skillhub.fullname" . }}-config + key: s3-endpoint - name: SKILLHUB_S3_REGION - value: {{ .Values.storage.s3.region }} + valueFrom: + configMapKeyRef: + name: {{ include "skillhub.fullname" . }}-config + key: s3-region - name: SKILLHUB_S3_ACCESS_KEY valueFrom: secretKeyRef: @@ -216,7 +250,7 @@ spec: {{- toYaml . | nindent 12 }} {{- end }} - {{- if eq .Values.storage.provider "local" }} + {{- if and .Values.server.enabled (not .Values.s3.enabled) }} volumeMounts: - name: skillhub-storage mountPath: /var/lib/skillhub/storage @@ -232,11 +266,11 @@ spec: livenessProbe: {{- toYaml .Values.server.probes.liveness | nindent 12 }} - {{- if eq .Values.storage.provider "local" }} + {{- if and .Values.server.enabled (not .Values.s3.enabled) }} volumes: - name: skillhub-storage persistentVolumeClaim: - claimName: {{ include "skillhub.fullname" . }}-storage-pvc + claimName: {{ include "skillhub.fullname" . }}-server-data {{- end }} {{- with .Values.server.nodeSelector }} nodeSelector: @@ -250,3 +284,5 @@ spec: affinity: {{- toYaml . | nindent 8 }} {{- end }} + +{{- end }} \ No newline at end of file diff --git a/charts/skillhub/templates/services.yaml b/charts/skillhub/templates/services.yaml index 2ac0e120..f3b2be89 100644 --- a/charts/skillhub/templates/services.yaml +++ b/charts/skillhub/templates/services.yaml @@ -1,58 +1,44 @@ +{{- /* +SkillHub Service 资源 +- server/web: 使用组件自己的 service.type 配置(共享同一模板) +- scanner: 固定 ClusterIP(仅供内部调用) +*/}} + +{{- range $name := list "server" "web" }} +{{- $component := index $.Values $name }} +{{- if $component.service.enabled }} --- apiVersion: v1 kind: Service metadata: - name: {{ include "skillhub.fullname" . }}-server + name: {{ include "skillhub.fullname" $ }}-{{ $name }} labels: - {{- include "skillhub.server.labels" . | nindent 4 }} + {{- include (printf "skillhub.%s.labels" $name) $ | nindent 4 }} spec: - type: {{ .Values.service.type }} - {{- if eq .Values.service.type "LoadBalancer" }} - {{- if .Values.service.loadBalancerIP }} - loadBalancerIP: {{ .Values.service.loadBalancerIP }} + type: {{ $component.service.type }} + {{- if eq $component.service.type "LoadBalancer" }} + {{- if $component.service.loadBalancerIP }} + loadBalancerIP: {{ $component.service.loadBalancerIP }} {{- end }} - {{- if .Values.service.loadBalancerSourceRanges }} + {{- if $component.service.loadBalancerSourceRanges }} loadBalancerSourceRanges: - {{- toYaml .Values.service.loadBalancerSourceRanges | nindent 4 }} + {{- toYaml $component.service.loadBalancerSourceRanges | nindent 4 }} {{- end }} {{- end }} ports: - name: http - port: {{ .Values.service.serverPort }} + port: {{ $component.service.port }} targetPort: http - {{- if and (eq .Values.service.type "NodePort") .Values.service.serverNodePort }} - nodePort: {{ .Values.service.serverNodePort }} + {{- if and (eq $component.service.type "NodePort") $component.service.nodePort }} + nodePort: {{ $component.service.nodePort }} {{- end }} selector: - {{- include "skillhub.server.selectorLabels" . | nindent 4 }} ---- -apiVersion: v1 -kind: Service -metadata: - name: {{ include "skillhub.fullname" . }}-web - labels: - {{- include "skillhub.web.labels" . | nindent 4 }} -spec: - type: {{ .Values.service.type }} - {{- if eq .Values.service.type "LoadBalancer" }} - {{- if .Values.service.loadBalancerIP }} - loadBalancerIP: {{ .Values.service.loadBalancerIP }} - {{- end }} - {{- if .Values.service.loadBalancerSourceRanges }} - loadBalancerSourceRanges: - {{- toYaml .Values.service.loadBalancerSourceRanges | nindent 4 }} - {{- end }} - {{- end }} - ports: - - name: http - port: {{ .Values.service.webPort }} - targetPort: http - {{- if and (eq .Values.service.type "NodePort") .Values.service.webNodePort }} - nodePort: {{ .Values.service.webNodePort }} - {{- end }} - selector: - {{- include "skillhub.web.selectorLabels" . | nindent 4 }} -{{- if .Values.scanner.enabled }} + {{- include (printf "skillhub.%s.selectorLabels" $name) $ | nindent 4 }} +{{- end }} +{{- end }} + +{{- /* Scanner Service(固定 ClusterIP) */}} +{{- if and .Values.scanner.enabled .Values.scanner.service }} --- apiVersion: v1 kind: Service @@ -61,23 +47,11 @@ metadata: labels: {{- include "skillhub.scanner.labels" . | nindent 4 }} spec: - type: {{ .Values.service.type }} - {{- if eq .Values.service.type "LoadBalancer" }} - {{- if .Values.service.loadBalancerIP }} - loadBalancerIP: {{ .Values.service.loadBalancerIP }} - {{- end }} - {{- if .Values.service.loadBalancerSourceRanges }} - loadBalancerSourceRanges: - {{- toYaml .Values.service.loadBalancerSourceRanges | nindent 4 }} - {{- end }} - {{- end }} + type: ClusterIP ports: - name: http - port: {{ .Values.service.scannerPort }} + port: {{ .Values.scanner.service.port }} targetPort: http - {{- if and (eq .Values.service.type "NodePort") .Values.service.scannerNodePort }} - nodePort: {{ .Values.service.scannerNodePort }} - {{- end }} selector: {{- include "skillhub.scanner.selectorLabels" . | nindent 4 }} {{- end }} diff --git a/charts/skillhub/templates/frontend-deployment.yaml b/charts/skillhub/templates/web-deployment.yaml similarity index 66% rename from charts/skillhub/templates/frontend-deployment.yaml rename to charts/skillhub/templates/web-deployment.yaml index 89fe2f05..7392a398 100644 --- a/charts/skillhub/templates/frontend-deployment.yaml +++ b/charts/skillhub/templates/web-deployment.yaml @@ -5,7 +5,9 @@ metadata: labels: {{- include "skillhub.web.labels" . | nindent 4 }} spec: - replicas: {{ .Values.replicaCount }} + {{- if not .Values.web.autoscaling.enabled }} + replicas: {{ .Values.web.replicaCount }} + {{- end }} selector: matchLabels: {{- include "skillhub.web.selectorLabels" . | nindent 6 }} @@ -14,29 +16,28 @@ spec: labels: {{- include "skillhub.web.selectorLabels" . | nindent 8 }} annotations: - checksum/config: {{ toYaml (dict "redis" .Values.redis "storage" .Values.storage "database" .Values.database "session" .Values.session "springProfilesActive" .Values.springProfilesActive "bootstrapAdmin" .Values.bootstrapAdmin) | sha256sum }} - checksum/secret: {{ toYaml (dict "secrets" .Values.secrets "bootstrapAdmin" .Values.bootstrapAdmin) | sha256sum }} + checksum/config: {{ toYaml (dict "web" .Values.web) | sha256sum }} {{- range $key, $val := .Values.web.podAnnotations }} {{ $key }}: {{ $val }} {{- end }} spec: - {{- $secrets := .Values.web.imagePullSecrets | default .Values.global.imagePullSecrets }} + {{- $secrets := .Values.web.imagePullSecrets }} {{- if $secrets }} imagePullSecrets: {{- toYaml $secrets | nindent 8 }} {{- end }} containers: - name: web - image: {{ .Values.global.imageRegistry | default .Values.images.registry }}/skillhub-web:{{ .Values.web.image.tag | default .Values.images.tag | default (printf "v%s" .Chart.AppVersion) }} + image: {{ .Values.web.image.registry | default .Values.images.registry }}/skillhub-web:{{ .Values.web.image.tag | default .Values.images.tag | default (printf "v%s" .Chart.AppVersion) }} imagePullPolicy: {{ .Values.images.pullPolicy }} env: - name: SKILLHUB_API_UPSTREAM - value: http://{{ include "skillhub.fullname" . }}-server:{{ .Values.service.serverPort }} + value: http://{{ include "skillhub.fullname" . }}-server:{{ .Values.server.service.port }} {{- with .Values.web.extraEnv }} {{- toYaml . | nindent 12 }} {{- end }} ports: - - containerPort: {{ .Values.service.webPort }} + - containerPort: {{ .Values.web.service.port }} name: http resources: {{- toYaml .Values.web.resources | nindent 12 }} diff --git a/charts/skillhub/values.yaml b/charts/skillhub/values.yaml index 5edd8153..9cedffdc 100644 --- a/charts/skillhub/values.yaml +++ b/charts/skillhub/values.yaml @@ -2,47 +2,17 @@ # SkillHub Helm Chart 全局配置 # ============================================================================ -# ============================================================================ -# Global -# ============================================================================ -global: - imageRegistry: "" - imagePullSecrets: [] - # ============================================================================ # 镜像配置 # ============================================================================ images: registry: ghcr.io/iflytek - # 留空时自动使用 Chart.yaml 中的 appVersion(带 v 前缀) tag: "" pullPolicy: IfNotPresent -# ============================================================================ -# 副本数 -# ============================================================================ -replicaCount: 1 - nameOverride: "" fullnameOverride: "" -# ============================================================================ -# 服务配置 -# ============================================================================ -service: - # ClusterIP | NodePort | LoadBalancer - type: ClusterIP - serverPort: 8080 - webPort: 80 - scannerPort: 8000 - # type: NodePort 时指定 nodePort(不指定则由集群分配) - serverNodePort: "" - webNodePort: "" - scannerNodePort: "" - # type: LoadBalancer 时可选固定 IP - loadBalancerIP: "" - loadBalancerSourceRanges: [] - # ============================================================================ # Ingress 配置 # ============================================================================ @@ -61,12 +31,236 @@ ingress: issuerKind: ClusterIssuer # ============================================================================ -# 应用组件配置 +# S3 对象存储配置 +# ============================================================================ +s3: + enabled: false + bucket: skillhub-storage + endpoint: "" + region: us-east-1 + accessKey: "" + secretKey: "" + +# ============================================================================ +# Session 配置 +# ============================================================================ +session: + cookieSecure: false + +# ============================================================================ +# Bootstrap 管理员 +# ============================================================================ +bootstrapAdmin: + enabled: true + userId: docker-admin + username: admin + displayName: "Platform Admin" + email: admin@example.com + password: "" + +# ============================================================================ +# Spring Profiles +# ============================================================================ +springProfilesActive: docker + +# ============================================================================ +# Secret 配置 +# ============================================================================ +existingSecret: "" + +secrets: + springDatasourcePassword: "" + bootstrapAdminPassword: "" + oauth2GithubClientId: "" + oauth2GithubClientSecret: "" + scannerLlmApiKey: "" + scannerLlmModel: "" + +# ============================================================================ +# PostgreSQL 配置(Bitnami) +# ============================================================================ +postgresql: + enabled: true + + image: + registry: docker.io + repository: bitnami/postgresql + tag: latest + digest: "" + + architecture: standalone + + auth: + postgresPassword: "" + database: skillhub + username: skillhub + password: "skillhub_demo" + + primary: + persistence: + enabled: true + size: 10Gi + storageClass: "" + accessModes: + - ReadWriteOnce + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: 500m + memory: 1Gi + extraEnv: + - name: POSTGRES_MAX_CONNECTIONS + value: "500" + podAnnotations: {} + podSecurityContext: + enabled: true + fsGroup: 1001 + containerSecurityContext: + enabled: true + runAsUser: 1001 + livenessProbe: + enabled: true + initialDelaySeconds: 30 + periodSeconds: 20 + readinessProbe: + enabled: true + initialDelaySeconds: 5 + periodSeconds: 10 + + readReplicas: + persistence: + enabled: true + size: 10Gi + storageClass: "" + accessModes: + - ReadWriteOnce + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: 500m + memory: 1Gi + + metrics: + enabled: true + serviceMonitor: + enabled: false + +externalDatabase: + host: postgres.example.com + port: 5432 + database: skillhub + username: skillhub + password: "" + jdbcUrl: "" + +# ============================================================================ +# Redis 配置(Bitnami) +# ============================================================================ +redis: + enabled: true + + image: + registry: docker.io + repository: bitnami/redis + tag: latest + digest: "" + + architecture: standalone + + auth: + enabled: true + password: "skillhub_redis" + sentinelPassword: "" + + master: + persistence: + enabled: true + size: 5Gi + storageClass: "" + accessModes: + - ReadWriteOnce + resources: + requests: + cpu: 50m + memory: 128Mi + limits: + cpu: 200m + memory: 512Mi + podAnnotations: {} + podSecurityContext: + enabled: true + fsGroup: 1001 + containerSecurityContext: + enabled: true + runAsUser: 1001 + + replica: + persistence: + enabled: true + size: 5Gi + storageClass: "" + accessModes: + - ReadWriteOnce + resources: + requests: + cpu: 50m + memory: 128Mi + limits: + cpu: 200m + memory: 512Mi + + sentinel: + enabled: false + masterSet: mymaster + nodes: "" + service: + enabled: true + ports: + sentinel: 26379 + + metrics: + enabled: true + serviceMonitor: + enabled: false + +externalRedis: + host: redis.example.com + port: 6379 + password: "" + sentinel: + enabled: false + masterSet: mymaster + nodes: [] + +# ============================================================================ +# Server 配置 # ============================================================================ server: - # 组件级镜像标签(留空时使用全局 images.tag) + enabled: true + replicaCount: 1 + image: + registry: "" tag: "" + + service: + enabled: true + type: ClusterIP + port: 8080 + nodePort: "" + loadBalancerIP: "" + loadBalancerSourceRanges: [] + + storage: + # 访问模式:留空时自动判断(单副本 RWO,多副本 RWX),或手动指定 + accessMode: "" + size: 10Gi + storageClassName: "" + resources: requests: cpu: 500m @@ -74,6 +268,7 @@ server: limits: cpu: 1000m memory: 1Gi + javaOpts: "" extraEnv: [] podAnnotations: {} @@ -81,17 +276,7 @@ server: nodeSelector: {} tolerations: [] affinity: {} - # HPA(自动扩缩容) - autoscaling: - enabled: false - minReplicas: 1 - maxReplicas: 10 - targetCPUUtilizationPercentage: 80 - targetMemoryUtilizationPercentage: 80 - # PDB(自愿干扰预算,多副本时保证最少可用实例) - podDisruptionBudget: - enabled: false - minAvailable: 1 + probes: startup: httpGet: @@ -113,10 +298,34 @@ server: initialDelaySeconds: 30 periodSeconds: 15 + autoscaling: + enabled: false + minReplicas: 1 + maxReplicas: 10 + targetCPUUtilizationPercentage: 80 + targetMemoryUtilizationPercentage: 80 + + podDisruptionBudget: + enabled: false + minAvailable: 1 + +# ============================================================================ +# Web 配置 +# ============================================================================ web: - # 组件级镜像标签(留空时使用全局 images.tag) + replicaCount: 1 image: + registry: "" tag: "" + + service: + enabled: true + type: ClusterIP + port: 80 + nodePort: "" + loadBalancerIP: "" + loadBalancerSourceRanges: [] + resources: requests: cpu: 100m @@ -124,23 +333,14 @@ web: limits: cpu: 200m memory: 256Mi + extraEnv: [] podAnnotations: {} imagePullSecrets: [] nodeSelector: {} tolerations: [] affinity: {} - # HPA(自动扩缩容) - autoscaling: - enabled: false - minReplicas: 1 - maxReplicas: 5 - targetCPUUtilizationPercentage: 80 - targetMemoryUtilizationPercentage: 80 - # PDB(自愿干扰预算,多副本时保证最少可用实例) - podDisruptionBudget: - enabled: false - minAvailable: 1 + probes: readiness: httpGet: @@ -155,11 +355,30 @@ web: initialDelaySeconds: 10 periodSeconds: 15 + autoscaling: + enabled: false + minReplicas: 1 + maxReplicas: 5 + targetCPUUtilizationPercentage: 80 + targetMemoryUtilizationPercentage: 80 + + podDisruptionBudget: + enabled: false + minAvailable: 1 + +# ============================================================================ +# Scanner 配置 +# ============================================================================ scanner: enabled: true - # 组件级镜像标签(留空时使用全局 images.tag) + replicaCount: 1 image: + registry: "" tag: "" + + service: + port: 8000 + resources: requests: cpu: 100m @@ -167,23 +386,14 @@ scanner: limits: cpu: 500m memory: 512Mi + extraEnv: [] podAnnotations: {} imagePullSecrets: [] nodeSelector: {} tolerations: [] affinity: {} - # HPA(自动扩缩容) - autoscaling: - enabled: false - minReplicas: 1 - maxReplicas: 5 - targetCPUUtilizationPercentage: 80 - targetMemoryUtilizationPercentage: 80 - # PDB(自愿干扰预算,多副本时保证最少可用实例) - podDisruptionBudget: - enabled: false - minAvailable: 1 + probes: readiness: httpGet: @@ -198,122 +408,13 @@ scanner: initialDelaySeconds: 20 periodSeconds: 15 -# ============================================================================ -# 数据库配置(PostgreSQL) -# ============================================================================ -# mode: internal(内置单实例)| external(外置) -# 内置模式部署单实例 PostgreSQL。 -# 如需高可用集群,请使用 external 模式连接外部集群。 -database: - mode: internal - - internal: - image: postgres:16-alpine - registry: docker.io - storage: 10Gi - storageClassName: "" - resources: - requests: - cpu: 100m - memory: 256Mi - limits: - cpu: 500m - memory: 512Mi - - external: - host: postgres.example.com - port: 5432 - database: skillhub - username: skillhub - password: "" - parameters: "" - # 自定义 JDBC URL(非空时覆盖 host/port/database/parameters 的拼接结果) - # 用于连接外部 PostgreSQL 集群(如 Patroni、JDBC 多主机等) - jdbcUrl: "" - -# ============================================================================ -# Redis 配置 -# ============================================================================ -# mode: internal(内置单实例)| external(外置) -# 注意: Redis Cluster 模式不支持 -redis: - mode: internal - - internal: - image: redis:7-alpine - registry: docker.io - storage: 5Gi - storageClassName: "" - resources: - requests: - cpu: 50m - memory: 128Mi - limits: - cpu: 200m - memory: 256Mi - - external: - host: redis.example.com - port: 6379 - password: "" - sentinel: - enabled: false - masterSet: mymaster - nodes: [] - -# ============================================================================ -# 存储配置 -# ============================================================================ -storage: - provider: local - local: - # 多副本 (replicaCount > 1) 时必须设为 ReadWriteMany,底层需支持 RWX(如 NFS/Longhorn) - accessMode: ReadWriteMany - storage: 10Gi - storageClassName: "" - s3: - bucket: skillhub-storage - endpoint: "" - region: "" - accessKey: "" - secretKey: "" - -# ============================================================================ -# Bootstrap 管理员 -# ============================================================================ -bootstrapAdmin: - enabled: true - userId: docker-admin - username: admin - displayName: "Platform Admin" - email: admin@example.com - password: "" - -# ============================================================================ -# Session 配置 -# ============================================================================ -session: - cookieSecure: false - -# ============================================================================ -# Spring Profiles -# ============================================================================ -springProfilesActive: docker - -# ============================================================================ -# Secret 配置 -# ============================================================================ -# 使用已有 Secret(优先级高于下方 secrets.* 字段) -# 设置后 chart 不会创建 Secret,而是直接引用该名称 -existingSecret: "" - -secrets: - springDatasourceUrl: "" - springDatasourceUsername: "" - springDatasourcePassword: "" - bootstrapAdminPassword: "" - oauth2GithubClientId: "" - oauth2GithubClientSecret: "" - scannerLlmApiKey: "" - scannerLlmModel: "" + autoscaling: + enabled: false + minReplicas: 1 + maxReplicas: 5 + targetCPUUtilizationPercentage: 80 + targetMemoryUtilizationPercentage: 80 + podDisruptionBudget: + enabled: false + minAvailable: 1 From 07c97cf7cd92efee0dc0c9debc24745a560d2314 Mon Sep 17 00:00:00 2001 From: jangrui Date: Mon, 1 Jun 2026 08:35:35 +0800 Subject: [PATCH 10/81] =?UTF-8?q?feat(server):=20=E6=94=AF=E6=8C=81=20Redi?= =?UTF-8?q?s=20Sentinel=20=E6=A8=A1=E5=BC=8F=E5=AF=86=E7=A0=81=E9=85=8D?= =?UTF-8?q?=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RedissonConfig 根据 spring.profiles.active 切换普通/Sentinel密码 - 新增 application-redis-sentinel.yml 专属 Spring profile - 新增 RedissonConfigTest 覆盖普通和 Sentinel 两种模式用例 Signed-off-by: jangrui --- .../skillhub/config/RedissonConfig.java | 6 +++- .../resources/application-redis-sentinel.yml | 8 +++++ .../skillhub/config/RedissonConfigTest.java | 31 +++++++++++++++++++ 3 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 server/skillhub-app/src/main/resources/application-redis-sentinel.yml diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/RedissonConfig.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/RedissonConfig.java index 1e15eace..df8f528e 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/RedissonConfig.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/RedissonConfig.java @@ -41,7 +41,8 @@ public class RedissonConfig { private static void configureSentinelServers(Config config, RedisProperties redisProperties) { SentinelServersConfig sentinelServersConfig = config.useSentinelServers() .setMasterName(redisProperties.getSentinel().getMaster()) - .setDatabase(redisProperties.getDatabase()); + .setDatabase(redisProperties.getDatabase()) + .setCheckSentinelsList(false); List nodes = redisProperties.getSentinel().getNodes(); nodes.stream() .map(String::trim) @@ -50,6 +51,9 @@ public class RedissonConfig { .forEach(sentinelServersConfig::addSentinelAddress); applySharedSettings(sentinelServersConfig, redisProperties); + if (StringUtils.hasText(redisProperties.getSentinel().getPassword())) { + sentinelServersConfig.setSentinelPassword(redisProperties.getSentinel().getPassword()); + } } private static boolean hasSentinelConfiguration(RedisProperties redisProperties) { diff --git a/server/skillhub-app/src/main/resources/application-redis-sentinel.yml b/server/skillhub-app/src/main/resources/application-redis-sentinel.yml new file mode 100644 index 00000000..b203b0cd --- /dev/null +++ b/server/skillhub-app/src/main/resources/application-redis-sentinel.yml @@ -0,0 +1,8 @@ +spring: + data: + redis: + password: ${SPRING_DATA_REDIS_SENTINEL_PASSWORD:${SPRING_DATA_REDIS_PASSWORD:${REDIS_PASSWORD:}}} + sentinel: + master: ${SPRING_DATA_REDIS_SENTINEL_MASTER:${REDIS_SENTINEL_MASTER:mymaster}} + nodes: ${SPRING_DATA_REDIS_SENTINEL_NODES:${REDIS_SENTINEL_NODES:}} + password: ${SPRING_DATA_REDIS_SENTINEL_PASSWORD:${REDIS_SENTINEL_PASSWORD:}} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/RedissonConfigTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/RedissonConfigTest.java index 4ebb502d..bd18e6a5 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/RedissonConfigTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/RedissonConfigTest.java @@ -105,6 +105,37 @@ class RedissonConfigTest { assertThat(sentinelConfig.getSentinelAddresses()).containsExactly("rediss://redis-sentinel-1:26379"); } + @Test + void createConfig_appliesSentinelPasswordWhenSet() throws Exception { + RedisProperties properties = new RedisProperties(); + RedisProperties.Sentinel sentinel = new RedisProperties.Sentinel(); + sentinel.setMaster("mymaster"); + sentinel.setNodes(List.of("redis-sentinel-1:26379")); + sentinel.setPassword("sentinel-secret"); + properties.setSentinel(sentinel); + properties.setPassword("master-secret"); + + Config config = RedissonConfig.createConfig(properties); + SentinelServersConfig sentinelConfig = sentinelConfig(config); + + assertThat(sentinelConfig.getSentinelPassword()).isEqualTo("sentinel-secret"); + assertThat(sentinelConfig.getPassword()).isEqualTo("master-secret"); + } + + @Test + void createConfig_sentinelCheckSentinelsListDisabled() throws Exception { + RedisProperties properties = new RedisProperties(); + RedisProperties.Sentinel sentinel = new RedisProperties.Sentinel(); + sentinel.setMaster("mymaster"); + sentinel.setNodes(List.of("redis-sentinel-1:26379")); + properties.setSentinel(sentinel); + + Config config = RedissonConfig.createConfig(properties); + SentinelServersConfig sentinelConfig = sentinelConfig(config); + + assertThat(sentinelConfig.isCheckSentinelsList()).isFalse(); + } + private SentinelServersConfig sentinelConfig(Config config) throws Exception { Method method = Config.class.getDeclaredMethod("getSentinelServersConfig"); method.setAccessible(true); From f5f42454f796a0751dd6b33407be9307ed7a86a5 Mon Sep 17 00:00:00 2001 From: yaffir <97219715+yaffir@users.noreply.github.com> Date: Wed, 3 Jun 2026 16:31:48 +0800 Subject: [PATCH 11/81] =?UTF-8?q?=E8=87=AA=E5=8A=A8=E5=B1=8F=E8=94=BD=20pl?= =?UTF-8?q?aceholder=20OAuth=20provider?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added a method to validate OAuth provider configurations based on client ID. Signed-off-by: yaffir <97219715+yaffir@users.noreply.github.com> --- .../skillhub/service/AuthMethodCatalog.java | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AuthMethodCatalog.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AuthMethodCatalog.java index 84324f18..cc927801 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AuthMethodCatalog.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AuthMethodCatalog.java @@ -43,6 +43,7 @@ public class AuthMethodCatalog { public List listOAuthProviders(String returnTo) { String sanitizedReturnTo = OAuthLoginRedirectSupport.sanitizeReturnTo(returnTo); return new ArrayList<>(oAuth2ClientProperties.getRegistration().entrySet().stream() + .filter(entry -> isValidOAuthProvider(entry.getValue())) .sorted(Comparator.comparing(entry -> entry.getKey())) .map(entry -> new AuthProviderResponse( entry.getKey(), @@ -54,6 +55,19 @@ public class AuthMethodCatalog { .toList()); } + /** + * Check if an OAuth provider has valid configuration (non-empty client-id that is not a placeholder). + */ + private boolean isValidOAuthProvider(OAuth2ClientProperties.Registration registration) { + String clientId = registration.getClientId(); + if (clientId == null || clientId.isBlank()) { + return false; + } + // Filter out placeholder values used in dev/test configs + String lowerClientId = clientId.toLowerCase(); + return !lowerClientId.contains("placeholder") && !lowerClientId.contains("local-placeholder"); + } + public List listMethods(String returnTo) { String sanitizedReturnTo = OAuthLoginRedirectSupport.sanitizeReturnTo(returnTo); List methods = new ArrayList<>(); @@ -67,6 +81,7 @@ public class AuthMethodCatalog { )); oAuth2ClientProperties.getRegistration().entrySet().stream() + .filter(entry -> isValidOAuthProvider(entry.getValue())) .sorted(Comparator.comparing(entry -> entry.getKey())) .forEach(entry -> methods.add(new AuthMethodResponse( "oauth-" + entry.getKey(), From 060535731674a1755b0db3d1e05f43a5258bf271 Mon Sep 17 00:00:00 2001 From: jangrui Date: Thu, 4 Jun 2026 06:32:30 +0800 Subject: [PATCH 12/81] =?UTF-8?q?fix(chart):=20=E4=BF=AE=E5=A4=8D=20PR=20r?= =?UTF-8?q?eview=20=E5=8F=8D=E9=A6=88=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除 subchart image block,使用 Bitnami 默认版本 - 移除 Ingress cert-manager annotation,消除双重签发 - 清空默认明文密码,改为空字符串 - 排除 tgz 进 git,CI 添加 helm dependency build - checksum 改为模板级渲染,修复文件末尾换行 - sentinel default 3,列表生成改用 append+join - 添加 externalRedis.sentinel.password 字段 - README 补充 existingSecret key 清单 - CI 添加 kubeconform -strict 校验 - RedissonConfig 添加注释,补充空密码测试用例 Signed-off-by: jangrui --- .github/workflows/pr-helm-chart.yml | 23 ++++++++++------- .gitignore | 3 +++ charts/skillhub/README.md | 24 ++++++++++++++++++ charts/skillhub/charts/postgresql-18.6.10.tgz | Bin 89599 -> 0 bytes charts/skillhub/charts/redis-25.5.3.tgz | Bin 104594 -> 0 bytes charts/skillhub/templates/_helpers.tpl | 4 +-- charts/skillhub/templates/ingress.yaml | 8 ------ charts/skillhub/templates/secret.yaml | 2 +- .../skillhub/templates/server-deployment.yaml | 6 ++--- charts/skillhub/values.yaml | 17 +++---------- .../skillhub/config/RedissonConfig.java | 2 ++ .../skillhub/config/RedissonConfigTest.java | 16 ++++++++++++ 12 files changed, 68 insertions(+), 37 deletions(-) delete mode 100644 charts/skillhub/charts/postgresql-18.6.10.tgz delete mode 100644 charts/skillhub/charts/redis-25.5.3.tgz diff --git a/.github/workflows/pr-helm-chart.yml b/.github/workflows/pr-helm-chart.yml index 52fa7366..48afc044 100644 --- a/.github/workflows/pr-helm-chart.yml +++ b/.github/workflows/pr-helm-chart.yml @@ -36,6 +36,9 @@ jobs: with: version: latest + - name: Build dependencies + run: helm dependency build . + - name: Lint chart run: helm lint . @@ -124,24 +127,26 @@ jobs: with: version: latest + - name: Build dependencies + run: helm dependency build . + - name: Render template - ${{ matrix.scenario.name }} run: | echo "## ${{ matrix.scenario.description }}" - helm template test-release . ${{ matrix.scenario.args }} > /tmp/rendered.yaml + helm template test-release . ${{ matrix.scenario.args }} > rendered.yaml echo "✅ Template rendered successfully" - name: Validate resources run: | - RESOURCES=$(grep -c '^kind:' /tmp/rendered.yaml || true) + RESOURCES=$(grep -c '^kind:' rendered.yaml || true) echo "Rendered $RESOURCES resources for ${{ matrix.scenario.name }}" if [ "$RESOURCES" -eq 0 ]; then echo "ERROR: No resources rendered for ${{ matrix.scenario.name }}" exit 1 fi - grep -E '^ name:' /tmp/rendered.yaml | while read -r line; do - if echo "$line" | grep -qP '\{\{'; then - echo "ERROR: Unrendered template in name: $line" - exit 1 - fi - done - echo "✅ All resource names properly rendered" + + - name: Schema validation (kubeconform) + uses: docker://ghcr.io/yannh/kubeconform:latest + with: + entrypoint: '/kubeconform' + args: "-strict -summary -output text charts/skillhub/rendered.yaml" diff --git a/.gitignore b/.gitignore index 3a6c5f5b..76a972db 100644 --- a/.gitignore +++ b/.gitignore @@ -84,5 +84,8 @@ docs/superpowers/ # Local workspace metadata CLAUDE.md +# Helm chart dependencies +charts/skillhub/charts/*.tgz + # Local config file .mcp.json diff --git a/charts/skillhub/README.md b/charts/skillhub/README.md index b74d6985..19fcefc3 100644 --- a/charts/skillhub/README.md +++ b/charts/skillhub/README.md @@ -53,6 +53,30 @@ helm -n skillhub upgrade -i skillhub ./charts/skillhub \ --set externalRedis.password=your-redis-password ``` +### 使用 existingSecret + +通过 `existingSecret` 引用已存在的 Secret 对象,避免在 values 中明文写入密码。该 Secret 必须包含以下 key: + +| Key | 必填 | 说明 | +|-----|------|------| +| `spring-datasource-url` | 是 | JDBC 连接 URL | +| `spring-datasource-username` | 是 | 数据库用户名 | +| `spring-datasource-password` | 是 | 数据库密码 | +| `redis-password` | 是 | Redis 密码 | +| `redis-sentinel-password` | 否 | Redis Sentinel 密码(sentinel 模式) | +| `bootstrap-admin-password` | 是 | 初始管理员密码 | +| `oauth2-github-client-id` | 否 | GitHub OAuth2 Client ID | +| `oauth2-github-client-secret` | 否 | GitHub OAuth2 Client Secret | +| `skill-scanner-llm-api-key` | 否 | Scanner LLM API Key | +| `skill-scanner-llm-model` | 否 | Scanner LLM 模型名称 | +| `s3-access-key` | 否 | S3 Access Key | +| `s3-secret-key` | 否 | S3 Secret Key | + +```bash +helm -n skillhub upgrade -i skillhub ./charts/skillhub \ + --set existingSecret=my-custom-secret +``` + ## 配置参考 ### 副本数配置 diff --git a/charts/skillhub/charts/postgresql-18.6.10.tgz b/charts/skillhub/charts/postgresql-18.6.10.tgz deleted file mode 100644 index cb6f57c3242900e4ff28bbd0a53a1840820f8bfa..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 89599 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�OuVA9)@w-MF%?qq$p$RHWm?^FoY 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�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 AA9h=6i(WkBb%bJhezs8fR)KdmN3 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%0D$wmkMse92F=F)Y!Ic>H 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 diff --git a/charts/skillhub/templates/_helpers.tpl b/charts/skillhub/templates/_helpers.tpl index 824363d4..7315f3b4 100644 --- a/charts/skillhub/templates/_helpers.tpl +++ b/charts/skillhub/templates/_helpers.tpl @@ -138,8 +138,8 @@ app.kubernetes.io/component: scanner {{- $prefix := printf "%s-redis-node" (include "skillhub.fullname" .) -}} {{- $headless := printf "%s-redis-headless" (include "skillhub.fullname" .) -}} {{- $port := include "skillhub.redis.port" . -}} -{{- $replicas := .Values.redis.replica.replicaCount | default 2 | int -}} -{{- $first := true -}}{{- range $i := until $replicas -}}{{- if not $first -}},{{- end -}}{{ $prefix }}-{{ $i }}.{{ $headless }}.{{ $.Release.Namespace }}.svc.cluster.local:{{ $port }}{{- $first = false -}}{{- end -}} +{{- $replicas := .Values.redis.replica.replicaCount | default 3 | int -}} +{{- $nodes := list -}}{{- range $i := until $replicas -}}{{- $nodes = append $nodes (printf "%s-%d.%s.%s.svc.cluster.local:%s" $prefix $i $headless $.Release.Namespace $port) -}}{{- end -}}{{- join "," $nodes -}} {{- end }} {{- /* Redis Host */}} diff --git a/charts/skillhub/templates/ingress.yaml b/charts/skillhub/templates/ingress.yaml index 00a8b602..8c95928c 100644 --- a/charts/skillhub/templates/ingress.yaml +++ b/charts/skillhub/templates/ingress.yaml @@ -10,14 +10,6 @@ metadata: {{- if .Values.ingress.annotations }} {{- toYaml .Values.ingress.annotations | nindent 4 }} {{- end }} - {{- if .Values.ingress.certManager.enabled }} - {{- if eq .Values.ingress.certManager.issuerKind "ClusterIssuer" }} - cert-manager.io/cluster-issuer: {{ .Values.ingress.certManager.issuerName }} - {{- else }} - cert-manager.io/issuer: {{ .Values.ingress.certManager.issuerName }} - {{- end }} - cert-manager.io/issuer-kind: {{ .Values.ingress.certManager.issuerKind }} - {{- end }} spec: ingressClassName: {{ .Values.ingress.className }} {{- if or .Values.ingress.tls.enabled .Values.ingress.certManager.enabled }} diff --git a/charts/skillhub/templates/secret.yaml b/charts/skillhub/templates/secret.yaml index eb568b82..648fd9e7 100644 --- a/charts/skillhub/templates/secret.yaml +++ b/charts/skillhub/templates/secret.yaml @@ -53,7 +53,7 @@ stringData: redis-sentinel-password: {{ .Values.redis.auth.sentinelPassword | default .Values.redis.auth.password | quote }} {{- end }} {{- else if and (not .Values.redis.enabled) .Values.externalRedis.sentinel.enabled }} - redis-sentinel-password: {{ .Values.externalRedis.password | default "" | quote }} + redis-sentinel-password: {{ .Values.externalRedis.sentinel.password | default .Values.externalRedis.password | default "" | quote }} {{- end }} # Bootstrap 管理员密码 # 优先级: secrets.bootstrapAdminPassword → bootstrapAdmin.password → 集群已有 Secret → 随机生成 diff --git a/charts/skillhub/templates/server-deployment.yaml b/charts/skillhub/templates/server-deployment.yaml index ac854665..2363efe2 100644 --- a/charts/skillhub/templates/server-deployment.yaml +++ b/charts/skillhub/templates/server-deployment.yaml @@ -17,8 +17,8 @@ spec: labels: {{- include "skillhub.server.selectorLabels" . | nindent 8 }} annotations: - checksum/config: {{ toYaml (dict "redisHost" (include "skillhub.redis.host" .) "redisPort" (include "skillhub.redis.port" .) "storage" .Values.server.storage "s3" .Values.s3 "session" .Values.session "springProfilesActive" .Values.springProfilesActive "bootstrapAdmin" .Values.bootstrapAdmin "scannerEnabled" .Values.scanner.enabled "scannerPort" .Values.scanner.service.port) | sha256sum }} - checksum/secret: {{ toYaml (dict "secrets" .Values.secrets "bootstrapAdmin" .Values.bootstrapAdmin) | sha256sum }} + checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }} + checksum/secret: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }} {{- range $key, $val := .Values.server.podAnnotations }} {{ $key }}: {{ $val }} {{- end }} @@ -285,4 +285,4 @@ spec: {{- toYaml . | nindent 8 }} {{- end }} -{{- end }} \ No newline at end of file +{{- end }} diff --git a/charts/skillhub/values.yaml b/charts/skillhub/values.yaml index 9cedffdc..3fb16248 100644 --- a/charts/skillhub/values.yaml +++ b/charts/skillhub/values.yaml @@ -82,19 +82,13 @@ secrets: postgresql: enabled: true - image: - registry: docker.io - repository: bitnami/postgresql - tag: latest - digest: "" - architecture: standalone auth: postgresPassword: "" database: skillhub username: skillhub - password: "skillhub_demo" + password: "" primary: persistence: @@ -163,17 +157,11 @@ externalDatabase: redis: enabled: true - image: - registry: docker.io - repository: bitnami/redis - tag: latest - digest: "" - architecture: standalone auth: enabled: true - password: "skillhub_redis" + password: "" sentinelPassword: "" master: @@ -235,6 +223,7 @@ externalRedis: enabled: false masterSet: mymaster nodes: [] + password: "" # ============================================================================ # Server 配置 diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/RedissonConfig.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/RedissonConfig.java index df8f528e..09123c37 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/RedissonConfig.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/RedissonConfig.java @@ -42,6 +42,8 @@ public class RedissonConfig { SentinelServersConfig sentinelServersConfig = config.useSentinelServers() .setMasterName(redisProperties.getSentinel().getMaster()) .setDatabase(redisProperties.getDatabase()) + // K8s headless DNS 场景下,客户端通过 pod FQDN 连接 sentinel, + // 与 sentinel 自身上报的地址格式不同,跳过地址一致性检查避免误报连接失败 .setCheckSentinelsList(false); List nodes = redisProperties.getSentinel().getNodes(); nodes.stream() diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/RedissonConfigTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/RedissonConfigTest.java index bd18e6a5..1095f16e 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/RedissonConfigTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/RedissonConfigTest.java @@ -136,6 +136,22 @@ class RedissonConfigTest { assertThat(sentinelConfig.isCheckSentinelsList()).isFalse(); } + @Test + void createConfig_doesNotSetSentinelPasswordWhenEmpty() throws Exception { + RedisProperties properties = new RedisProperties(); + RedisProperties.Sentinel sentinel = new RedisProperties.Sentinel(); + sentinel.setMaster("mymaster"); + sentinel.setNodes(List.of("redis-sentinel-1:26379")); + properties.setSentinel(sentinel); + properties.setPassword("master-secret"); + + Config config = RedissonConfig.createConfig(properties); + SentinelServersConfig sentinelConfig = sentinelConfig(config); + + assertThat(sentinelConfig.getPassword()).isEqualTo("master-secret"); + assertThat(sentinelConfig.getSentinelPassword()).isNull(); + } + private SentinelServersConfig sentinelConfig(Config config) throws Exception { Method method = Config.class.getDeclaredMethod("getSentinelServersConfig"); method.setAccessible(true); From 2b1be5ccf86a56ed27c3c19f265897795e40b20a Mon Sep 17 00:00:00 2001 From: jangrui Date: Tue, 9 Jun 2026 17:17:06 +0800 Subject: [PATCH 13/81] =?UTF-8?q?fix(ci):=20kubeconform=20=E5=8A=A0?= =?UTF-8?q?=E8=BD=BD=20CRD=20schema=20=E4=BF=AE=E5=A4=8D=20cert-manager=20?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ingress-tls-certmanager 场景渲染出 cert-manager 的 Certificate CRD, kubeconform 默认仅内置原生 k8s schema,遇到 CRD 报 "could not find schema"。 追加 -schema-location 从 datreeio/CRDs-catalog 远程加载 CRD schema, 覆盖整个 catalog 收录的 CRD 资源,9 场景矩阵无需差异化处理。 Signed-off-by: jangrui --- .github/workflows/pr-helm-chart.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr-helm-chart.yml b/.github/workflows/pr-helm-chart.yml index 48afc044..b1c38d37 100644 --- a/.github/workflows/pr-helm-chart.yml +++ b/.github/workflows/pr-helm-chart.yml @@ -149,4 +149,4 @@ jobs: uses: docker://ghcr.io/yannh/kubeconform:latest with: entrypoint: '/kubeconform' - args: "-strict -summary -output text charts/skillhub/rendered.yaml" + 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" From 4aee3a64cdda8eb4960f141855c55613169ba4e4 Mon Sep 17 00:00:00 2001 From: FenjuFu Date: Tue, 16 Jun 2026 01:30:38 +0800 Subject: [PATCH 14/81] docs(faq): supplement FAQ (zh & en) with community-sourced Q&A Add questions frequently raised in the user community that were not yet covered in the SkillHub FAQ, for both Chinese and English pages: - Recommended deployment via the one-line script vs manual image pulls - Redirected back to login page after deploying (manual deployment) - Changing the admin password / why env changes need a restart - Password change/reset requires email code (SMTP setup) - Skill naming (English only; Chinese names error in OpenClaw) - Whether unreviewed skills can be downloaded - Hiding/removing GitHub & GitLab SSO login options - Built-in Skill Scanner: iFLYTEK integration over Cisco's scanner (Apache-2.0) - Which cisco-ai-skill-scanner version is used (unpinned in Dockerfile) - Note that upgrades preserve registered skills; online docs link Signed-off-by: FenjuFu --- docs/skillhub/en/faq.md | 53 ++++++++++++++++++++++++++++++++++++++++- docs/skillhub/faq.md | 53 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 104 insertions(+), 2 deletions(-) diff --git a/docs/skillhub/en/faq.md b/docs/skillhub/en/faq.md index ac5326e4..2d350fee 100644 --- a/docs/skillhub/en/faq.md +++ b/docs/skillhub/en/faq.md @@ -122,7 +122,7 @@ curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- u curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --version v0.2.0 ``` -> **Note**: It is recommended to back up the database and object storage before upgrading. Database migrations are handled automatically by Flyway. +> **Note**: It is recommended to back up the database and object storage before upgrading. Database migrations are handled automatically by Flyway. Upgrading does not wipe the database, so already-registered skill packages will not be lost. ## Q: Why can't administrators (admin) and regular users create namespaces? @@ -136,11 +136,62 @@ curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- u A: When using the OpenClaw CLI, you can specify the namespace using the `--` format for operations like search or installation. If you encounter issues finding it on the web interface, you can also manage it by exporting the skill package and importing it into your target namespace. +## Q: What is the recommended deployment method? Can I pull the images and deploy manually? + +A: We recommend the official one-line deployment script. Pulling images and deploying manually is not recommended (manual deployment is prone to initialization issues such as being redirected back to the login page after logging in): + +```bash +curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --aliyun --public-url https://skillhub.your-company.com --version latest +``` + +The script performs a series of initialization steps. The generated runtime configuration is located at `/tmp/skillhub-runtime/` by default (containing `.env.release` and the docker-compose file). + +## Q: After deployment, I enter the correct username and password but get redirected back to the login page? + +A: This is most commonly seen with **manual deployment** (caused by API errors or incomplete initialization). Suggestions: + +1. Switch to the one-line script above for deployment. +2. If necessary, clear and recreate the PostgreSQL data volume, then log in again. +3. If a reverse proxy is in front, verify that it forwards requests correctly. + +## Q: How do I change the admin password? Why don't my config changes take effect? + +A: Environment variables are read at container startup, so you must restart the containers after changing them. + +1. Edit `/tmp/skillhub-runtime/.env.release` in the runtime directory (refer to [.env.release.example](https://github.com/iflytek/skillhub/blob/main/.env.release.example)). +2. Restart the relevant containers. +3. If the password was already persisted to the database and the change still doesn't take effect, you may need to clear the corresponding data and re-initialize. + +## Q: Is an email verification code required to change / reset a password? + +A: Yes. By default, passwords are changed or reset via an email verification code, so SMTP must be configured first. See [docs/19-smtp-password-reset-email-setup.md](https://github.com/iflytek/skillhub/blob/main/docs/19-smtp-password-reset-email-setup.md). Administrators can also reset it via `.env.release`. + +## Q: Can a skill have a Chinese name? + +A: Skill names are generally in English; Chinese names are not currently supported (using a Chinese skill name in OpenClaw will cause an error). + +## Q: Can unreviewed skills be downloaded? + +A: As long as you have permission to view it, it can generally be downloaded. + +## Q: How do I hide or remove the GitHub / GitLab SSO login options on the login page? + +A: Edit `application.yml` and comment out or delete the `github` and `gitlab` blocks under `spring.security.oauth2.client.registration`, along with their corresponding `provider` sections. Spring Boot then won't create these registrations at startup, and the login page won't show those entries. + +## Q: Is SkillHub's security scanning (Skill Scanner) developed in-house by iFLYTEK? What license does it use? + +A: SkillHub has built-in security scanning. The scanner integration, task orchestration, audit persistence, and deployment integration are implemented by the iFLYTEK team; the underlying scanning service uses Cisco's [cisco-ai-skill-scanner](https://github.com/cisco-ai-defense/skill-scanner) (Apache License 2.0, copyright Cisco). + +## Q: Which version of cisco-ai-skill-scanner does SkillHub use? + +A: `scanner/Dockerfile` runs `pip install cisco-ai-skill-scanner` directly without pinning a version, so the latest version on PyPI is pulled when the image is built. To pin a version, do so yourself when customizing the build. + ## Q: What should I do if I encounter issues? A: You can get help through the following channels: - **GitHub Issues**: https://github.com/iflytek/skillhub/issues +- **Online Docs**: https://www.astron-skillhub.org/ - **Documentation**: Refer to the project README.md - **Community Discussions**: https://github.com/iflytek/skillhub/discussions diff --git a/docs/skillhub/faq.md b/docs/skillhub/faq.md index a29d6d85..f27253b0 100644 --- a/docs/skillhub/faq.md +++ b/docs/skillhub/faq.md @@ -122,7 +122,7 @@ curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- u curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --version v0.2.0 ``` -> **注意**:升级前建议先备份数据库和对象存储。数据库迁移由 Flyway 自动执行。 +> **注意**:升级前建议先备份数据库和对象存储。数据库迁移由 Flyway 自动执行。升级不会清空数据库,已录入的技能包不会丢失。 ## Q: 为什么管理员(admin)和普通用户都无法创建命名空间? @@ -136,11 +136,62 @@ curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- u A: 使用 OpenClaw CLI 命令行工具时,可以通过 `--` 的格式来指定命名空间进行操作(例如搜索、安装)。如果在网页端搜索遇到问题,也可以尝试通过先导出技能、再导入到目标命名空间的方式来完成跨空间操作。 +## Q: 推荐的部署方式是什么?可以自己拉镜像手动部署吗? + +A: 推荐使用官方一键部署脚本,不建议自己拉取镜像手动部署(手动部署容易出现登录后跳回登录页等初始化问题): + +```bash +curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --aliyun --public-url https://skillhub.your-company.com --version latest +``` + +脚本会执行一系列初始化操作,生成的运行时配置默认位于 `/tmp/skillhub-runtime/`(包含 `.env.release` 和 docker-compose 文件)。 + +## Q: 部署后输入正确的账号密码,却又跳回登录页? + +A: 该现象多见于「手动部署」场景(接口异常或初始化未完成导致)。建议: + +1. 改用上面的一键脚本部署。 +2. 必要时清空 PostgreSQL 数据卷后重建再登录。 +3. 若前置了反向代理,检查代理配置是否正确转发。 + +## Q: 如何修改 admin 密码?修改配置后不生效? + +A: 环境变量在容器启动时读取,修改后必须重启容器才会生效。 + +1. 修改运行时目录下的 `/tmp/skillhub-runtime/.env.release`(参考仓库 [.env.release.example](https://github.com/iflytek/skillhub/blob/main/.env.release.example))。 +2. 重启相关容器。 +3. 若此前密码已写入数据库导致仍不生效,可能需要清理对应数据后重新初始化。 + +## Q: 修改 / 找回密码必须使用邮箱验证码吗? + +A: 是的,默认通过邮箱验证码修改或找回密码,因此需要先配置 SMTP。配置方法参考 [docs/19-smtp-password-reset-email-setup.md](https://github.com/iflytek/skillhub/blob/main/docs/19-smtp-password-reset-email-setup.md)。管理员也可在 `.env.release` 中进行重置。 + +## Q: skill 可以起中文名吗? + +A: skill name 一般使用英文,目前不支持中文名(在 OpenClaw 中使用中文 skill 名会报错)。 + +## Q: 未审核的 skill 可以下载吗? + +A: 只要拥有可查看的权限,一般都可以下载。 + +## Q: 如何隐藏或删除登录页的 GitHub / GitLab SSO 登录方式? + +A: 修改 `application.yml`,注释或删除 `spring.security.oauth2.client.registration` 下的 `github` 和 `gitlab` 两块,并删除对应的 `provider` 段。Spring Boot 启动时便不会创建这两个注册,登录页也不会再显示对应入口。 + +## Q: SkillHub 的安全扫描(Skill Scanner)是讯飞自研的吗?使用什么协议? + +A: SkillHub 内置安全扫描能力。其中扫描接入、任务编排、审计落库和部署集成由讯飞团队实现;底层扫描服务使用 Cisco 的 [cisco-ai-skill-scanner](https://github.com/cisco-ai-defense/skill-scanner)(Apache License 2.0,版权归 Cisco)。 + +## Q: SkillHub 使用的 cisco-ai-skill-scanner 是哪个版本? + +A: `scanner/Dockerfile` 中直接执行 `pip install cisco-ai-skill-scanner`,未锁定版本,因此构建镜像时会拉取 PyPI 上的最新版本。如需固定版本,可在二次开发时自行锁定。 + ## Q: 遇到问题怎么办? A: 可以通过以下方式获取帮助: - **GitHub Issues**: https://github.com/iflytek/skillhub/issues +- **在线文档**: https://www.astron-skillhub.org/ - **文档**: 参考项目 README.md - **社区讨论**: https://github.com/iflytek/skillhub/discussions From e3f84b074f98b2c20c1364f732b14ff32e9c9437 Mon Sep 17 00:00:00 2001 From: FenjuFu Date: Tue, 16 Jun 2026 01:37:01 +0800 Subject: [PATCH 15/81] docs(faq): use github.io docs URL for online docs link Signed-off-by: FenjuFu --- docs/skillhub/en/faq.md | 2 +- docs/skillhub/faq.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/skillhub/en/faq.md b/docs/skillhub/en/faq.md index 2d350fee..e65541f6 100644 --- a/docs/skillhub/en/faq.md +++ b/docs/skillhub/en/faq.md @@ -191,7 +191,7 @@ A: `scanner/Dockerfile` runs `pip install cisco-ai-skill-scanner` directly witho A: You can get help through the following channels: - **GitHub Issues**: https://github.com/iflytek/skillhub/issues -- **Online Docs**: https://www.astron-skillhub.org/ +- **Online Docs**: https://iflytek.github.io/skillhub/ - **Documentation**: Refer to the project README.md - **Community Discussions**: https://github.com/iflytek/skillhub/discussions diff --git a/docs/skillhub/faq.md b/docs/skillhub/faq.md index f27253b0..4d9715fc 100644 --- a/docs/skillhub/faq.md +++ b/docs/skillhub/faq.md @@ -191,7 +191,7 @@ A: `scanner/Dockerfile` 中直接执行 `pip install cisco-ai-skill-scanner`, A: 可以通过以下方式获取帮助: - **GitHub Issues**: https://github.com/iflytek/skillhub/issues -- **在线文档**: https://www.astron-skillhub.org/ +- **在线文档**: https://iflytek.github.io/skillhub/ - **文档**: 参考项目 README.md - **社区讨论**: https://github.com/iflytek/skillhub/discussions From e57667ae60069890ccb040fbafd4944ea7d94483 Mon Sep 17 00:00:00 2001 From: FenjuFu Date: Tue, 16 Jun 2026 10:15:36 +0800 Subject: [PATCH 16/81] docs(faq): add CLI publish & deployment Q&A (zh & en) Add more community-sourced questions (both Chinese and English): - Troubleshooting CLI `skillhub publish` returning 400 (name conflict, SKILL.md location/frontmatter, namespace membership, etc.) - Required skill package structure (SKILL.md in root) - "malformed input" on publish caused by non-UTF-8 / Chinese-path zips - Per-package file-count limit and how to raise it - Minimum server version for CLI features (v0.2.7+) - PostgreSQL-only (no MySQL); plugins not distributable yet - How to check server/CLI versions and customize via secondary dev Signed-off-by: FenjuFu --- docs/skillhub/en/faq.md | 60 +++++++++++++++++++++++++++++++++++++++++ docs/skillhub/faq.md | 60 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 120 insertions(+) diff --git a/docs/skillhub/en/faq.md b/docs/skillhub/en/faq.md index e65541f6..9cf6d556 100644 --- a/docs/skillhub/en/faq.md +++ b/docs/skillhub/en/faq.md @@ -186,6 +186,66 @@ A: SkillHub has built-in security scanning. The scanner integration, task orches A: `scanner/Dockerfile` runs `pip install cisco-ai-skill-scanner` directly without pinning a version, so the latest version on PyPI is pulled when the image is built. To pin a version, do so yourself when customizing the build. +## Q: How do I troubleshoot a `registry returned 400` error from `skillhub publish` (CLI)? + +A: A 400 usually means backend validation failed. Common causes: + +- `SKILL.md` is not in the package root directory; +- `SKILL.md` frontmatter is missing `name` / `description` or is malformed; +- name or version conflict (e.g. `error.skill.publish.nameConflict`, meaning a skill with the same name is already published in that namespace) — change `name` in `SKILL.md`, use another namespace, or have an admin handle the existing skill; +- the namespace does not exist, or you are not a member of it; +- the package contains suspected tokens/secrets that the CLI cannot confirm skipping; +- file type / size / path is not allowed. + +You can inspect the server logs to locate the cause: + +```bash +docker logs --tail=300 2>&1 | grep -Ei 'publish|SKILL.md|namespace|400|BadRequest' +``` + +## Q: What directory structure does a skill package require? + +A: The package root directory must contain a `SKILL.md` file, whose frontmatter must include fields such as `name` and `description`. + +## Q: Publishing fails with "package validation failed / malformed input" — what do I do? + +A: This error occurs while unzipping and reading file names, usually because the archive is not UTF-8 encoded (e.g. created with the built-in Windows compression tool) or contains Chinese/non-ASCII paths. Repackage using UTF-8 encoding and avoid Chinese / special-character paths. + +## Q: How many files can a skill package contain? What if I hit the file-count limit? + +A: The default limit is **100 files** (this is separate from the 100MB size limit). To raise it, change the `skillhub.publish.max-file-count` setting, or override it via an environment variable at deploy time: + +```bash +SKILLHUB_PUBLISH_MAX_FILE_COUNT=500 +``` + +Restart the containers for the change to take effect. Note that `compose.release.yml` must also reference this variable; older versions (e.g. v0.2.6) may hard-code the value, so upgrading to the latest version is recommended. + +## Q: Is there a server version requirement for using the CLI (publish / download, etc.)? + +A: A SkillHub server image of **v0.2.7 or later** is required for CLI features. + +## Q: Does SkillHub support MySQL? + +A: Currently only PostgreSQL is supported; MySQL is not supported. + +## Q: Can SkillHub be used to distribute Plugins? + +A: Not supported for now. + +## Q: How do I check the SkillHub version? How do I customize it (e.g. change the logo)? + +A: + +- Check the server image version: + +```bash +docker image inspect ghcr.io/iflytek/skillhub-server:latest --format '{{index .Config.Labels "org.opencontainers.image.version"}}' +``` + +- Check the CLI version: `skillhub version`. +- For customization (e.g. changing the logo), it is recommended to fork the latest code, modify it, and build your own Docker image. + ## Q: What should I do if I encounter issues? A: You can get help through the following channels: diff --git a/docs/skillhub/faq.md b/docs/skillhub/faq.md index 4d9715fc..2aac0a54 100644 --- a/docs/skillhub/faq.md +++ b/docs/skillhub/faq.md @@ -186,6 +186,66 @@ A: SkillHub 内置安全扫描能力。其中扫描接入、任务编排、审 A: `scanner/Dockerfile` 中直接执行 `pip install cisco-ai-skill-scanner`,未锁定版本,因此构建镜像时会拉取 PyPI 上的最新版本。如需固定版本,可在二次开发时自行锁定。 +## Q: 使用 CLI `skillhub publish` 报错 `registry returned 400` 怎么排查? + +A: 400 通常是后端校验未通过。常见原因: + +- `SKILL.md` 不在技能包根目录; +- `SKILL.md` 的 frontmatter 缺少 `name` / `description` 或格式错误; +- 名称或版本冲突(如 `error.skill.publish.nameConflict`,表示该 namespace 下已存在同名的已发布技能)——可改 `SKILL.md` 里的 `name`、换一个 namespace,或让管理员处理已有同名技能; +- namespace 不存在,或你不是该 namespace 的成员; +- 包内含疑似 token/secret,CLI 无法确认跳过; +- 文件类型 / 大小 / 路径不合规。 + +可用以下命令查看服务端日志定位: + +```bash +docker logs --tail=300 2>&1 | grep -Ei 'publish|SKILL.md|namespace|400|BadRequest' +``` + +## Q: 技能包的目录结构有什么要求? + +A: 技能包根目录必须包含一个 `SKILL.md` 文件,且其 frontmatter 需包含 `name`、`description` 等字段。 + +## Q: 发布时报“技能包校验失败 / malformed input”怎么办? + +A: 该错误发生在 zip 解包读取文件名阶段,通常是压缩包不是 UTF-8 编码(例如用 Windows 自带压缩工具生成)或包内含中文路径导致。请使用 UTF-8 编码重新打包,并避免中文 / 特殊字符路径。 + +## Q: 技能包能包含多少个文件?提示文件数超限怎么办? + +A: 默认上限为 **100 个文件**(这与 100MB 的大小限制是两回事)。如需放宽,修改配置项 `skillhub.publish.max-file-count`,或在部署时用环境变量覆盖: + +```bash +SKILLHUB_PUBLISH_MAX_FILE_COUNT=500 +``` + +修改后需重启容器生效。注意 `compose.release.yml` 中也需引用该变量;较旧版本(如 v0.2.6)可能将该值写死,建议升级到最新版本。 + +## Q: 使用 CLI(发布 / 下载等)对服务端版本有要求吗? + +A: 需要 SkillHub 服务端镜像 **v0.2.7 及以上** 才支持 CLI 功能。 + +## Q: SkillHub 支持 MySQL 数据库吗? + +A: 目前仅支持 PostgreSQL,暂不支持 MySQL。 + +## Q: SkillHub 可以用来分发 Plugin 吗? + +A: 暂不支持。 + +## Q: 如何查看 SkillHub 的版本?想做定制(如修改 logo)怎么办? + +A: + +- 查看服务端镜像版本: + +```bash +docker image inspect ghcr.io/iflytek/skillhub-server:latest --format '{{index .Config.Labels "org.opencontainers.image.version"}}' +``` + +- 查看 CLI 版本:`skillhub version`。 +- 如需定制(如修改 logo 等),建议基于最新代码进行二次开发并自行构建 docker 镜像。 + ## Q: 遇到问题怎么办? A: 可以通过以下方式获取帮助: From b56973fb80a21448ba2de8a76b93b004c3951fe2 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Fri, 12 Jun 2026 11:50:23 +0800 Subject: [PATCH 17/81] fix(auth): fail closed invalid cli bearer tokens Signed-off-by: dongmucat <1127093059@qq.com> --- docs/03-authentication-design.md | 1 + .../cli/CliSkillControllerTest.java | 112 +++++++++++++++++ .../token/ApiTokenAuthenticationFilter.java | 112 ++++++++++++----- .../ApiTokenAuthenticationFilterTest.java | 118 +++++++++++++++++- .../auth/token/ApiTokenServiceTest.java | 40 ++++++ 5 files changed, 353 insertions(+), 30 deletions(-) diff --git a/docs/03-authentication-design.md b/docs/03-authentication-design.md index db7c8b22..1c62b1c6 100644 --- a/docs/03-authentication-design.md +++ b/docs/03-authentication-design.md @@ -377,6 +377,7 @@ API Token 仍保留,但定位从“CLI 唯一认证方式”调整为“平台 - 用途:自动化脚本、兼容层调用、手工 Token 管理、后续系统集成 - 存储:只存 SHA-256 哈希,明文只展示一次 - 校验:从 `Authorization: Bearer ` 提取 → 哈希比对 → 加载关联用户 → 检查用户状态 +- 失败闭合:公共读接口只有在缺少 `Authorization` 头时才按匿名访问处理;只要出现 Bearer 凭证,空值、格式错误、未知、过期、已吊销、用户缺失或用户禁用均返回 401,不能回退为匿名访问 - 作用域:`skill:read`, `skill:publish`, `skill:delete`, `token:manage` > **一期作用域说明(非最小权限)**:一期 Token 作用域为粗粒度动作级别,不与 namespace 绑定。Token 继承用户的全部权限——如果用户是某个 namespace 的 MEMBER,则该用户的任何 Token(只要包含 `skill:publish` scope)都可以向该 namespace 发布技能。这是有意的一期简化,不满足最小权限原则。后续版本计划引入 namespace 级别的 Token 作用域限定(如 `namespace:ai-team:skill:publish`),或通过 `api_token_scope` 子表实现 Token 与 namespace 的绑定。 diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliSkillControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliSkillControllerTest.java index b2421c89..fa40e0fa 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliSkillControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliSkillControllerTest.java @@ -1,15 +1,27 @@ package com.iflytek.skillhub.controller.cli; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.auth.entity.ApiToken; +import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; +import com.iflytek.skillhub.auth.token.ApiTokenService; +import com.iflytek.skillhub.domain.namespace.NamespaceMember; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; import com.iflytek.skillhub.ratelimit.RateLimit; import com.iflytek.skillhub.service.cli.CliSkillAppService; import jakarta.servlet.http.HttpServletRequest; +import java.io.ByteArrayInputStream; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.core.io.InputStreamResource; +import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.test.context.ActiveProfiles; @@ -19,11 +31,17 @@ import org.springframework.web.multipart.MultipartFile; import java.lang.reflect.Method; import java.util.List; +import java.util.Map; +import java.util.Optional; import java.util.Set; import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.BDDMockito.given; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; @@ -35,7 +53,11 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. @ActiveProfiles("test") class CliSkillControllerTest { @Autowired MockMvc mockMvc; + @Autowired NamespaceMemberRepository namespaceMemberRepository; @MockBean CliSkillAppService cliSkillAppService; + @MockBean ApiTokenService apiTokenService; + @MockBean UserAccountRepository userAccountRepository; + @MockBean UserRoleBindingRepository userRoleBindingRepository; @Test void downloadRoutesUseDownloadRateLimit() throws Exception { @@ -73,6 +95,47 @@ class CliSkillControllerTest { .andExpect(jsonPath("$.data.items[0].latestVersion").value("1.2.0")); } + @Test + void searchRejectsInvalidBearerBeforeAnonymousAccess() throws Exception { + givenInvalidBearerToken(); + given(cliSkillAppService.search("pdf", 20, null, null)).willReturn( + new CliSkillAppService.CliSearchResult(List.of(), 0, 20) + ); + + mockMvc.perform(get("/api/cli/v1/skills/search") + .param("q", "pdf") + .param("limit", "20") + .header(HttpHeaders.AUTHORIZATION, "Bearer unknown-token")) + .andExpect(status().isUnauthorized()); + + verifyNoInteractions(cliSkillAppService); + } + + @Test + void searchWithValidBearerProjectsIdentityAndNamespaceRoles() throws Exception { + ApiToken token = new ApiToken("user-cli-token", "cli", "sk_test", "hash", "[]"); + UserAccount user = new UserAccount("user-cli-token", "CLI User", "cli@example.com", ""); + Map nsRoles = Map.of(9L, NamespaceRole.MEMBER); + + given(apiTokenService.validateToken("raw-token")).willReturn(Optional.of(token)); + given(userAccountRepository.findById("user-cli-token")).willReturn(Optional.of(user)); + given(userRoleBindingRepository.findByUserId("user-cli-token")).willReturn(List.of()); + namespaceMemberRepository.save(new NamespaceMember(9L, "user-cli-token", NamespaceRole.MEMBER)); + given(cliSkillAppService.search("private", 20, "user-cli-token", nsRoles)).willReturn( + new CliSkillAppService.CliSearchResult(List.of(), 0, 20) + ); + + mockMvc.perform(get("/api/cli/v1/skills/search") + .param("q", "private") + .param("limit", "20") + .header(HttpHeaders.AUTHORIZATION, "Bearer raw-token")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items").isArray()); + + verify(cliSkillAppService).search("private", 20, "user-cli-token", nsRoles); + verify(apiTokenService).touchLastUsed(token); + } + @Test void resolveReturnsCliResolveResponse() throws Exception { given(cliSkillAppService.resolve("global", "demo", null, null, null)).willReturn( @@ -91,6 +154,47 @@ class CliSkillControllerTest { .andExpect(jsonPath("$.data.fingerprint").value("abc123")); } + @Test + void resolveRejectsInvalidBearerBeforeAnonymousAccess() throws Exception { + givenInvalidBearerToken(); + given(cliSkillAppService.resolve("global", "demo", null, null, null)).willReturn( + new com.iflytek.skillhub.dto.cli.CliResolveResponse( + "global", "demo", "2.0.0", 42L, "abc123", + "/api/v1/skills/global/demo/versions/2.0.0/download" + ) + ); + + mockMvc.perform(get("/api/cli/v1/skills/global/demo/resolve") + .header(HttpHeaders.AUTHORIZATION, "Bearer unknown-token")) + .andExpect(status().isUnauthorized()); + + verify(cliSkillAppService, never()).resolve(any(), any(), any(), any(), any()); + } + + @Test + void downloadLatestRejectsInvalidBearerBeforeAnonymousAccess() throws Exception { + givenInvalidBearerToken(); + given(cliSkillAppService.downloadLatest(any(), any(), any())).willReturn(downloadResponse()); + + mockMvc.perform(get("/api/cli/v1/skills/global/demo/download") + .header(HttpHeaders.AUTHORIZATION, "Bearer unknown-token")) + .andExpect(status().isUnauthorized()); + + verify(cliSkillAppService, never()).downloadLatest(any(), any(), any()); + } + + @Test + void downloadVersionRejectsInvalidBearerBeforeAnonymousAccess() throws Exception { + givenInvalidBearerToken(); + given(cliSkillAppService.downloadVersion(any(), any(), any(), any())).willReturn(downloadResponse()); + + mockMvc.perform(get("/api/cli/v1/skills/global/demo/versions/1.0.0/download") + .header(HttpHeaders.AUTHORIZATION, "Bearer unknown-token")) + .andExpect(status().isUnauthorized()); + + verify(cliSkillAppService, never()).downloadVersion(any(), any(), any(), any()); + } + @Test void deleteRequiresAuthentication() throws Exception { mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders @@ -132,4 +236,12 @@ class CliSkillControllerTest { assertEquals(120, rateLimit.authenticated()); assertEquals(30, rateLimit.anonymous()); } + + private static ResponseEntity downloadResponse() { + return ResponseEntity.ok(new InputStreamResource(new ByteArrayInputStream("zip".getBytes()))); + } + + private void givenInvalidBearerToken() { + given(apiTokenService.validateToken("unknown-token")).willReturn(Optional.empty()); + } } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilter.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilter.java index 6f594c1e..8b24aa86 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilter.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilter.java @@ -10,9 +10,12 @@ import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; @@ -37,49 +40,74 @@ public class ApiTokenAuthenticationFilter extends OncePerRequestFilter { private final UserAccountRepository userRepo; private final UserRoleBindingRepository roleBindingRepo; private final ApiTokenScopeService apiTokenScopeService; + private final AuthenticationEntryPoint authenticationEntryPoint; + @Autowired public ApiTokenAuthenticationFilter(ApiTokenService apiTokenService, UserAccountRepository userRepo, UserRoleBindingRepository roleBindingRepo, - ApiTokenScopeService apiTokenScopeService) { + ApiTokenScopeService apiTokenScopeService, + AuthenticationEntryPoint authenticationEntryPoint) { this.apiTokenService = apiTokenService; this.userRepo = userRepo; this.roleBindingRepo = roleBindingRepo; this.apiTokenScopeService = apiTokenScopeService; + this.authenticationEntryPoint = authenticationEntryPoint; + } + + ApiTokenAuthenticationFilter(ApiTokenService apiTokenService, + UserAccountRepository userRepo, + UserRoleBindingRepository roleBindingRepo, + ApiTokenScopeService apiTokenScopeService) { + this(apiTokenService, userRepo, roleBindingRepo, apiTokenScopeService, + (request, response, authException) -> + response.sendError(HttpServletResponse.SC_UNAUTHORIZED, authException.getMessage())); } @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String authHeader = request.getHeader(AUTH_HEADER); - if (authHeader != null && authHeader.startsWith(BEARER_PREFIX)) { - String rawToken = authHeader.substring(BEARER_PREFIX.length()); - apiTokenService.validateToken(rawToken).ifPresent(token -> { - userRepo.findById(token.getUserId()).ifPresent(user -> { - if (!user.isActive()) { - return; - } - Set roles = roleBindingRepo.findByUserId(user.getId()).stream() - .map(rb -> rb.getRole().getCode()) - .collect(Collectors.toSet()); - roles = PlatformRoleDefaults.withDefaultUserRole(roles); - Set scopes = apiTokenScopeService.parseScopes(token.getScopeJson()); - PlatformPrincipal principal = new PlatformPrincipal( - user.getId(), user.getDisplayName(), user.getEmail(), - user.getAvatarUrl(), "api_token", roles - ); - List authorities = new ArrayList<>(); - authorities.addAll(roles.stream() - .map(role -> new SimpleGrantedAuthority("ROLE_" + role)) - .toList()); - authorities.addAll(scopes.stream() - .map(scope -> new SimpleGrantedAuthority("SCOPE_" + scope)) - .toList()); - var auth = new UsernamePasswordAuthenticationToken(principal, null, authorities); - SecurityContextHolder.getContext().setAuthentication(auth); - apiTokenService.touchLastUsed(token); - }); - }); + if (authHeader != null && isBearerAuthorization(authHeader)) { + String rawToken = extractBearerToken(authHeader); + if (rawToken == null) { + rejectBearer(request, response); + return; + } + + var token = apiTokenService.validateToken(rawToken); + if (token.isEmpty()) { + rejectBearer(request, response); + return; + } + + ApiToken apiToken = token.get(); + var user = userRepo.findById(apiToken.getUserId()); + if (user.isEmpty() || !user.get().isActive()) { + rejectBearer(request, response); + return; + } + + UserAccount userAccount = user.get(); + Set roles = roleBindingRepo.findByUserId(userAccount.getId()).stream() + .map(rb -> rb.getRole().getCode()) + .collect(Collectors.toSet()); + roles = PlatformRoleDefaults.withDefaultUserRole(roles); + Set scopes = apiTokenScopeService.parseScopes(apiToken.getScopeJson()); + PlatformPrincipal principal = new PlatformPrincipal( + userAccount.getId(), userAccount.getDisplayName(), userAccount.getEmail(), + userAccount.getAvatarUrl(), "api_token", roles + ); + List authorities = new ArrayList<>(); + authorities.addAll(roles.stream() + .map(role -> new SimpleGrantedAuthority("ROLE_" + role)) + .toList()); + authorities.addAll(scopes.stream() + .map(scope -> new SimpleGrantedAuthority("SCOPE_" + scope)) + .toList()); + var auth = new UsernamePasswordAuthenticationToken(principal, null, authorities); + SecurityContextHolder.getContext().setAuthentication(auth); + apiTokenService.touchLastUsed(apiToken); } filterChain.doFilter(request, response); } @@ -91,4 +119,30 @@ public class ApiTokenAuthenticationFilter extends OncePerRequestFilter { || path.startsWith("/api/web/") || path.startsWith("/api/cli/")); } + + private boolean isBearerAuthorization(String authHeader) { + if (!authHeader.regionMatches(true, 0, "Bearer", 0, "Bearer".length())) { + return false; + } + return authHeader.length() == "Bearer".length() + || Character.isWhitespace(authHeader.charAt("Bearer".length())); + } + + private String extractBearerToken(String authHeader) { + if (authHeader.length() <= BEARER_PREFIX.length() - 1 + || authHeader.charAt(BEARER_PREFIX.length() - 1) != ' ') { + return null; + } + String rawToken = authHeader.substring(BEARER_PREFIX.length()).trim(); + return rawToken.isEmpty() ? null : rawToken; + } + + private void rejectBearer(HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException { + SecurityContextHolder.clearContext(); + authenticationEntryPoint.commence( + request, + response, + new BadCredentialsException("Invalid bearer token") + ); + } } diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilterTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilterTest.java index e82f7a1a..d9f030a9 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilterTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilterTest.java @@ -17,11 +17,13 @@ import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.security.core.context.SecurityContextHolder; import java.util.List; import java.util.Optional; +import java.util.stream.Stream; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -89,12 +91,117 @@ class ApiTokenAuthenticationFilterTest { request.setRequestURI("/api/v1/publish"); request.addHeader("Authorization", "Bearer raw-token"); - filter.doFilter(request, new MockHttpServletResponse(), new MockFilterChain()); + MockHttpServletResponse response = new MockHttpServletResponse(); + MockFilterChain chain = new MockFilterChain(); + + filter.doFilter(request, response, chain); assertNull(SecurityContextHolder.getContext().getAuthentication()); + assertEquals(MockHttpServletResponse.SC_UNAUTHORIZED, response.getStatus()); + assertNull(chain.getRequest()); verify(apiTokenService, never()).touchLastUsed(token); } + @Test + void shouldRejectUnknownBearerTokenOnCliReadRoutes() throws Exception { + when(apiTokenService.validateToken("unknown-token")).thenReturn(Optional.empty()); + + for (String route : cliReadRoutes()) { + SecurityContextHolder.clearContext(); + MockHttpServletRequest request = new MockHttpServletRequest("GET", route); + request.addHeader("Authorization", "Bearer unknown-token"); + MockHttpServletResponse response = new MockHttpServletResponse(); + MockFilterChain chain = new MockFilterChain(); + + filter.doFilter(request, response, chain); + + assertEquals(MockHttpServletResponse.SC_UNAUTHORIZED, response.getStatus(), route); + assertNull(SecurityContextHolder.getContext().getAuthentication(), route); + assertNull(chain.getRequest(), route); + } + } + + @Test + void shouldRejectBearerTokenWhenUserIsMissing() throws Exception { + ApiToken token = new ApiToken("missing-user", "cli", "sk_test", "hash", "[]"); + + when(apiTokenService.validateToken("raw-token")).thenReturn(Optional.of(token)); + when(userAccountRepository.findById("missing-user")).thenReturn(Optional.empty()); + + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/cli/v1/skills/search"); + request.addHeader("Authorization", "Bearer raw-token"); + MockHttpServletResponse response = new MockHttpServletResponse(); + MockFilterChain chain = new MockFilterChain(); + + filter.doFilter(request, response, chain); + + assertEquals(MockHttpServletResponse.SC_UNAUTHORIZED, response.getStatus()); + assertNull(SecurityContextHolder.getContext().getAuthentication()); + assertNull(chain.getRequest()); + verify(apiTokenService, never()).touchLastUsed(token); + } + + @Test + void shouldRejectEmptyBearerTokenWithoutValidatingIt() throws Exception { + when(apiTokenService.validateToken("")).thenReturn(Optional.empty()); + + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/cli/v1/skills/search"); + request.addHeader("Authorization", "Bearer "); + MockHttpServletResponse response = new MockHttpServletResponse(); + MockFilterChain chain = new MockFilterChain(); + + filter.doFilter(request, response, chain); + + assertEquals(MockHttpServletResponse.SC_UNAUTHORIZED, response.getStatus()); + assertNull(SecurityContextHolder.getContext().getAuthentication()); + assertNull(chain.getRequest()); + verify(apiTokenService, never()).validateToken(any()); + } + + @Test + void shouldRejectMalformedBearerHeaderWithoutValidatingIt() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/cli/v1/skills/search"); + request.addHeader("Authorization", "Bearer"); + MockHttpServletResponse response = new MockHttpServletResponse(); + MockFilterChain chain = new MockFilterChain(); + + filter.doFilter(request, response, chain); + + assertEquals(MockHttpServletResponse.SC_UNAUTHORIZED, response.getStatus()); + assertNull(SecurityContextHolder.getContext().getAuthentication()); + assertNull(chain.getRequest()); + verify(apiTokenService, never()).validateToken(any()); + } + + @Test + void shouldAllowAnonymousCliReadsWhenAuthorizationHeaderIsAbsent() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/cli/v1/skills/search"); + MockHttpServletResponse response = new MockHttpServletResponse(); + MockFilterChain chain = new MockFilterChain(); + + filter.doFilter(request, response, chain); + + assertEquals(MockHttpServletResponse.SC_OK, response.getStatus()); + assertNull(SecurityContextHolder.getContext().getAuthentication()); + assertNotNull(chain.getRequest()); + verify(apiTokenService, never()).validateToken(any()); + } + + @Test + void shouldIgnoreNonBearerAuthorizationHeader() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/cli/v1/skills/search"); + request.addHeader("Authorization", "Basic abc123"); + MockHttpServletResponse response = new MockHttpServletResponse(); + MockFilterChain chain = new MockFilterChain(); + + filter.doFilter(request, response, chain); + + assertEquals(MockHttpServletResponse.SC_OK, response.getStatus()); + assertNull(SecurityContextHolder.getContext().getAuthentication()); + assertNotNull(chain.getRequest()); + verify(apiTokenService, never()).validateToken(any()); + } + @Test void shouldAuthenticateBearerTokensForApiWebRequests() throws Exception { ApiToken token = new ApiToken("user-3", "cli", "sk_test", "hash", "[\"skill:publish\"]"); @@ -113,4 +220,13 @@ class ApiTokenAuthenticationFilterTest { assertNotNull(SecurityContextHolder.getContext().getAuthentication()); verify(apiTokenService).touchLastUsed(token); } + + private static List cliReadRoutes() { + return Stream.of( + "/api/cli/v1/skills/search", + "/api/cli/v1/skills/global/demo/resolve", + "/api/cli/v1/skills/global/demo/download", + "/api/cli/v1/skills/global/demo/versions/1.0.0/download" + ).toList(); + } } diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenServiceTest.java index 2e4de008..d9ed2c75 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenServiceTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenServiceTest.java @@ -10,9 +10,14 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.dao.DataIntegrityViolationException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.time.Clock; import java.time.Instant; import java.time.ZoneOffset; +import java.util.HexFormat; +import java.util.Optional; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.assertThat; @@ -124,4 +129,39 @@ class ApiTokenServiceTest { .isInstanceOf(DomainBadRequestException.class) .hasMessageContaining("error.token.name.duplicate"); } + + @Test + void validateToken_returnsEmptyForUnknownToken() { + when(tokenRepo.findByTokenHash(sha256("missing-token"))).thenReturn(Optional.empty()); + + assertThat(service.validateToken("missing-token")).isEmpty(); + } + + @Test + void validateToken_returnsEmptyForExpiredToken() { + ApiToken token = new ApiToken("user-1", "CLI", "sk_test", sha256("expired-token"), "[]"); + token.setExpiresAt(Instant.parse("2026-03-17T23:59:59Z")); + when(tokenRepo.findByTokenHash(sha256("expired-token"))).thenReturn(Optional.of(token)); + + assertThat(service.validateToken("expired-token")).isEmpty(); + } + + @Test + void validateToken_returnsEmptyForRevokedToken() { + ApiToken token = new ApiToken("user-1", "CLI", "sk_test", sha256("revoked-token"), "[]"); + token.setRevokedAt(Instant.parse("2026-03-17T23:59:59Z")); + when(tokenRepo.findByTokenHash(sha256("revoked-token"))).thenReturn(Optional.of(token)); + + assertThat(service.validateToken("revoked-token")).isEmpty(); + } + + private static String sha256(String input) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(input.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(hash); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 not available", e); + } + } } From 9520cf63e074d27909ce3f257eadd1d897213a48 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Fri, 12 Jun 2026 14:01:21 +0800 Subject: [PATCH 18/81] fix(cli): add token auth to search Signed-off-by: dongmucat <1127093059@qq.com> --- cli/README.md | 5 +- cli/src/commands/help.ts | 4 +- cli/src/index.ts | 3 +- cli/test/integration/install-command.test.ts | 59 +++++++++++ cli/test/integration/search-command.test.ts | 103 +++++++++++++++++++ 5 files changed, 170 insertions(+), 4 deletions(-) diff --git a/cli/README.md b/cli/README.md index 57a0e495..6d98df80 100644 --- a/cli/README.md +++ b/cli/README.md @@ -112,6 +112,9 @@ Logout only removes the token for the specified registry, preserving registry co # Keyword search skillhub search pdf +# Search with a one-off token +skillhub search pdf --token sk_xxx + # List all skills (empty query) skillhub search "" --limit 50 @@ -333,7 +336,7 @@ Update mechanism: | `skillhub login --token [--registry ] [--json]` | Save token and registry configuration | | `skillhub logout [--registry ] [--json]` | Remove token for specified registry | | `skillhub whoami [--registry ] [--token ] [--json]` | Validate current token and display user information | -| `skillhub search [--registry ] [--limit ] [--json]` | Search published skills | +| `skillhub search [--registry ] [--token ] [--limit ] [--json]` | Search published skills | | `skillhub install [--scope ] [--namespace ] [--version ] [--agent ] [--dir ] [--force] [--registry ] [--token ] [--json]` | Install a skill | | `skillhub list [--agent ] [--dir ] [--registry ] [--json]` | List installed skills | | `skillhub remove [--agent ] [--all] [--remote] [--hard] [--namespace ] [--registry ] [--token ] [--json]` | Remove a skill | diff --git a/cli/src/commands/help.ts b/cli/src/commands/help.ts index 083d72aa..9b35f3b4 100644 --- a/cli/src/commands/help.ts +++ b/cli/src/commands/help.ts @@ -28,8 +28,8 @@ export const commands = { }, search: { summary: 'Search published skills', - usage: 'skillhub search [query] [--limit ] [--registry ] [--json]', - examples: ['skillhub search', 'skillhub search pdf'] + usage: 'skillhub search [query] [--limit ] [--registry ] [--token ] [--json]', + examples: ['skillhub search', 'skillhub search pdf', 'skillhub search pdf --token sk_xxx'] }, install: { summary: 'Install a skill locally', diff --git a/cli/src/index.ts b/cli/src/index.ts index 15a7eb84..512b5b1b 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -223,9 +223,10 @@ cli cli .command('search [query]', 'Search published skills') .option('--registry ', 'Registry URL') + .option('--token ', 'API token') .option('--limit ', 'Max results', { default: 20 }) .option('--json', 'Output JSON') - .action((query: string | undefined, options: { registry?: string; limit?: number; json?: boolean }) => { + .action((query: string | undefined, options: { registry?: string; token?: string; limit?: number; json?: boolean }) => { return runCommand(() => searchCommand(query ?? '', options), Boolean(options.json)) }) diff --git a/cli/test/integration/install-command.test.ts b/cli/test/integration/install-command.test.ts index 134672f6..a4ca3bd5 100644 --- a/cli/test/integration/install-command.test.ts +++ b/cli/test/integration/install-command.test.ts @@ -218,6 +218,65 @@ describe('install command — P1', () => { expect(result.stderr.toLowerCase()).toMatch(/auth|unauthorized|401/) }) + test('bad token stops on 401 without retrying resolve anonymously', async () => { + const env = await createTempHome() + const installDir = join(env.cwd, 'skills-no-anon-retry') + await mkdir(installDir, { recursive: true }) + + const resolveAuthHeaders: Array = [] + let downloadRequests = 0 + const server = Bun.serve({ + port: 0, + fetch(req) { + const url = new URL(req.url) + const resolveMatch = url.pathname.match(/^\/api\/cli\/v1\/skills\/([^/]+)\/([^/]+)\/resolve$/) + if (resolveMatch) { + const auth = req.headers.get('authorization') + resolveAuthHeaders.push(auth) + if (auth === 'Bearer sk_bad') { + return Response.json({ code: 401, message: 'unauthorized' }, { status: 401 }) + } + return Response.json({ + code: 0, + data: { + namespace: resolveMatch[1], + slug: resolveMatch[2], + version: '1.0.0', + versionId: 1, + fingerprint: 'abc123', + downloadUrl: `${url.protocol}//${url.host}/api/cli/v1/skills/${resolveMatch[1]}/${resolveMatch[2]}/download` + } + }) + } + if (url.pathname.endsWith('/download')) { + downloadRequests += 1 + return new Response(makeSkillZip() as BodyInit, { + status: 200, + headers: { 'Content-Type': 'application/zip' } + }) + } + return Response.json({ code: 404 }, { status: 404 }) + } + }) + + try { + const registryUrl = `http://localhost:${server.port}` + const result = await runCli( + ['install', 'pdf-parser', '--dir', installDir, '--registry', registryUrl, '--token', 'sk_bad'], + { HOME: env.home, USERPROFILE: env.home } + ) + + expect(result.exitCode).toBe(2) + expect(result.stderr).toContain('Error: authentication failed') + expect(result.stderr).toContain(`Context: registry ${registryUrl}`) + expect(result.stderr).toContain('Next:') + expect(resolveAuthHeaders).toEqual(['Bearer sk_bad']) + expect(downloadRequests).toBe(0) + } finally { + server.stop() + } + }) + // ------------------------------------------------------------------------- // P1 — --namespace override // ------------------------------------------------------------------------- diff --git a/cli/test/integration/search-command.test.ts b/cli/test/integration/search-command.test.ts index 1902142c..f352b158 100644 --- a/cli/test/integration/search-command.test.ts +++ b/cli/test/integration/search-command.test.ts @@ -10,6 +10,109 @@ afterEach(() => { }) describe('search command', () => { + test('--token sends bearer auth and takes priority over SKILLHUB_TOKEN', async () => { + let capturedAuth = '' + const server = Bun.serve({ + port: 0, + fetch(req) { + const url = new URL(req.url) + if (url.pathname === '/api/cli/v1/skills/search') { + capturedAuth = req.headers.get('authorization') ?? '' + return Response.json({ + code: 0, + data: { + items: [{ namespace: 'global', slug: 'pdf-parser', latestVersion: '1.2.0', summary: 'Parse PDFs' }], + total: 1, + limit: 20 + } + }) + } + return Response.json({ code: 404 }, { status: 404 }) + } + }) + + try { + const result = await runCli( + ['search', 'pdf', '--registry', `http://localhost:${server.port}`, '--token', 'sk_ok'], + { SKILLHUB_TOKEN: 'sk_bad' } + ) + + expect(result.exitCode).toBe(0) + expect(capturedAuth).toBe('Bearer sk_ok') + expect(result.stdout).toContain('global/pdf-parser') + } finally { + server.stop() + } + }) + + test('bad --token fails with auth output and does not retry anonymously', async () => { + const authHeaders: Array = [] + const server = Bun.serve({ + port: 0, + fetch(req) { + const url = new URL(req.url) + if (url.pathname === '/api/cli/v1/skills/search') { + const auth = req.headers.get('authorization') + authHeaders.push(auth) + if (auth === 'Bearer sk_bad') { + return Response.json({ code: 401, message: 'unauthorized' }, { status: 401 }) + } + return Response.json({ + code: 0, + data: { + items: [{ namespace: 'global', slug: 'anonymous-only', latestVersion: '1.0.0', summary: 'anonymous fallback' }], + total: 1, + limit: 20 + } + }) + } + return Response.json({ code: 404 }, { status: 404 }) + } + }) + + try { + const registryUrl = `http://localhost:${server.port}` + const result = await runCli(['search', 'pdf', '--registry', registryUrl, '--token', 'sk_bad']) + + expect(result.exitCode).toBe(2) + expect(result.stderr).toContain('Error: authentication failed') + expect(result.stderr).toContain(`Context: registry ${registryUrl}`) + expect(result.stderr).toContain('Next:') + expect(authHeaders).toEqual(['Bearer sk_bad']) + } finally { + server.stop() + } + }) + + test('bad --token returns structured json auth error', async () => { + const server = Bun.serve({ + port: 0, + fetch(req) { + const url = new URL(req.url) + if (url.pathname === '/api/cli/v1/skills/search') { + return Response.json({ code: 401, message: 'unauthorized' }, { status: 401 }) + } + return Response.json({ code: 404 }, { status: 404 }) + } + }) + + try { + const registryUrl = `http://localhost:${server.port}` + const result = await runCli(['search', 'pdf', '--registry', registryUrl, '--token', 'sk_bad', '--json']) + + expect(result.exitCode).toBe(2) + const parsed = JSON.parse(result.stderr) + expect(parsed.ok).toBe(false) + expect(parsed.message).toBe('authentication failed') + expect(parsed.exitCode).toBe(2) + expect(parsed.details.registry).toBe(registryUrl) + expect(typeof parsed.details.next).toBe('string') + expect(parsed.details.next).toContain('skillhub login') + } finally { + server.stop() + } + }) + test('prints compact search table', async () => { registry = await startFakeRegistry({ searchItems: [{ namespace: 'global', slug: 'pdf-parser', latestVersion: '1.2.0', summary: 'Parse PDFs' }] From 32cc316b30b46899787b8f94c7a5659101e8bfed Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Fri, 12 Jun 2026 14:43:23 +0800 Subject: [PATCH 19/81] fix(cli): align anonymous installability rules Signed-off-by: dongmucat <1127093059@qq.com> --- .../service/cli/CliSkillAppService.java | 5 +- .../service/SkillSearchAppServiceTest.java | 32 ++++++++ .../service/cli/CliSkillAppServiceTest.java | 31 ++++++++ .../domain/skill/SkillInstallability.java | 18 +++++ .../skill/service/SkillDownloadService.java | 6 +- .../SkillLifecycleProjectionService.java | 7 +- .../skill/service/SkillQueryService.java | 15 ++-- .../service/SkillDownloadServiceTest.java | 73 +++++++++++++++++++ .../skill/service/SkillQueryServiceTest.java | 44 +++++++++++ 9 files changed, 218 insertions(+), 13 deletions(-) create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillInstallability.java diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/cli/CliSkillAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/cli/CliSkillAppService.java index e431eaf3..a4030ebf 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/cli/CliSkillAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/cli/CliSkillAppService.java @@ -56,15 +56,16 @@ public class CliSkillAppService { ); List items = response.items().stream() + .filter(item -> item.publishedVersion() != null) .map(item -> new CliSearchItem( item.namespace(), item.slug(), - item.publishedVersion() != null ? item.publishedVersion().version() : null, + item.publishedVersion().version(), item.summary() )) .toList(); - return new CliSearchResult(items, response.total(), limit); + return new CliSearchResult(items, items.size(), limit); } public CliResolveResponse resolve(String namespace, String slug, String version, String userId, Map userNsRoles) { diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSearchAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSearchAppServiceTest.java index fdac46f8..984be1d0 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSearchAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSearchAppServiceTest.java @@ -8,7 +8,9 @@ import com.iflytek.skillhub.domain.namespace.NamespaceStatus; import com.iflytek.skillhub.domain.namespace.NamespaceService; import com.iflytek.skillhub.domain.skill.Skill; import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.SkillVersion; import com.iflytek.skillhub.domain.skill.SkillVersionRepository; +import com.iflytek.skillhub.domain.skill.SkillVersionStatus; import com.iflytek.skillhub.domain.skill.SkillVisibility; import com.iflytek.skillhub.domain.skill.service.SkillLifecycleProjectionService; import com.iflytek.skillhub.search.SearchQuery; @@ -184,6 +186,36 @@ class SkillSearchAppServiceTest { .findBySkillIdInAndStatus(List.of(10L, 11L), com.iflytek.skillhub.domain.skill.SkillVersionStatus.PUBLISHED); } + @Test + void search_shouldNotExposeDownloadUnavailableVersionAsPublishedSummary() { + Skill skill = new Skill(1L, "not-ready", "owner-1", SkillVisibility.PUBLIC); + setField(skill, "id", 10L); + skill.setLatestVersionId(101L); + + SkillVersion version = new SkillVersion(10L, "1.0.0", "owner-1"); + setField(version, "id", 101L); + version.setStatus(SkillVersionStatus.PUBLISHED); + version.setDownloadReady(false); + + Namespace namespace = new Namespace("global", "Global", "owner-1"); + setField(namespace, "id", 1L); + namespace.setStatus(NamespaceStatus.ACTIVE); + + when(searchQueryService.search(any())) + .thenReturn(new SearchResult(List.of(10L), 1, 0, 20)); + when(skillRepository.findByIdIn(List.of(10L))).thenReturn(List.of(skill)); + when(namespaceRepository.findByIdIn(List.of(1L))).thenReturn(List.of(namespace)); + when(skillVersionRepository.findByIdIn(List.of(101L))).thenReturn(List.of(version)); + when(skillVersionRepository.findBySkillIdInAndStatus(List.of(10L), SkillVersionStatus.PUBLISHED)) + .thenReturn(List.of(version)); + + SkillSearchAppService.SearchResponse response = service.search(null, null, "newest", 0, 20, null, null); + + assertEquals(1, response.items().size()); + assertEquals("not-ready", response.items().getFirst().slug()); + assertEquals(null, response.items().getFirst().publishedVersion()); + } + @Test void search_shouldNormalizeAndPassLabelSlugs() { when(searchQueryService.search(any())) diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/cli/CliSkillAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/cli/CliSkillAppServiceTest.java index b7fbe1d7..b2c230fe 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/cli/CliSkillAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/cli/CliSkillAppServiceTest.java @@ -76,6 +76,37 @@ class CliSkillAppServiceTest { assertEquals(20, result.limit()); } + @Test + void search_filtersResultsWithoutInstallablePublishedVersion() { + var searchResponse = new SkillSearchAppService.SearchResponse( + List.of( + new SkillSummaryResponse( + 1L, "draft-only", "Draft Only", "No installable version", + "PUBLIC", "ACTIVE", 0L, 0, BigDecimal.ZERO, 0, + "global", Instant.now(), false, + null, null, null, "NONE" + ), + new SkillSummaryResponse( + 2L, "ready", "Ready", "Installable", + "PUBLIC", "ACTIVE", 0L, 0, BigDecimal.ZERO, 0, + "global", Instant.now(), false, + new SkillLifecycleVersionResponse(2L, "1.0.0", "PUBLISHED"), + new SkillLifecycleVersionResponse(2L, "1.0.0", "PUBLISHED"), + null, "PUBLISHED" + ) + ), + 2L, 0, 20 + ); + given(skillSearchAppService.search("demo", null, "newest", 0, 20, null, null)) + .willReturn(searchResponse); + + var result = service.search("demo", 20, null, null); + + assertEquals(1, result.items().size()); + assertEquals("ready", result.items().getFirst().slug()); + assertEquals(1L, result.total()); + } + @Test void resolve_delegatesToQueryService() { given(skillQueryService.resolveVersion("global", "demo", "2.0.0", null, null, "user-1", Map.of())) diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillInstallability.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillInstallability.java new file mode 100644 index 00000000..dfeeed1c --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillInstallability.java @@ -0,0 +1,18 @@ +package com.iflytek.skillhub.domain.skill; + +/** + * Defines whether a skill version can be installed through public download + * paths. Storage object presence is checked later by the download service so + * fallback bundle behavior stays separate from domain publication state. + */ +public final class SkillInstallability { + private SkillInstallability() { + } + + public static boolean isInstallableVersion(SkillVersion version) { + return version != null + && version.getStatus() == SkillVersionStatus.PUBLISHED + && version.isDownloadReady() + && version.getYankedAt() == null; + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java index 56b69ddd..db14fe2e 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java @@ -306,7 +306,7 @@ public class SkillDownloadService { /** * Asserts that the version can be downloaded. - * - PUBLISHED: anyone with skill access can download + * - PUBLISHED: must be installable before public download * - UPLOADED/PENDING_REVIEW: only skill owner or namespace admin can download */ private void assertDownloadableVersion(Skill skill, @@ -315,7 +315,9 @@ public class SkillDownloadService { Map userNsRoles) { switch (version.getStatus()) { case PUBLISHED -> { - // Anyone with skill access can download published versions + if (!SkillInstallability.isInstallableVersion(version)) { + throw new DomainBadRequestException("error.skill.version.notDownloadable", version.getVersion()); + } } case UPLOADED, PENDING_REVIEW -> { if (!canManageSkillDraft(skill, currentUserId, userNsRoles)) { diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillLifecycleProjectionService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillLifecycleProjectionService.java index 368ae850..1ad3fd35 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillLifecycleProjectionService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillLifecycleProjectionService.java @@ -2,6 +2,7 @@ package com.iflytek.skillhub.domain.skill.service; import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillInstallability; import com.iflytek.skillhub.domain.skill.SkillVersion; import com.iflytek.skillhub.domain.skill.SkillVersionRepository; import com.iflytek.skillhub.domain.skill.SkillVersionStatus; @@ -91,7 +92,7 @@ public class SkillLifecycleProjectionService { List unresolvedSkillIds = new java.util.ArrayList<>(); for (Skill skill : skills) { SkillVersion latestVersion = latestVersionsById.get(skill.getLatestVersionId()); - if (latestVersion != null && latestVersion.getStatus() == SkillVersionStatus.PUBLISHED) { + if (SkillInstallability.isInstallableVersion(latestVersion)) { publishedBySkillId.put(skill.getId(), latestVersion); } else { unresolvedSkillIds.add(skill.getId()); @@ -100,7 +101,9 @@ public class SkillLifecycleProjectionService { if (!unresolvedSkillIds.isEmpty()) { for (SkillVersion version : skillVersionRepository.findBySkillIdInAndStatus(unresolvedSkillIds, SkillVersionStatus.PUBLISHED)) { - publishedBySkillId.merge(version.getSkillId(), version, this::newerVersion); + if (SkillInstallability.isInstallableVersion(version)) { + publishedBySkillId.merge(version.getSkillId(), version, this::newerVersion); + } } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java index 7d966327..66c5e319 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java @@ -487,13 +487,7 @@ public class SkillQueryService { } public boolean isDownloadAvailable(SkillVersion version) { - if (version == null) { - return false; - } - if (version.getStatus() != SkillVersionStatus.PUBLISHED) { - return false; - } - return version.isDownloadReady(); + return SkillInstallability.isInstallableVersion(version); } public ReviewSkillSnapshotDTO getReviewSkillSnapshot(Long skillVersionId) { @@ -565,6 +559,7 @@ public class SkillQueryService { Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId); assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles); SkillVersion resolved = resolveVersionEntity(skill, version, tag, hash); + assertInstallableVersion(resolved, resolved.getVersion()); String fingerprint = computeFingerprint(resolved); Boolean matched = hash == null || hash.isBlank() ? null : Objects.equals(hash, fingerprint); @@ -916,6 +911,12 @@ public class SkillQueryService { } } + private void assertInstallableVersion(SkillVersion version, String versionStr) { + if (!SkillInstallability.isInstallableVersion(version)) { + throw new DomainBadRequestException("error.skill.version.notDownloadable", versionStr); + } + } + /** * Checks whether the caller may preview a specific version's files and metadata. * Published versions are visible to everyone; all other statuses are restricted diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java index ce84509d..2b7ea78b 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java @@ -92,6 +92,7 @@ class SkillDownloadServiceTest { SkillVersion version = new SkillVersion(1L, "1.0.0", userId); setId(version, 10L); version.setStatus(SkillVersionStatus.PUBLISHED); + version.setDownloadReady(true); String storageKey = "packages/1/10/bundle.zip"; InputStream content = new ByteArrayInputStream("test".getBytes()); ObjectMetadata metadata = new ObjectMetadata(1000L, "application/zip", Instant.now()); @@ -137,6 +138,7 @@ class SkillDownloadServiceTest { SkillVersion version = new SkillVersion(1L, "1.0.0", userId); setId(version, 10L); version.setStatus(SkillVersionStatus.PUBLISHED); + version.setDownloadReady(true); String storageKey = "packages/1/10/bundle.zip"; InputStream content = new ByteArrayInputStream("test".getBytes()); ObjectMetadata metadata = new ObjectMetadata(1000L, "application/zip", Instant.now()); @@ -180,6 +182,7 @@ class SkillDownloadServiceTest { SkillVersion version = new SkillVersion(1L, versionStr, userId); setId(version, 10L); version.setStatus(SkillVersionStatus.PUBLISHED); + version.setDownloadReady(true); String storageKey = "packages/1/10/bundle.zip"; InputStream content = new ByteArrayInputStream("test".getBytes()); ObjectMetadata metadata = new ObjectMetadata(1000L, "application/zip", Instant.now()); @@ -232,6 +235,73 @@ class SkillDownloadServiceTest { verify(eventPublisher, never()).publishEvent(any(SkillDownloadedEvent.class)); } + @Test + void testDownloadVersion_ShouldRejectDownloadUnavailablePublishedVersion() throws Exception { + String namespaceSlug = "test-ns"; + String skillSlug = "test-skill"; + String versionStr = "1.0.0"; + String userId = "user-100"; + Map userNsRoles = Map.of(1L, NamespaceRole.MEMBER); + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1"); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, userId, SkillVisibility.PUBLIC); + setId(skill, 1L); + skill.setStatus(SkillStatus.ACTIVE); + SkillVersion version = new SkillVersion(1L, versionStr, userId); + setId(version, 10L); + version.setStatus(SkillVersionStatus.PUBLISHED); + version.setDownloadReady(false); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); + when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true); + when(skillVersionRepository.findBySkillIdAndVersion(1L, versionStr)).thenReturn(Optional.of(version)); + + DomainBadRequestException ex = assertThrows(DomainBadRequestException.class, () -> + service.downloadVersion(namespaceSlug, skillSlug, versionStr, userId, userNsRoles)); + + assertEquals("error.skill.version.notDownloadable", ex.messageCode()); + assertArrayEquals(new Object[]{versionStr}, ex.messageArgs()); + verify(skillRepository, never()).incrementDownloadCount(anyLong()); + verify(skillVersionStatsRepository, never()).incrementDownloadCount(anyLong(), anyLong()); + verify(eventPublisher, never()).publishEvent(any(SkillDownloadedEvent.class)); + } + + @Test + void testDownloadVersion_ShouldRejectYankedPublishedVersion() throws Exception { + String namespaceSlug = "test-ns"; + String skillSlug = "test-skill"; + String versionStr = "1.0.0"; + String userId = "user-100"; + Map userNsRoles = Map.of(1L, NamespaceRole.MEMBER); + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1"); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, userId, SkillVisibility.PUBLIC); + setId(skill, 1L); + skill.setStatus(SkillStatus.ACTIVE); + SkillVersion version = new SkillVersion(1L, versionStr, userId); + setId(version, 10L); + version.setStatus(SkillVersionStatus.PUBLISHED); + version.setDownloadReady(true); + version.setYankedAt(Instant.parse("2026-06-12T00:00:00Z")); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); + when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true); + when(skillVersionRepository.findBySkillIdAndVersion(1L, versionStr)).thenReturn(Optional.of(version)); + + DomainBadRequestException ex = assertThrows(DomainBadRequestException.class, () -> + service.downloadVersion(namespaceSlug, skillSlug, versionStr, userId, userNsRoles)); + + assertEquals("error.skill.version.notDownloadable", ex.messageCode()); + assertArrayEquals(new Object[]{versionStr}, ex.messageArgs()); + verify(skillRepository, never()).incrementDownloadCount(anyLong()); + verify(skillVersionStatsRepository, never()).incrementDownloadCount(anyLong(), anyLong()); + verify(eventPublisher, never()).publishEvent(any(SkillDownloadedEvent.class)); + } + @Test void testDownloadVersion_ShouldFallbackToBundledFilesWhenBundleIsMissing() throws Exception { String namespaceSlug = "test-ns"; @@ -249,6 +319,7 @@ class SkillDownloadServiceTest { SkillVersion version = new SkillVersion(1L, versionStr, userId); setId(version, 10L); version.setStatus(SkillVersionStatus.PUBLISHED); + version.setDownloadReady(true); SkillFile file = new SkillFile(10L, "SKILL.md", 4L, "text/markdown", "hash", "skills/1/10/SKILL.md"); when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); @@ -297,6 +368,7 @@ class SkillDownloadServiceTest { SkillVersion version = new SkillVersion(1L, "1.0.0", "owner-1"); setId(version, 10L); version.setStatus(SkillVersionStatus.PUBLISHED); + version.setDownloadReady(true); when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(namespace)); when(skillRepository.findByNamespaceIdAndSlug(1L, "demo-skill")).thenReturn(List.of(skill)); @@ -332,6 +404,7 @@ class SkillDownloadServiceTest { SkillVersion version = new SkillVersion(1L, "1.0.0", "owner-1"); setId(version, 10L); version.setStatus(SkillVersionStatus.PUBLISHED); + version.setDownloadReady(true); when(namespaceRepository.findBySlug("team-ai")).thenReturn(Optional.of(namespace)); when(skillRepository.findByNamespaceIdAndSlug(2L, "demo-skill")).thenReturn(List.of(skill)); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java index 7ee683ac..032bc683 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java @@ -27,6 +27,7 @@ import java.io.ByteArrayInputStream; import java.io.InputStream; import java.io.UncheckedIOException; import java.lang.reflect.Field; +import java.time.Instant; import java.util.List; import java.util.Map; import java.util.Optional; @@ -439,6 +440,17 @@ class SkillQueryServiceTest { assertTrue(service.isDownloadAvailable(version)); } + @Test + void testIsDownloadAvailable_ShouldReturnFalseWhenVersionIsYanked() throws Exception { + SkillVersion version = new SkillVersion(1L, "1.0.0", "user-100"); + setId(version, 10L); + version.setStatus(SkillVersionStatus.PUBLISHED); + version.setDownloadReady(true); + version.setYankedAt(Instant.parse("2026-06-12T00:00:00Z")); + + assertFalse(service.isDownloadAvailable(version)); + } + @Test void testIsDownloadAvailable_ShouldNotHitObjectStorageForListSignals() throws Exception { SkillVersion version = new SkillVersion(1L, "1.0.0", "user-100"); @@ -565,9 +577,11 @@ class SkillQueryServiceTest { SkillVersion version100 = new SkillVersion(1L, "1.0.0", "user-100"); setId(version100, 9L); version100.setStatus(SkillVersionStatus.PUBLISHED); + version100.setDownloadReady(true); SkillVersion version110 = new SkillVersion(1L, "1.1.0", "user-100"); setId(version110, 10L); version110.setStatus(SkillVersionStatus.PUBLISHED); + version110.setDownloadReady(true); SkillFile version100File = new SkillFile(9L, "SKILL.md", 10L, "text/markdown", "hash100", "key100"); SkillFile version110File = new SkillFile(10L, "SKILL.md", 10L, "text/markdown", "hash110", "key110"); @@ -611,6 +625,7 @@ class SkillQueryServiceTest { SkillVersion version = new SkillVersion(3L, "1.0.0 beta", "user-100"); setId(version, 11L); version.setStatus(SkillVersionStatus.PUBLISHED); + version.setDownloadReady(true); SkillFile file = new SkillFile(11L, "SKILL.md", 10L, "text/markdown", "hash", "key"); when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); @@ -632,6 +647,35 @@ class SkillQueryServiceTest { assertEquals("/api/v1/skills/global/smoke-skill-two/versions/1.0.0%20beta/download", result.downloadUrl()); } + @Test + void testResolveVersion_ShouldRejectDownloadUnavailableLatestVersion() throws Exception { + String namespaceSlug = "global"; + String skillSlug = "not-ready"; + + Namespace namespace = new Namespace(namespaceSlug, "Global", "owner-1"); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, "owner-1", SkillVisibility.PUBLIC); + setId(skill, 3L); + skill.setStatus(SkillStatus.ACTIVE); + skill.setLatestVersionId(11L); + + SkillVersion version = new SkillVersion(3L, "1.0.0", "owner-1"); + setId(version, 11L); + version.setStatus(SkillVersionStatus.PUBLISHED); + version.setDownloadReady(false); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); + when(skillVersionRepository.findBySkillIdAndStatus(3L, SkillVersionStatus.PUBLISHED)).thenReturn(List.of(version)); + when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(version)); + + DomainBadRequestException ex = assertThrows(DomainBadRequestException.class, () -> + service.resolveVersion(namespaceSlug, skillSlug, null, null, null, null, Map.of())); + + assertEquals("error.skill.version.notDownloadable", ex.messageCode()); + assertArrayEquals(new Object[]{"1.0.0"}, ex.messageArgs()); + } + @Test void testGetSkillDetail_ShouldFlagLifecyclePermissionForOwner() throws Exception { String namespaceSlug = "test-ns"; From f5259daa947a73c4ee8105b9d46366ab52f7db5f Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Fri, 12 Jun 2026 15:12:13 +0800 Subject: [PATCH 20/81] fix(cli): require installable latest in search Signed-off-by: dongmucat <1127093059@qq.com> --- .../service/SkillSearchAppServiceTest.java | 97 +++++++++-- .../skill/service/SkillDownloadService.java | 9 + .../SkillLifecycleProjectionService.java | 15 -- .../service/SkillDownloadServiceTest.java | 131 +++++++++++++++ .../skill/service/SkillQueryServiceTest.java | 154 ++++++++++++++++++ .../PostgresFullTextQueryServiceTest.java | 37 +++++ 6 files changed, 416 insertions(+), 27 deletions(-) diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSearchAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSearchAppServiceTest.java index 984be1d0..3cd408e4 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSearchAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSearchAppServiceTest.java @@ -24,11 +24,13 @@ import org.mockito.Mock; import org.mockito.ArgumentCaptor; import org.mockito.junit.jupiter.MockitoExtension; +import java.time.Instant; import java.util.List; import java.util.Map; import java.util.Set; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyList; @@ -98,8 +100,6 @@ class SkillSearchAppServiceTest { when(skillRepository.findByIdIn(List.of(11L))).thenReturn(List.of(visibleSkill)); when(namespaceRepository.findByIdIn(List.of(2L))).thenReturn(List.of(activeNamespace)); when(skillVersionRepository.findByIdIn(List.of(111L))).thenReturn(List.of()); - when(skillVersionRepository.findBySkillIdInAndStatus(List.of(11L), com.iflytek.skillhub.domain.skill.SkillVersionStatus.PUBLISHED)) - .thenReturn(List.of()); SkillSearchAppService.SearchResponse response = service.search("skill", null, "newest", 0, 1, null, null); @@ -147,8 +147,6 @@ class SkillSearchAppServiceTest { when(skillRepository.findByIdIn(List.of(10L))).thenReturn(List.of(visibleSkill)); when(namespaceRepository.findByIdIn(List.of(1L))).thenReturn(List.of(namespace)); when(skillVersionRepository.findByIdIn(List.of(101L))).thenReturn(List.of()); - when(skillVersionRepository.findBySkillIdInAndStatus(List.of(10L), com.iflytek.skillhub.domain.skill.SkillVersionStatus.PUBLISHED)) - .thenReturn(List.of()); SkillSearchAppService.SearchResponse response = service.search("skill", null, "newest", 0, 20, "user-9", Map.of()); @@ -166,6 +164,9 @@ class SkillSearchAppServiceTest { setField(second, "id", 11L); second.setLatestVersionId(102L); + SkillVersion firstVersion = publishedVersion(10L, 101L, "1.0.0"); + SkillVersion secondVersion = publishedVersion(11L, 102L, "2.0.0"); + Namespace namespace = new Namespace("team-a", "Team A", "owner-1"); setField(namespace, "id", 1L); namespace.setStatus(NamespaceStatus.ACTIVE); @@ -174,20 +175,80 @@ class SkillSearchAppServiceTest { .thenReturn(new SearchResult(List.of(10L, 11L), 2, 0, 20)); when(skillRepository.findByIdIn(List.of(10L, 11L))).thenReturn(List.of(first, second)); when(namespaceRepository.findByIdIn(List.of(1L))).thenReturn(List.of(namespace)); - when(skillVersionRepository.findByIdIn(List.of(101L, 102L))).thenReturn(List.of()); - when(skillVersionRepository.findBySkillIdInAndStatus(List.of(10L, 11L), com.iflytek.skillhub.domain.skill.SkillVersionStatus.PUBLISHED)) - .thenReturn(List.of()); + when(skillVersionRepository.findByIdIn(List.of(101L, 102L))).thenReturn(List.of(firstVersion, secondVersion)); SkillSearchAppService.SearchResponse response = service.search(null, null, "newest", 0, 20, null, null); assertEquals(2, response.items().size()); + assertEquals("1.0.0", response.items().get(0).publishedVersion().version()); + assertEquals("2.0.0", response.items().get(1).publishedVersion().version()); verify(skillVersionRepository, times(1)).findByIdIn(List.of(101L, 102L)); - verify(skillVersionRepository, times(1)) + verify(skillVersionRepository, times(0)) .findBySkillIdInAndStatus(List.of(10L, 11L), com.iflytek.skillhub.domain.skill.SkillVersionStatus.PUBLISHED); } @Test - void search_shouldNotExposeDownloadUnavailableVersionAsPublishedSummary() { + void search_shouldNotFallbackToOlderPublishedVersionWhenLatestIsMissing() { + Skill skill = new Skill(1L, "missing-latest", "owner-1", SkillVisibility.PUBLIC); + setField(skill, "id", 10L); + + SkillVersion oldInstallable = publishedVersion(10L, 100L, "0.9.0"); + + Namespace namespace = new Namespace("global", "Global", "owner-1"); + setField(namespace, "id", 1L); + namespace.setStatus(NamespaceStatus.ACTIVE); + + when(searchQueryService.search(any())) + .thenReturn(new SearchResult(List.of(10L), 1, 0, 20)); + when(skillRepository.findByIdIn(List.of(10L))).thenReturn(List.of(skill)); + when(namespaceRepository.findByIdIn(List.of(1L))).thenReturn(List.of(namespace)); + org.mockito.Mockito.lenient() + .when(skillVersionRepository.findBySkillIdInAndStatus(List.of(10L), SkillVersionStatus.PUBLISHED)) + .thenReturn(List.of(oldInstallable)); + + SkillSearchAppService.SearchResponse response = service.search(null, null, "newest", 0, 20, null, null); + + assertEquals(1, response.items().size()); + assertEquals("missing-latest", response.items().getFirst().slug()); + assertNull(response.items().getFirst().publishedVersion()); + verify(skillVersionRepository, times(0)) + .findBySkillIdInAndStatus(List.of(10L), SkillVersionStatus.PUBLISHED); + } + + @Test + void search_shouldNotFallbackToOlderPublishedVersionWhenLatestIsYanked() { + Skill skill = new Skill(1L, "yanked-latest", "owner-1", SkillVisibility.PUBLIC); + setField(skill, "id", 10L); + skill.setLatestVersionId(101L); + + SkillVersion latest = publishedVersion(10L, 101L, "1.0.0"); + latest.setYankedAt(Instant.parse("2026-06-12T00:00:00Z")); + SkillVersion oldInstallable = publishedVersion(10L, 100L, "0.9.0"); + + Namespace namespace = new Namespace("global", "Global", "owner-1"); + setField(namespace, "id", 1L); + namespace.setStatus(NamespaceStatus.ACTIVE); + + when(searchQueryService.search(any())) + .thenReturn(new SearchResult(List.of(10L), 1, 0, 20)); + when(skillRepository.findByIdIn(List.of(10L))).thenReturn(List.of(skill)); + when(namespaceRepository.findByIdIn(List.of(1L))).thenReturn(List.of(namespace)); + when(skillVersionRepository.findByIdIn(List.of(101L))).thenReturn(List.of(latest)); + org.mockito.Mockito.lenient() + .when(skillVersionRepository.findBySkillIdInAndStatus(List.of(10L), SkillVersionStatus.PUBLISHED)) + .thenReturn(List.of(oldInstallable)); + + SkillSearchAppService.SearchResponse response = service.search(null, null, "newest", 0, 20, null, null); + + assertEquals(1, response.items().size()); + assertEquals("yanked-latest", response.items().getFirst().slug()); + assertNull(response.items().getFirst().publishedVersion()); + verify(skillVersionRepository, times(0)) + .findBySkillIdInAndStatus(List.of(10L), SkillVersionStatus.PUBLISHED); + } + + @Test + void search_shouldNotFallbackToOlderPublishedVersionWhenLatestDownloadUnavailable() { Skill skill = new Skill(1L, "not-ready", "owner-1", SkillVisibility.PUBLIC); setField(skill, "id", 10L); skill.setLatestVersionId(101L); @@ -196,6 +257,7 @@ class SkillSearchAppServiceTest { setField(version, "id", 101L); version.setStatus(SkillVersionStatus.PUBLISHED); version.setDownloadReady(false); + SkillVersion oldInstallable = publishedVersion(10L, 100L, "0.9.0"); Namespace namespace = new Namespace("global", "Global", "owner-1"); setField(namespace, "id", 1L); @@ -206,14 +268,17 @@ class SkillSearchAppServiceTest { when(skillRepository.findByIdIn(List.of(10L))).thenReturn(List.of(skill)); when(namespaceRepository.findByIdIn(List.of(1L))).thenReturn(List.of(namespace)); when(skillVersionRepository.findByIdIn(List.of(101L))).thenReturn(List.of(version)); - when(skillVersionRepository.findBySkillIdInAndStatus(List.of(10L), SkillVersionStatus.PUBLISHED)) - .thenReturn(List.of(version)); + org.mockito.Mockito.lenient() + .when(skillVersionRepository.findBySkillIdInAndStatus(List.of(10L), SkillVersionStatus.PUBLISHED)) + .thenReturn(List.of(oldInstallable)); SkillSearchAppService.SearchResponse response = service.search(null, null, "newest", 0, 20, null, null); assertEquals(1, response.items().size()); assertEquals("not-ready", response.items().getFirst().slug()); - assertEquals(null, response.items().getFirst().publishedVersion()); + assertNull(response.items().getFirst().publishedVersion()); + verify(skillVersionRepository, times(0)) + .findBySkillIdInAndStatus(List.of(10L), SkillVersionStatus.PUBLISHED); } @Test @@ -272,4 +337,12 @@ class SkillSearchAppServiceTest { throw new RuntimeException(e); } } + + private SkillVersion publishedVersion(Long skillId, Long versionId, String versionNumber) { + SkillVersion version = new SkillVersion(skillId, versionNumber, "owner-1"); + setField(version, "id", versionId); + version.setStatus(SkillVersionStatus.PUBLISHED); + version.setDownloadReady(true); + return version; + } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java index db14fe2e..294563ad 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java @@ -4,6 +4,7 @@ import com.iflytek.skillhub.domain.event.SkillDownloadedEvent; import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.namespace.NamespaceStatus; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.domain.skill.*; @@ -284,12 +285,20 @@ public class SkillDownloadService { if (!visibilityChecker.canAccess(skill, currentUserId, userNsRoles)) { throw new DomainForbiddenException("error.skill.access.denied", skill.getSlug()); } + if (namespace.getStatus() == NamespaceStatus.ARCHIVED + && !isNamespaceMember(namespace.getId(), currentUserId, userNsRoles)) { + throw new DomainForbiddenException("error.namespace.archived", namespace.getSlug()); + } } private boolean isAnonymousDownloadAllowed(Skill skill) { return skill.getVisibility() == SkillVisibility.PUBLIC; } + private boolean isNamespaceMember(Long namespaceId, String currentUserId, Map userNsRoles) { + return currentUserId != null && userNsRoles != null && userNsRoles.containsKey(namespaceId); + } + private Skill resolveVisibleSkill(Long namespaceId, String slug, String currentUserId) { return skillSlugResolutionService.resolve( namespaceId, diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillLifecycleProjectionService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillLifecycleProjectionService.java index 1ad3fd35..31ff9a6b 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillLifecycleProjectionService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillLifecycleProjectionService.java @@ -89,21 +89,10 @@ public class SkillLifecycleProjectionService { .collect(Collectors.toMap(SkillVersion::getId, Function.identity())); Map publishedBySkillId = new java.util.HashMap<>(); - List unresolvedSkillIds = new java.util.ArrayList<>(); for (Skill skill : skills) { SkillVersion latestVersion = latestVersionsById.get(skill.getLatestVersionId()); if (SkillInstallability.isInstallableVersion(latestVersion)) { publishedBySkillId.put(skill.getId(), latestVersion); - } else { - unresolvedSkillIds.add(skill.getId()); - } - } - - if (!unresolvedSkillIds.isEmpty()) { - for (SkillVersion version : skillVersionRepository.findBySkillIdInAndStatus(unresolvedSkillIds, SkillVersionStatus.PUBLISHED)) { - if (SkillInstallability.isInstallableVersion(version)) { - publishedBySkillId.merge(version.getSkillId(), version, this::newerVersion); - } } } @@ -160,10 +149,6 @@ public class SkillLifecycleProjectionService { .thenComparing(SkillVersion::getId, Comparator.nullsLast(Comparator.naturalOrder())); } - private SkillVersion newerVersion(SkillVersion left, SkillVersion right) { - return versionComparator().compare(left, right) >= 0 ? left : right; - } - private VersionProjection toProjection(SkillVersion version) { if (version == null) { return null; diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java index 2b7ea78b..89a2e4cb 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java @@ -119,6 +119,137 @@ class SkillDownloadServiceTest { verify(eventPublisher).publishEvent(any(SkillDownloadedEvent.class)); } + @Test + void testDownloadLatest_ShouldRejectSkillWithoutLatest() throws Exception { + String namespaceSlug = "global"; + String skillSlug = "missing-latest"; + + Namespace namespace = new Namespace(namespaceSlug, "Global", "owner-1"); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, "owner-1", SkillVisibility.PUBLIC); + setId(skill, 1L); + skill.setStatus(SkillStatus.ACTIVE); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); + + DomainBadRequestException ex = assertThrows(DomainBadRequestException.class, () -> + service.downloadLatest(namespaceSlug, skillSlug, null, Map.of())); + + assertEquals("error.skill.notFound", ex.messageCode()); + assertArrayEquals(new Object[]{skillSlug}, ex.messageArgs()); + verify(skillRepository, never()).incrementDownloadCount(anyLong()); + verify(skillVersionStatsRepository, never()).incrementDownloadCount(anyLong(), anyLong()); + verify(eventPublisher, never()).publishEvent(any(SkillDownloadedEvent.class)); + } + + @Test + void testDownloadLatest_ShouldRejectYankedLatestVersion() throws Exception { + String namespaceSlug = "global"; + String skillSlug = "yanked-latest"; + + Namespace namespace = new Namespace(namespaceSlug, "Global", "owner-1"); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, "owner-1", SkillVisibility.PUBLIC); + setId(skill, 1L); + skill.setStatus(SkillStatus.ACTIVE); + skill.setLatestVersionId(10L); + + SkillVersion version = new SkillVersion(1L, "1.0.0", "owner-1"); + setId(version, 10L); + version.setStatus(SkillVersionStatus.PUBLISHED); + version.setDownloadReady(true); + version.setYankedAt(Instant.parse("2026-06-12T00:00:00Z")); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); + when(visibilityChecker.canAccess(skill, null, Map.of())).thenReturn(true); + when(skillVersionRepository.findById(10L)).thenReturn(Optional.of(version)); + + DomainBadRequestException ex = assertThrows(DomainBadRequestException.class, () -> + service.downloadLatest(namespaceSlug, skillSlug, null, Map.of())); + + assertEquals("error.skill.version.notDownloadable", ex.messageCode()); + assertArrayEquals(new Object[]{"1.0.0"}, ex.messageArgs()); + verify(skillRepository, never()).incrementDownloadCount(anyLong()); + verify(skillVersionStatsRepository, never()).incrementDownloadCount(anyLong(), anyLong()); + verify(eventPublisher, never()).publishEvent(any(SkillDownloadedEvent.class)); + } + + @Test + void testDownloadLatest_ShouldRejectAnonymousArchivedNamespaceSkill() throws Exception { + String namespaceSlug = "archived"; + String skillSlug = "archived-skill"; + + Namespace namespace = new Namespace(namespaceSlug, "Archived", "owner-1"); + setId(namespace, 1L); + namespace.setStatus(com.iflytek.skillhub.domain.namespace.NamespaceStatus.ARCHIVED); + Skill skill = new Skill(1L, skillSlug, "owner-1", SkillVisibility.PUBLIC); + setId(skill, 1L); + skill.setStatus(SkillStatus.ACTIVE); + skill.setLatestVersionId(10L); + + SkillVersion version = new SkillVersion(1L, "1.0.0", "owner-1"); + setId(version, 10L); + version.setStatus(SkillVersionStatus.PUBLISHED); + version.setDownloadReady(true); + ObjectMetadata metadata = new ObjectMetadata(1000L, "application/zip", Instant.now()); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); + when(visibilityChecker.canAccess(skill, null, Map.of())).thenReturn(true); + org.mockito.Mockito.lenient().when(skillVersionRepository.findById(10L)).thenReturn(Optional.of(version)); + org.mockito.Mockito.lenient().when(objectStorageService.exists("packages/1/10/bundle.zip")).thenReturn(true); + org.mockito.Mockito.lenient().when(objectStorageService.getMetadata("packages/1/10/bundle.zip")).thenReturn(metadata); + org.mockito.Mockito.lenient().when(objectStorageService.getObject("packages/1/10/bundle.zip")) + .thenReturn(new ByteArrayInputStream("test".getBytes())); + org.mockito.Mockito.lenient() + .when(objectStorageService.generatePresignedUrl(eq("packages/1/10/bundle.zip"), any(), eq("archived-skill-1.0.0.zip"))) + .thenReturn(null); + + assertThrows(com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException.class, () -> + service.downloadLatest(namespaceSlug, skillSlug, null, Map.of())); + verify(skillRepository, never()).incrementDownloadCount(anyLong()); + verify(skillVersionStatsRepository, never()).incrementDownloadCount(anyLong(), anyLong()); + verify(eventPublisher, never()).publishEvent(any(SkillDownloadedEvent.class)); + } + + @Test + void testDownloadLatest_ShouldRejectAnonymousHiddenPrivateAndUnpublishedSkills() throws Exception { + Namespace namespace = new Namespace("global", "Global", "owner-1"); + setId(namespace, 1L); + + Skill hiddenSkill = new Skill(1L, "hidden", "owner-1", SkillVisibility.PUBLIC); + setId(hiddenSkill, 11L); + hiddenSkill.setStatus(SkillStatus.ACTIVE); + hiddenSkill.setLatestVersionId(101L); + hiddenSkill.setHidden(true); + + Skill privateSkill = new Skill(1L, "private", "owner-1", SkillVisibility.PRIVATE); + setId(privateSkill, 12L); + privateSkill.setStatus(SkillStatus.ACTIVE); + privateSkill.setLatestVersionId(102L); + + Skill unpublishedSkill = new Skill(1L, "unpublished", "owner-1", SkillVisibility.PUBLIC); + setId(unpublishedSkill, 13L); + unpublishedSkill.setStatus(SkillStatus.ACTIVE); + + when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, "hidden")).thenReturn(List.of(hiddenSkill)); + when(skillRepository.findByNamespaceIdAndSlug(1L, "private")).thenReturn(List.of(privateSkill)); + when(skillRepository.findByNamespaceIdAndSlug(1L, "unpublished")).thenReturn(List.of(unpublishedSkill)); + + assertThrows(DomainBadRequestException.class, () -> + service.downloadLatest("global", "hidden", null, Map.of())); + assertThrows(com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException.class, () -> + service.downloadLatest("global", "private", null, Map.of())); + assertThrows(DomainBadRequestException.class, () -> + service.downloadLatest("global", "unpublished", null, Map.of())); + verify(skillRepository, never()).incrementDownloadCount(anyLong()); + verify(skillVersionStatsRepository, never()).incrementDownloadCount(anyLong(), anyLong()); + verify(eventPublisher, never()).publishEvent(any(SkillDownloadedEvent.class)); + } + @Test void testDownloadByTag_Success() throws Exception { // Arrange diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java index 032bc683..99cb448b 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java @@ -676,6 +676,160 @@ class SkillQueryServiceTest { assertArrayEquals(new Object[]{"1.0.0"}, ex.messageArgs()); } + @Test + void testResolveVersion_ShouldRejectSkillWithoutLatest() throws Exception { + String namespaceSlug = "global"; + String skillSlug = "missing-latest"; + + Namespace namespace = new Namespace(namespaceSlug, "Global", "owner-1"); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, "owner-1", SkillVisibility.PUBLIC); + setId(skill, 3L); + skill.setStatus(SkillStatus.ACTIVE); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); + + DomainBadRequestException ex = assertThrows(DomainBadRequestException.class, () -> + service.resolveVersion(namespaceSlug, skillSlug, null, null, null, null, Map.of())); + + assertEquals("error.skill.notFound", ex.messageCode()); + assertArrayEquals(new Object[]{skillSlug}, ex.messageArgs()); + } + + @Test + void testResolveVersion_ShouldRejectYankedLatestVersion() throws Exception { + String namespaceSlug = "global"; + String skillSlug = "yanked-latest"; + + Namespace namespace = new Namespace(namespaceSlug, "Global", "owner-1"); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, "owner-1", SkillVisibility.PUBLIC); + setId(skill, 3L); + skill.setStatus(SkillStatus.ACTIVE); + skill.setLatestVersionId(11L); + + SkillVersion version = new SkillVersion(3L, "1.0.0", "owner-1"); + setId(version, 11L); + version.setStatus(SkillVersionStatus.PUBLISHED); + version.setDownloadReady(true); + version.setYankedAt(Instant.parse("2026-06-12T00:00:00Z")); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); + when(skillVersionRepository.findBySkillIdAndStatus(3L, SkillVersionStatus.PUBLISHED)).thenReturn(List.of(version)); + when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(version)); + + DomainBadRequestException ex = assertThrows(DomainBadRequestException.class, () -> + service.resolveVersion(namespaceSlug, skillSlug, null, null, null, null, Map.of())); + + assertEquals("error.skill.version.notDownloadable", ex.messageCode()); + assertArrayEquals(new Object[]{"1.0.0"}, ex.messageArgs()); + } + + @Test + void testResolveVersion_ShouldRejectDownloadUnavailableExplicitVersion() throws Exception { + String namespaceSlug = "global"; + String skillSlug = "explicit-not-ready"; + + Namespace namespace = new Namespace(namespaceSlug, "Global", "owner-1"); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, "owner-1", SkillVisibility.PUBLIC); + setId(skill, 3L); + skill.setStatus(SkillStatus.ACTIVE); + skill.setLatestVersionId(11L); + + SkillVersion version = new SkillVersion(3L, "1.0.0", "owner-1"); + setId(version, 11L); + version.setStatus(SkillVersionStatus.PUBLISHED); + version.setDownloadReady(false); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); + when(skillVersionRepository.findBySkillIdAndVersion(3L, "1.0.0")).thenReturn(Optional.of(version)); + + DomainBadRequestException ex = assertThrows(DomainBadRequestException.class, () -> + service.resolveVersion(namespaceSlug, skillSlug, "1.0.0", null, null, null, Map.of())); + + assertEquals("error.skill.version.notDownloadable", ex.messageCode()); + assertArrayEquals(new Object[]{"1.0.0"}, ex.messageArgs()); + } + + @Test + void testResolveVersion_ShouldRejectDownloadUnavailableTaggedVersion() throws Exception { + String namespaceSlug = "global"; + String skillSlug = "tag-not-ready"; + + Namespace namespace = new Namespace(namespaceSlug, "Global", "owner-1"); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, "owner-1", SkillVisibility.PUBLIC); + setId(skill, 3L); + skill.setStatus(SkillStatus.ACTIVE); + skill.setLatestVersionId(11L); + + SkillVersion version = new SkillVersion(3L, "1.0.0", "owner-1"); + setId(version, 11L); + version.setStatus(SkillVersionStatus.PUBLISHED); + version.setDownloadReady(false); + SkillTag tag = new SkillTag(3L, "stable", 11L, "owner-1"); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); + when(skillTagRepository.findBySkillIdAndTagName(3L, "stable")).thenReturn(Optional.of(tag)); + when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(version)); + + DomainBadRequestException ex = assertThrows(DomainBadRequestException.class, () -> + service.resolveVersion(namespaceSlug, skillSlug, null, "stable", null, null, Map.of())); + + assertEquals("error.skill.version.notDownloadable", ex.messageCode()); + assertArrayEquals(new Object[]{"1.0.0"}, ex.messageArgs()); + } + + @Test + void testResolveVersion_ShouldRejectAnonymousHiddenPrivateArchivedAndUnpublishedSkills() throws Exception { + Namespace activeNamespace = new Namespace("global", "Global", "owner-1"); + setId(activeNamespace, 1L); + Namespace archivedNamespace = new Namespace("archived", "Archived", "owner-1"); + setId(archivedNamespace, 2L); + archivedNamespace.setStatus(NamespaceStatus.ARCHIVED); + + Skill hiddenSkill = new Skill(1L, "hidden", "owner-1", SkillVisibility.PUBLIC); + setId(hiddenSkill, 10L); + hiddenSkill.setStatus(SkillStatus.ACTIVE); + hiddenSkill.setLatestVersionId(101L); + hiddenSkill.setHidden(true); + + Skill privateSkill = new Skill(1L, "private", "owner-1", SkillVisibility.PRIVATE); + setId(privateSkill, 11L); + privateSkill.setStatus(SkillStatus.ACTIVE); + privateSkill.setLatestVersionId(102L); + + Skill archivedSkill = new Skill(2L, "archived", "owner-1", SkillVisibility.PUBLIC); + setId(archivedSkill, 12L); + archivedSkill.setStatus(SkillStatus.ACTIVE); + archivedSkill.setLatestVersionId(103L); + + Skill unpublishedSkill = new Skill(1L, "unpublished", "owner-1", SkillVisibility.PUBLIC); + setId(unpublishedSkill, 13L); + unpublishedSkill.setStatus(SkillStatus.ACTIVE); + + when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(activeNamespace)); + when(namespaceRepository.findBySlug("archived")).thenReturn(Optional.of(archivedNamespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, "hidden")).thenReturn(List.of(hiddenSkill)); + when(skillRepository.findByNamespaceIdAndSlug(1L, "private")).thenReturn(List.of(privateSkill)); + when(skillRepository.findByNamespaceIdAndSlug(2L, "archived")).thenReturn(List.of(archivedSkill)); + when(skillRepository.findByNamespaceIdAndSlug(1L, "unpublished")).thenReturn(List.of(unpublishedSkill)); + + assertThrows(DomainBadRequestException.class, () -> + service.resolveVersion("global", "hidden", null, null, null, null, Map.of())); + assertThrows(DomainForbiddenException.class, () -> + service.resolveVersion("global", "private", null, null, null, null, Map.of())); + assertThrows(DomainForbiddenException.class, () -> + service.resolveVersion("archived", "archived", null, null, null, null, Map.of())); + assertThrows(DomainBadRequestException.class, () -> + service.resolveVersion("global", "unpublished", null, null, null, null, Map.of())); + } + @Test void testGetSkillDetail_ShouldFlagLifecyclePermissionForOwner() throws Exception { String namespaceSlug = "test-ns"; diff --git a/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryServiceTest.java b/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryServiceTest.java index f89d05e9..15804b92 100644 --- a/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryServiceTest.java +++ b/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryServiceTest.java @@ -363,6 +363,43 @@ class PostgresFullTextQueryServiceTest { .contains("ORDER BY s.updated_at DESC, d.skill_id DESC"); } + @Test + void anonymousSearchSqlShouldOnlyReadPublicActiveVisibleNonArchivedSkills() { + EntityManager entityManager = mock(EntityManager.class); + Query nativeQuery = mock(Query.class); + Query countQuery = mock(Query.class); + when(entityManager.createNativeQuery(anyString())) + .thenReturn(nativeQuery) + .thenReturn(countQuery); + when(nativeQuery.setParameter(anyString(), org.mockito.ArgumentMatchers.any())).thenReturn(nativeQuery); + when(countQuery.setParameter(anyString(), org.mockito.ArgumentMatchers.any())).thenReturn(countQuery); + when(nativeQuery.getResultList()).thenReturn(List.of()); + when(countQuery.getSingleResult()).thenReturn(0L); + + PostgresFullTextQueryService service = new PostgresFullTextQueryService(entityManager); + + service.search(new SearchQuery( + null, + null, + new SearchVisibilityScope(null, Set.of(), Set.of()), + "newest", + 0, + 12 + )); + + ArgumentCaptor sqlCaptor = ArgumentCaptor.forClass(String.class); + verify(entityManager, org.mockito.Mockito.times(2)).createNativeQuery(sqlCaptor.capture()); + assertThat(sqlCaptor.getAllValues().getFirst()) + .contains("AND (d.visibility = 'PUBLIC' )") + .contains("AND d.status = 'ACTIVE'") + .contains("AND s.status = 'ACTIVE'") + .contains("AND s.hidden = FALSE") + .contains("AND (n.status <> 'ARCHIVED' )") + .doesNotContain("memberNamespaceIds"); + verify(nativeQuery, never()).setParameter(org.mockito.ArgumentMatchers.eq("memberNamespaceIds"), org.mockito.ArgumentMatchers.any()); + verify(countQuery, never()).setParameter(org.mockito.ArgumentMatchers.eq("memberNamespaceIds"), org.mockito.ArgumentMatchers.any()); + } + @Test void authenticatedQueriesShouldAllowArchivedNamespacesForMembers() { EntityManager entityManager = mock(EntityManager.class); From 5a708a5bd6bcf0c7ceeb7a5b9f16ad045b4a0cf0 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Mon, 15 Jun 2026 17:25:35 +0800 Subject: [PATCH 21/81] fix(domain): reject anonymous restricted resolves cleanly Signed-off-by: dongmucat <1127093059@qq.com> --- .../domain/skill/VisibilityChecker.java | 7 +++--- .../skill/service/SkillQueryServiceTest.java | 25 +++++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/VisibilityChecker.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/VisibilityChecker.java index 65c8e753..30b52a66 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/VisibilityChecker.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/VisibilityChecker.java @@ -16,19 +16,20 @@ public class VisibilityChecker { } public boolean canAccess(Skill skill, String currentUserId, Map userNamespaceRoles, Set platformRoles) { + Map roles = userNamespaceRoles != null ? userNamespaceRoles : Map.of(); if (isSuperAdmin(platformRoles)) { return true; } if (skill.isHidden()) { - return isOwner(skill, currentUserId) || isAdminOrAbove(userNamespaceRoles.get(skill.getNamespaceId())); + return isOwner(skill, currentUserId) || isAdminOrAbove(roles.get(skill.getNamespaceId())); } if (skill.getLatestVersionId() == null) { return isOwner(skill, currentUserId); } return switch (skill.getVisibility()) { case PUBLIC -> true; - case NAMESPACE_ONLY -> userNamespaceRoles.containsKey(skill.getNamespaceId()); - case PRIVATE -> isOwner(skill, currentUserId) || isAdminOrAbove(userNamespaceRoles.get(skill.getNamespaceId())); + case NAMESPACE_ONLY -> roles.containsKey(skill.getNamespaceId()); + case PRIVATE -> isOwner(skill, currentUserId) || isAdminOrAbove(roles.get(skill.getNamespaceId())); }; } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java index 99cb448b..02cf02f1 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java @@ -830,6 +830,31 @@ class SkillQueryServiceTest { service.resolveVersion("global", "unpublished", null, null, null, null, Map.of())); } + @Test + void testResolveVersion_ShouldRejectAnonymousPrivateAndNamespaceOnlyWhenRolesAreMissing() throws Exception { + Namespace namespace = new Namespace("global", "Global", "owner-1"); + setId(namespace, 1L); + + Skill privateSkill = new Skill(1L, "private", "owner-1", SkillVisibility.PRIVATE); + setId(privateSkill, 11L); + privateSkill.setStatus(SkillStatus.ACTIVE); + privateSkill.setLatestVersionId(101L); + + Skill namespaceOnlySkill = new Skill(1L, "team-only", "owner-1", SkillVisibility.NAMESPACE_ONLY); + setId(namespaceOnlySkill, 12L); + namespaceOnlySkill.setStatus(SkillStatus.ACTIVE); + namespaceOnlySkill.setLatestVersionId(102L); + + when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, "private")).thenReturn(List.of(privateSkill)); + when(skillRepository.findByNamespaceIdAndSlug(1L, "team-only")).thenReturn(List.of(namespaceOnlySkill)); + + assertThrows(DomainForbiddenException.class, () -> + service.resolveVersion("global", "private", null, null, null, null, null)); + assertThrows(DomainForbiddenException.class, () -> + service.resolveVersion("global", "team-only", null, null, null, null, null)); + } + @Test void testGetSkillDetail_ShouldFlagLifecyclePermissionForOwner() throws Exception { String namespaceSlug = "test-ns"; From cb4bf947112e259bb1f124c1dc86bfc889762939 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Wed, 17 Jun 2026 11:15:03 +0800 Subject: [PATCH 22/81] fix(cli): filter installable search before pagination Signed-off-by: dongmucat <1127093059@qq.com> --- docs/03-authentication-design.md | 8 +- .../service/SkillSearchAppService.java | 21 ++- .../service/cli/CliSkillAppService.java | 5 +- .../service/cli/CliSkillAppServiceTest.java | 144 ++++++++++++++++-- .../iflytek/skillhub/search/SearchQuery.java | 16 +- .../PostgresFullTextQueryService.java | 8 + .../PostgresFullTextQueryServiceTest.java | 45 ++++++ 7 files changed, 225 insertions(+), 22 deletions(-) diff --git a/docs/03-authentication-design.md b/docs/03-authentication-design.md index 1c62b1c6..7a7e46d6 100644 --- a/docs/03-authentication-design.md +++ b/docs/03-authentication-design.md @@ -605,8 +605,8 @@ window.location.href = '/oauth2/authorization/github' | `GET /api/v1/skills`(搜索) | 仅 `PUBLIC`,且仅搜索 `ACTIVE`、非 hidden、已索引 skill | `PUBLIC + NAMESPACE_ONLY(成员空间)+ PRIVATE(owner/admin)` | `SearchVisibilityScope` + 搜索索引状态 | | `GET /api/v1/skills/{ns}/{slug}` | 仅已发布且可见的 `PUBLIC` skill | 同左,另加 owner 可读未发布 skill、namespace `ADMIN` / `OWNER` 可读 hidden | `visibility + latest_version_id + hidden + namespace 成员关系` | | `GET /api/v1/skills/{ns}/{slug}/versions` | 仅 `PUBLISHED` 版本 | owner / namespace `ADMIN` / `OWNER` 可见全部五种状态 | 同上 + version status 过滤 | -| `GET /api/v1/skills/{ns}/{slug}/download` | 仅全局 namespace 下的 `PUBLIC` skill 支持匿名下载 | 已登录后按 visibility 判定;下载目标版本必须是 `PUBLISHED` | visibility + namespace type + version status | -| `GET /api/v1/skills/{ns}/{slug}/resolve` | 仅全局 namespace 下的 `PUBLIC` skill 可匿名 | 同上 | visibility + namespace type + version status | +| `GET /api/v1/skills/{ns}/{slug}/download` | 仅 `PUBLIC`、`ACTIVE`、非 hidden、命名空间未归档且目标版本可安装的 skill 支持匿名下载 | 已登录后按 visibility 判定;下载目标版本必须可安装 | visibility + namespace status + `SkillInstallability` | +| `GET /api/v1/skills/{ns}/{slug}/resolve` | 仅 `PUBLIC`、`ACTIVE`、非 hidden、命名空间未归档且目标版本可安装的 skill 可匿名 | 同上 | visibility + namespace status + `SkillInstallability` | | `GET /api/v1/namespaces` | 全部 | 全部 | 无限制 | ### 10.2 Authenticated API @@ -655,6 +655,6 @@ window.location.href = '/oauth2/authorization/github' |------|---------|---------| | `GET /api/v1/whoami` | 任意有效 Bearer Token | 无 | | `GET /api/v1/search` | 可选(匿名限 PUBLIC) | `SearchVisibilityScope` | -| `GET /api/v1/resolve` | 可选(匿名仅限全局 namespace 下的 PUBLIC) | visibility + namespace type + version status | -| `GET /api/v1/download/{slug}/{version}` | 可选(匿名仅限全局 namespace 下的 PUBLIC) | visibility + namespace type + version status | +| `GET /api/v1/resolve` | 可选(匿名仅限 `PUBLIC`、`ACTIVE`、非 hidden、命名空间未归档且目标版本可安装) | visibility + namespace status + `SkillInstallability` | +| `GET /api/v1/download/{slug}/{version}` | 可选(匿名仅限 `PUBLIC`、`ACTIVE`、非 hidden、命名空间未归档且目标版本可安装) | visibility + namespace status + `SkillInstallability` | | `POST /api/v1/publish` | Bearer Token + `skill:publish` | 普通用户要求目标 namespace 成员;`SUPER_ADMIN` 可绕过(namespace 由 canonical slug 解析) | diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java index bffa778a..63410678 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java @@ -85,7 +85,20 @@ public class SkillSearchAppService { SearchVisibilityScope scope = buildVisibilityScope(userId, userNsRoles); - return searchVisibleSkills(keyword, namespaceId, sortBy != null ? sortBy : "newest", page, size, labelSlugs, scope); + return searchVisibleSkills(keyword, namespaceId, sortBy != null ? sortBy : "newest", page, size, labelSlugs, scope, false); + } + + public SearchResponse searchInstallableLatest( + String keyword, + String namespaceSlug, + String sortBy, + int page, + int size, + String userId, + Map userNsRoles) { + Long namespaceId = resolveNamespaceId(namespaceSlug, userId, userNsRoles); + SearchVisibilityScope scope = buildVisibilityScope(userId, userNsRoles); + return searchVisibleSkills(keyword, namespaceId, sortBy != null ? sortBy : "newest", page, size, List.of(), scope, true); } private Long resolveNamespaceId(String namespaceSlug, String userId, Map userNsRoles) { @@ -133,7 +146,8 @@ public class SkillSearchAppService { int page, int size, List labelSlugs, - SearchVisibilityScope scope) { + SearchVisibilityScope scope, + boolean requireInstallableLatest) { SearchResult result = searchQueryService.search(new SearchQuery( keyword, namespaceId, @@ -141,7 +155,8 @@ public class SkillSearchAppService { sortBy, page, size, - normalizeLabelSlugs(labelSlugs) + normalizeLabelSlugs(labelSlugs), + requireInstallableLatest )); List pageItems = mapVisibleSkillSummaries(result.skillIds()); return new SearchResponse(pageItems, result.total(), page, size); diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/cli/CliSkillAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/cli/CliSkillAppService.java index a4030ebf..1fcd2e25 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/cli/CliSkillAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/cli/CliSkillAppService.java @@ -51,12 +51,11 @@ public class CliSkillAppService { public record CliSearchResult(List items, long total, int limit) {} public CliSearchResult search(String q, int limit, String userId, Map userNsRoles) { - SkillSearchAppService.SearchResponse response = skillSearchAppService.search( + SkillSearchAppService.SearchResponse response = skillSearchAppService.searchInstallableLatest( q, null, "newest", 0, limit, userId, userNsRoles ); List items = response.items().stream() - .filter(item -> item.publishedVersion() != null) .map(item -> new CliSearchItem( item.namespace(), item.slug(), @@ -65,7 +64,7 @@ public class CliSkillAppService { )) .toList(); - return new CliSearchResult(items, items.size(), limit); + return new CliSearchResult(items, response.total(), limit); } public CliResolveResponse resolve(String namespace, String slug, String version, String userId, Map userNsRoles) { diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/cli/CliSkillAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/cli/CliSkillAppServiceTest.java index b2c230fe..0c75ca34 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/cli/CliSkillAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/cli/CliSkillAppServiceTest.java @@ -1,9 +1,18 @@ package com.iflytek.skillhub.service.cli; import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceService; +import com.iflytek.skillhub.auth.rbac.RbacService; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillRepository; import com.iflytek.skillhub.domain.skill.SkillVersion; +import com.iflytek.skillhub.domain.skill.SkillVersionRepository; +import com.iflytek.skillhub.domain.skill.SkillVersionStatus; import com.iflytek.skillhub.domain.skill.SkillVisibility; import com.iflytek.skillhub.domain.skill.service.SkillDownloadService; +import com.iflytek.skillhub.domain.skill.service.SkillLifecycleProjectionService; import com.iflytek.skillhub.domain.skill.service.SkillPublishService; import com.iflytek.skillhub.domain.skill.service.SkillQueryService; import com.iflytek.skillhub.domain.skill.validation.PackageEntry; @@ -15,6 +24,9 @@ import com.iflytek.skillhub.dto.cli.CliResolveResponse; import com.iflytek.skillhub.service.AuditRequestContext; import com.iflytek.skillhub.service.SkillDeleteAppService; import com.iflytek.skillhub.service.SkillSearchAppService; +import com.iflytek.skillhub.search.SearchQuery; +import com.iflytek.skillhub.search.SearchQueryService; +import com.iflytek.skillhub.search.SearchResult; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -39,6 +51,11 @@ class CliSkillAppServiceTest { @Mock SkillDownloadService skillDownloadService; @Mock SkillDeleteAppService skillDeleteAppService; @Mock SkillPublishService skillPublishService; + @Mock SkillRepository skillRepository; + @Mock NamespaceRepository namespaceRepository; + @Mock SkillVersionRepository skillVersionRepository; + @Mock NamespaceService namespaceService; + @Mock RbacService rbacService; private CliSkillAppService service; @@ -62,7 +79,7 @@ class CliSkillAppServiceTest { )), 1L, 0, 20 ); - given(skillSearchAppService.search("pdf", null, "newest", 0, 20, null, null)) + given(skillSearchAppService.searchInstallableLatest("pdf", null, "newest", 0, 20, null, null)) .willReturn(searchResponse); var result = service.search("pdf", 20, null, null); @@ -77,15 +94,9 @@ class CliSkillAppServiceTest { } @Test - void search_filtersResultsWithoutInstallablePublishedVersion() { + void search_mapsInstallableSearchTotalFromQueryStage() { var searchResponse = new SkillSearchAppService.SearchResponse( List.of( - new SkillSummaryResponse( - 1L, "draft-only", "Draft Only", "No installable version", - "PUBLIC", "ACTIVE", 0L, 0, BigDecimal.ZERO, 0, - "global", Instant.now(), false, - null, null, null, "NONE" - ), new SkillSummaryResponse( 2L, "ready", "Ready", "Installable", "PUBLIC", "ACTIVE", 0L, 0, BigDecimal.ZERO, 0, @@ -95,9 +106,9 @@ class CliSkillAppServiceTest { null, "PUBLISHED" ) ), - 2L, 0, 20 + 1L, 0, 20 ); - given(skillSearchAppService.search("demo", null, "newest", 0, 20, null, null)) + given(skillSearchAppService.searchInstallableLatest("demo", null, "newest", 0, 20, null, null)) .willReturn(searchResponse); var result = service.search("demo", 20, null, null); @@ -107,6 +118,93 @@ class CliSkillAppServiceTest { assertEquals(1L, result.total()); } + @Test + void search_limitOneSkipsUninstallableMatchAndReturnsNextInstallableWithFilteredTotal() { + Skill unavailableFirstMatch = new Skill(1L, "draft-first", "owner-1", SkillVisibility.PUBLIC); + setField(unavailableFirstMatch, "id", 1L); + assertLimitOneSkipsUninstallableFirstMatch(unavailableFirstMatch, List.of()); + } + + @Test + void search_limitOneSkipsYankedLatestMatchAndReturnsNextInstallableWithFilteredTotal() { + Skill unavailableFirstMatch = new Skill(1L, "yanked-first", "owner-1", SkillVisibility.PUBLIC); + setField(unavailableFirstMatch, "id", 1L); + unavailableFirstMatch.setLatestVersionId(10L); + SkillVersion yanked = publishedVersion(1L, 10L, "1.0.0"); + yanked.setYankedAt(Instant.parse("2026-06-12T00:00:00Z")); + + assertLimitOneSkipsUninstallableFirstMatch(unavailableFirstMatch, List.of(yanked)); + } + + @Test + void search_limitOneSkipsDownloadUnavailableLatestAndReturnsNextInstallableWithFilteredTotal() { + Skill unavailableFirstMatch = new Skill(1L, "not-ready-first", "owner-1", SkillVisibility.PUBLIC); + setField(unavailableFirstMatch, "id", 1L); + unavailableFirstMatch.setLatestVersionId(10L); + SkillVersion notReady = publishedVersion(1L, 10L, "1.0.0"); + notReady.setDownloadReady(false); + + assertLimitOneSkipsUninstallableFirstMatch(unavailableFirstMatch, List.of(notReady)); + } + + private void assertLimitOneSkipsUninstallableFirstMatch( + Skill unavailableFirstMatch, + List unavailableLatestVersions) { + SearchQueryService rankedSearch = query -> requiresInstallableLatest(query) + ? new SearchResult(List.of(2L), 1L, 0, 1) + : new SearchResult(List.of(1L), 2L, 0, 1); + SkillSearchAppService realSearchAppService = new SkillSearchAppService( + rankedSearch, + skillRepository, + namespaceRepository, + namespaceService, + new SkillLifecycleProjectionService(skillVersionRepository), + rbacService + ); + CliSkillAppService realService = new CliSkillAppService( + realSearchAppService, + skillQueryService, + skillDownloadService, + skillDeleteAppService, + skillPublishService + ); + + Skill installableSecondMatch = new Skill(1L, "ready-second", "owner-1", SkillVisibility.PUBLIC); + setField(installableSecondMatch, "id", 2L); + installableSecondMatch.setLatestVersionId(20L); + + Namespace namespace = new Namespace("global", "Global", "owner-1"); + setField(namespace, "id", 1L); + SkillVersion installableVersion = publishedVersion(2L, 20L, "1.0.0"); + + org.mockito.Mockito.lenient() + .when(skillRepository.findByIdIn(List.of(1L))) + .thenReturn(List.of(unavailableFirstMatch)); + org.mockito.Mockito.lenient() + .when(skillRepository.findByIdIn(List.of(2L))) + .thenReturn(List.of(installableSecondMatch)); + org.mockito.Mockito.lenient() + .when(namespaceRepository.findByIdIn(List.of(1L))) + .thenReturn(List.of(namespace)); + org.mockito.Mockito.lenient() + .when(skillVersionRepository.findByIdIn(List.of())) + .thenReturn(List.of()); + org.mockito.Mockito.lenient() + .when(skillVersionRepository.findByIdIn(List.of(10L))) + .thenReturn(unavailableLatestVersions); + org.mockito.Mockito.lenient() + .when(skillVersionRepository.findByIdIn(List.of(20L))) + .thenReturn(List.of(installableVersion)); + + var result = realService.search("demo", 1, null, null); + + assertEquals(1, result.items().size()); + assertEquals("ready-second", result.items().getFirst().slug()); + assertEquals("1.0.0", result.items().getFirst().latestVersion()); + assertEquals(1L, result.total()); + assertEquals(1, result.limit()); + } + @Test void resolve_delegatesToQueryService() { given(skillQueryService.resolveVersion("global", "demo", "2.0.0", null, null, "user-1", Map.of())) @@ -156,4 +254,30 @@ class CliSkillAppServiceTest { assertEquals("1.0.0", response.version()); assertEquals("PUBLIC", response.visibility()); } + + private boolean requiresInstallableLatest(SearchQuery query) { + try { + return (boolean) query.getClass().getMethod("requireInstallableLatest").invoke(query); + } catch (ReflectiveOperationException e) { + return false; + } + } + + private SkillVersion publishedVersion(Long skillId, Long versionId, String versionNumber) { + SkillVersion version = new SkillVersion(skillId, versionNumber, "owner-1"); + setField(version, "id", versionId); + version.setStatus(SkillVersionStatus.PUBLISHED); + version.setDownloadReady(true); + return version; + } + + private void setField(Object target, String fieldName, Object value) { + try { + java.lang.reflect.Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } catch (Exception e) { + throw new RuntimeException(e); + } + } } diff --git a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/SearchQuery.java b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/SearchQuery.java index 14c2cc4d..5a54d0a6 100644 --- a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/SearchQuery.java +++ b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/SearchQuery.java @@ -12,8 +12,20 @@ public record SearchQuery( String sortBy, int page, int size, - List labelSlugs + List labelSlugs, + boolean requireInstallableLatest ) { + public SearchQuery( + String keyword, + Long namespaceId, + SearchVisibilityScope visibilityScope, + String sortBy, + int page, + int size, + List labelSlugs) { + this(keyword, namespaceId, visibilityScope, sortBy, page, size, labelSlugs, false); + } + public SearchQuery( String keyword, Long namespaceId, @@ -21,6 +33,6 @@ public record SearchQuery( String sortBy, int page, int size) { - this(keyword, namespaceId, visibilityScope, sortBy, page, size, List.of()); + this(keyword, namespaceId, visibilityScope, sortBy, page, size, List.of(), false); } } diff --git a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryService.java b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryService.java index 2e1ffcb1..64015844 100644 --- a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryService.java +++ b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryService.java @@ -107,6 +107,9 @@ public class PostgresFullTextQueryService implements SearchQueryService { sql.append("FROM skill_search_document d "); sql.append("JOIN skill s ON s.id = d.skill_id "); sql.append("JOIN namespace n ON n.id = d.namespace_id "); + if (query.requireInstallableLatest()) { + sql.append("JOIN skill_version latest ON latest.id = s.latest_version_id "); + } sql.append("WHERE 1=1 "); // Visibility filtering @@ -120,6 +123,11 @@ public class PostgresFullTextQueryService implements SearchQueryService { sql.append("AND d.status = 'ACTIVE' "); sql.append("AND s.status = 'ACTIVE' "); sql.append("AND s.hidden = FALSE "); + if (query.requireInstallableLatest()) { + sql.append("AND latest.status = 'PUBLISHED' "); + sql.append("AND latest.download_ready = TRUE "); + sql.append("AND latest.yanked_at IS NULL "); + } sql.append("AND (n.status <> 'ARCHIVED' "); if (query.visibilityScope().userId() != null) { sql.append("OR d.namespace_id IN :memberNamespaceIds "); diff --git a/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryServiceTest.java b/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryServiceTest.java index 15804b92..c84c6cbd 100644 --- a/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryServiceTest.java +++ b/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryServiceTest.java @@ -400,6 +400,51 @@ class PostgresFullTextQueryServiceTest { verify(countQuery, never()).setParameter(org.mockito.ArgumentMatchers.eq("memberNamespaceIds"), org.mockito.ArgumentMatchers.any()); } + @Test + void installableLatestFilterShouldApplyToSearchAndCountQueries() { + EntityManager entityManager = mock(EntityManager.class); + Query nativeQuery = mock(Query.class); + Query countQuery = mock(Query.class); + when(entityManager.createNativeQuery(anyString())) + .thenReturn(nativeQuery) + .thenReturn(countQuery); + when(nativeQuery.setParameter(anyString(), org.mockito.ArgumentMatchers.any())).thenReturn(nativeQuery); + when(countQuery.setParameter(anyString(), org.mockito.ArgumentMatchers.any())).thenReturn(countQuery); + when(nativeQuery.getResultList()).thenReturn(List.of(2L)); + when(countQuery.getSingleResult()).thenReturn(1L); + + PostgresFullTextQueryService service = new PostgresFullTextQueryService(entityManager); + + var result = service.search(new SearchQuery( + "demo", + null, + SearchVisibilityScope.anonymous(), + "newest", + 0, + 1, + List.of(), + true + )); + + ArgumentCaptor sqlCaptor = ArgumentCaptor.forClass(String.class); + verify(entityManager, org.mockito.Mockito.times(2)).createNativeQuery(sqlCaptor.capture()); + assertThat(sqlCaptor.getAllValues().getFirst()) + .contains("JOIN skill_version latest ON latest.id = s.latest_version_id") + .contains("AND latest.status = 'PUBLISHED'") + .contains("AND latest.download_ready = TRUE") + .contains("AND latest.yanked_at IS NULL") + .contains("LIMIT :limit OFFSET :offset"); + assertThat(sqlCaptor.getAllValues().get(1)) + .contains("JOIN skill_version latest ON latest.id = s.latest_version_id") + .contains("AND latest.status = 'PUBLISHED'") + .contains("AND latest.download_ready = TRUE") + .contains("AND latest.yanked_at IS NULL") + .doesNotContain("LIMIT :limit") + .doesNotContain("ORDER BY"); + assertThat(result.skillIds()).containsExactly(2L); + assertThat(result.total()).isEqualTo(1L); + } + @Test void authenticatedQueriesShouldAllowArchivedNamespacesForMembers() { EntityManager entityManager = mock(EntityManager.class); From cf22f568f76efda6099f2f32ceb388a0e8b5e8ac Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Wed, 17 Jun 2026 11:27:09 +0800 Subject: [PATCH 23/81] test(cli): align auth tests with bearer hardening Refs: 25f57a32-5f1d-4d56-b6b7-9b6b7b868799 Signed-off-by: dongmucat <1127093059@qq.com> --- .../controller/cli/CliDryRunValidateTest.java | 43 +++++++++++-------- .../cli/CliSkillControllerTest.java | 18 +++----- 2 files changed, 30 insertions(+), 31 deletions(-) diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliDryRunValidateTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliDryRunValidateTest.java index d82aa32d..64f8c7c9 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliDryRunValidateTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliDryRunValidateTest.java @@ -1,7 +1,11 @@ package com.iflytek.skillhub.controller.cli; -import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.auth.entity.ApiToken; +import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; +import com.iflytek.skillhub.auth.token.ApiTokenService; import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; import com.iflytek.skillhub.dto.cli.CliDryRunResponse; import com.iflytek.skillhub.service.cli.CliSkillAppService; import org.junit.jupiter.api.Test; @@ -10,18 +14,16 @@ import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMock import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.mock.web.MockMultipartFile; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; import java.util.List; +import java.util.Optional; import java.util.Set; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; -import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -32,18 +34,22 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. class CliDryRunValidateTest { @Autowired MockMvc mockMvc; @MockBean CliSkillAppService cliSkillAppService; + @MockBean ApiTokenService apiTokenService; + @MockBean UserAccountRepository userAccountRepository; + @MockBean UserRoleBindingRepository userRoleBindingRepository; - private UsernamePasswordAuthenticationToken auth() { - PlatformPrincipal principal = new PlatformPrincipal( - "user-1", "tester", "t@example.com", "", "api_token", Set.of("USER")); - return new UsernamePasswordAuthenticationToken( - principal, null, List.of( - new SimpleGrantedAuthority("ROLE_USER"), - new SimpleGrantedAuthority("SCOPE_skill:publish"))); + private void givenValidPublishToken() { + ApiToken token = new ApiToken("user-1", "cli", "sk_test", "hash", "[\"skill:publish\"]"); + UserAccount user = new UserAccount("user-1", "tester", "t@example.com", ""); + + given(apiTokenService.validateToken("test-token")).willReturn(Optional.of(token)); + given(userAccountRepository.findById("user-1")).willReturn(Optional.of(user)); + given(userRoleBindingRepository.findByUserId("user-1")).willReturn(List.of()); } @Test void validatePublish_returnsValidResult() throws Exception { + givenValidPublishToken(); given(cliSkillAppService.validatePublish( eq("global"), any(), eq("user-1"), eq(SkillVisibility.PUBLIC), eq(Set.of("USER")))) .willReturn(new CliDryRunResponse( @@ -55,8 +61,7 @@ class CliDryRunValidateTest { mockMvc.perform(multipart("/api/cli/v1/skills/global/publish/validate") .file(file) - .header("Authorization", "Bearer test-token") - .with(authentication(auth()))) + .header("Authorization", "Bearer test-token")) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.valid").value(true)) .andExpect(jsonPath("$.data.resolvedSlug").value("my-skill")) @@ -65,6 +70,7 @@ class CliDryRunValidateTest { @Test void validatePublish_returnsInvalidResult() throws Exception { + givenValidPublishToken(); given(cliSkillAppService.validatePublish( eq("global"), any(), eq("user-1"), eq(SkillVisibility.PUBLIC), eq(Set.of("USER")))) .willReturn(new CliDryRunResponse( @@ -76,8 +82,7 @@ class CliDryRunValidateTest { mockMvc.perform(multipart("/api/cli/v1/skills/global/publish/validate") .file(file) - .header("Authorization", "Bearer test-token") - .with(authentication(auth()))) + .header("Authorization", "Bearer test-token")) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.valid").value(false)) .andExpect(jsonPath("$.data.errors[0]").value("Missing required file: SKILL.md at root")) @@ -86,6 +91,7 @@ class CliDryRunValidateTest { @Test void validatePublish_acceptsCustomVisibility() throws Exception { + givenValidPublishToken(); given(cliSkillAppService.validatePublish( eq("global"), any(), eq("user-1"), eq(SkillVisibility.PRIVATE), eq(Set.of("USER")))) .willReturn(new CliDryRunResponse( @@ -97,22 +103,21 @@ class CliDryRunValidateTest { mockMvc.perform(multipart("/api/cli/v1/skills/global/publish/validate") .file(file) .file(new MockMultipartFile("visibility", "", "text/plain", "PRIVATE".getBytes())) - .header("Authorization", "Bearer test-token") - .with(authentication(auth()))) + .header("Authorization", "Bearer test-token")) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.valid").value(true)); } @Test void validatePublish_rejectsInvalidVisibility() throws Exception { + givenValidPublishToken(); MockMultipartFile file = new MockMultipartFile("file", "skill.zip", "application/zip", new byte[]{0x50, 0x4B, 0x03, 0x04}); mockMvc.perform(multipart("/api/cli/v1/skills/global/publish/validate") .file(file) .file(new MockMultipartFile("visibility", "", "text/plain", "BOGUS".getBytes())) - .header("Authorization", "Bearer test-token") - .with(authentication(auth()))) + .header("Authorization", "Bearer test-token")) .andExpect(status().isBadRequest()); } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliSkillControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliSkillControllerTest.java index fa40e0fa..9f6fe695 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliSkillControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliSkillControllerTest.java @@ -22,8 +22,6 @@ import org.springframework.core.io.InputStreamResource; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; import org.springframework.web.bind.annotation.PostMapping; @@ -33,7 +31,6 @@ import java.lang.reflect.Method; import java.util.List; import java.util.Map; import java.util.Optional; -import java.util.Set; import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -43,7 +40,6 @@ import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.BDDMockito.given; -import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -204,13 +200,12 @@ class CliSkillControllerTest { @Test void deleteReturnsCliDeleteResponse() throws Exception { - PlatformPrincipal principal = new PlatformPrincipal( - "user-1", "tester", "t@example.com", "", "api_token", Set.of("USER")); - var auth = new UsernamePasswordAuthenticationToken( - principal, null, List.of( - new SimpleGrantedAuthority("ROLE_USER"), - new SimpleGrantedAuthority("SCOPE_skill:delete"))); + ApiToken token = new ApiToken("user-1", "cli", "sk_test", "hash", "[\"skill:delete\"]"); + UserAccount user = new UserAccount("user-1", "tester", "t@example.com", ""); + given(apiTokenService.validateToken("test-token")).willReturn(Optional.of(token)); + given(userAccountRepository.findById("user-1")).willReturn(Optional.of(user)); + given(userRoleBindingRepository.findByUserId("user-1")).willReturn(List.of()); given(cliSkillAppService.deleteRemote( org.mockito.ArgumentMatchers.eq("global"), org.mockito.ArgumentMatchers.eq("demo"), @@ -222,8 +217,7 @@ class CliSkillControllerTest { mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders .delete("/api/cli/v1/skills/global/demo") - .header("Authorization", "Bearer test-token") - .with(authentication(auth))) + .header("Authorization", "Bearer test-token")) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.ok").value(true)) .andExpect(jsonPath("$.data.namespace").value("global")) From 78b8b34ed18db24c260e073d5da929a52171c46c Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Thu, 18 Jun 2026 15:52:49 +0800 Subject: [PATCH 24/81] chore(cli): bump version to 0.1.8 Signed-off-by: dongmucat <1127093059@qq.com> --- cli/package.json | 2 +- cli/src/generated/pkg-info.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cli/package.json b/cli/package.json index 7edb6493..3845975b 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@astron-team/skillhub", - "version": "0.1.7", + "version": "0.1.8", "description": "Manage and install skills for AI coding agents", "keywords": [ "skillhub", diff --git a/cli/src/generated/pkg-info.ts b/cli/src/generated/pkg-info.ts index f73a487a..bc445c51 100644 --- a/cli/src/generated/pkg-info.ts +++ b/cli/src/generated/pkg-info.ts @@ -1,3 +1,3 @@ // Generated by scripts/generate-pkg-info.ts - do not edit by hand. export const PKG_NAME = "@astron-team/skillhub" -export const PKG_VERSION = "0.1.7" +export const PKG_VERSION = "0.1.8" From 665ee0499af78f3c6d026b2acfcd1ad7e7f6830b Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Mon, 22 Jun 2026 12:36:47 +0800 Subject: [PATCH 25/81] feat(auth): add ISSUE-60 password capability field Signed-off-by: dongmucat <1127093059@qq.com> --- .../skillhub/controller/AuthController.java | 12 ++++++--- .../controller/LocalAuthController.java | 10 ++++--- .../iflytek/skillhub/dto/AuthMeResponse.java | 4 ++- .../service/AuthMeResponseAssembler.java | 27 +++++++++++++++++++ .../controller/AuthControllerTest.java | 10 ++++++- .../controller/DirectAuthControllerTest.java | 9 ++++++- .../controller/LocalAuthControllerTest.java | 12 +++++++-- .../SessionBootstrapControllerTest.java | 9 ++++++- .../auth/local/LocalCredentialRepository.java | 2 ++ .../auth/local/LocalAuthServiceTest.java | 14 ++++++++++ web/src/api/generated/schema.d.ts | 1 + 11 files changed, 97 insertions(+), 13 deletions(-) create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AuthMeResponseAssembler.java diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AuthController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AuthController.java index 1552f6f2..4ba7b27b 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AuthController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AuthController.java @@ -16,6 +16,7 @@ import com.iflytek.skillhub.dto.AuthProviderResponse; import com.iflytek.skillhub.dto.DirectLoginRequest; import com.iflytek.skillhub.dto.SessionBootstrapRequest; import com.iflytek.skillhub.auth.exception.AuthFlowException; +import com.iflytek.skillhub.service.AuthMeResponseAssembler; import com.iflytek.skillhub.service.AuthMethodCatalog; import com.iflytek.skillhub.service.DirectAuthService; import com.iflytek.skillhub.service.SessionBootstrapService; @@ -56,6 +57,7 @@ public class AuthController extends BaseApiController { private final UserRoleBindingRepository userRoleBindingRepository; private final PlatformSessionService platformSessionService; private final UserAccountRepository userAccountRepository; + private final AuthMeResponseAssembler authMeResponseAssembler; public AuthController(ApiResponseFactory responseFactory, AuthMethodCatalog authMethodCatalog, @@ -64,7 +66,8 @@ public class AuthController extends BaseApiController { AuthFailureThrottleService authFailureThrottleService, UserRoleBindingRepository userRoleBindingRepository, PlatformSessionService platformSessionService, - UserAccountRepository userAccountRepository) { + UserAccountRepository userAccountRepository, + AuthMeResponseAssembler authMeResponseAssembler) { super(responseFactory); this.authMethodCatalog = authMethodCatalog; this.sessionBootstrapService = sessionBootstrapService; @@ -73,6 +76,7 @@ public class AuthController extends BaseApiController { this.userRoleBindingRepository = userRoleBindingRepository; this.platformSessionService = platformSessionService; this.userAccountRepository = userAccountRepository; + this.authMeResponseAssembler = authMeResponseAssembler; } /** @@ -111,7 +115,7 @@ public class AuthController extends BaseApiController { freshRoles); platformSessionService.establishSession(principal, request, false); } - return ok("response.success.read", AuthMeResponse.from(principal)); + return ok("response.success.read", authMeResponseAssembler.from(principal)); } /** @@ -146,7 +150,7 @@ public class AuthController extends BaseApiController { HttpServletRequest httpRequest) { return ok( "response.success.read", - AuthMeResponse.from(sessionBootstrapService.bootstrap(request.provider(), httpRequest)) + authMeResponseAssembler.from(sessionBootstrapService.bootstrap(request.provider(), httpRequest)) ); } @@ -178,7 +182,7 @@ public class AuthController extends BaseApiController { authFailureThrottleService.resetIdentifier(category, request.username()); return ok( "response.success.read", - AuthMeResponse.from(principal) + authMeResponseAssembler.from(principal) ); } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/LocalAuthController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/LocalAuthController.java index 8442939d..17e54fbe 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/LocalAuthController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/LocalAuthController.java @@ -17,6 +17,7 @@ import com.iflytek.skillhub.exception.UnauthorizedException; import com.iflytek.skillhub.metrics.SkillHubMetrics; import com.iflytek.skillhub.ratelimit.RateLimit; import com.iflytek.skillhub.security.AuthFailureThrottleService; +import com.iflytek.skillhub.service.AuthMeResponseAssembler; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import org.springframework.http.HttpStatus; @@ -38,19 +39,22 @@ public class LocalAuthController extends BaseApiController { private final PlatformSessionService platformSessionService; private final AuthFailureThrottleService authFailureThrottleService; private final PasswordResetService passwordResetService; + private final AuthMeResponseAssembler authMeResponseAssembler; public LocalAuthController(ApiResponseFactory responseFactory, LocalAuthService localAuthService, SkillHubMetrics skillHubMetrics, PlatformSessionService platformSessionService, AuthFailureThrottleService authFailureThrottleService, - PasswordResetService passwordResetService) { + PasswordResetService passwordResetService, + AuthMeResponseAssembler authMeResponseAssembler) { super(responseFactory); this.localAuthService = localAuthService; this.skillHubMetrics = skillHubMetrics; this.platformSessionService = platformSessionService; this.authFailureThrottleService = authFailureThrottleService; this.passwordResetService = passwordResetService; + this.authMeResponseAssembler = authMeResponseAssembler; } @PostMapping("/register") @@ -60,7 +64,7 @@ public class LocalAuthController extends BaseApiController { PlatformPrincipal principal = localAuthService.register(request.username(), request.password(), request.email()); skillHubMetrics.incrementUserRegister(); platformSessionService.establishSession(principal, httpRequest); - return ok("response.success.created", AuthMeResponse.from(principal)); + return ok("response.success.created", authMeResponseAssembler.from(principal)); } @PostMapping("/login") @@ -84,7 +88,7 @@ public class LocalAuthController extends BaseApiController { authFailureThrottleService.resetIdentifier("local", request.username()); skillHubMetrics.recordLocalLogin(true); platformSessionService.establishSession(principal, httpRequest); - return ok("response.success.read", AuthMeResponse.from(principal)); + return ok("response.success.read", authMeResponseAssembler.from(principal)); } @PostMapping("/change-password") diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AuthMeResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AuthMeResponse.java index 470b2fdb..d5447884 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AuthMeResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AuthMeResponse.java @@ -10,15 +10,17 @@ public record AuthMeResponse( String email, String avatarUrl, String oauthProvider, + boolean canChangePassword, Set platformRoles ) { - public static AuthMeResponse from(PlatformPrincipal principal) { + public static AuthMeResponse from(PlatformPrincipal principal, boolean canChangePassword) { return new AuthMeResponse( principal.userId(), principal.displayName(), principal.email() != null ? principal.email() : "", principal.avatarUrl() != null ? principal.avatarUrl() : "", principal.oauthProvider(), + canChangePassword, principal.platformRoles() ); } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AuthMeResponseAssembler.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AuthMeResponseAssembler.java new file mode 100644 index 00000000..60b0066b --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AuthMeResponseAssembler.java @@ -0,0 +1,27 @@ +package com.iflytek.skillhub.service; + +import com.iflytek.skillhub.auth.local.LocalCredentialRepository; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.dto.AuthMeResponse; +import org.springframework.stereotype.Service; + +/** + * Builds the current-user API response with account capabilities derived from + * authoritative backend state. + */ +@Service +public class AuthMeResponseAssembler { + + private final LocalCredentialRepository localCredentialRepository; + + public AuthMeResponseAssembler(LocalCredentialRepository localCredentialRepository) { + this.localCredentialRepository = localCredentialRepository; + } + + public AuthMeResponse from(PlatformPrincipal principal) { + return AuthMeResponse.from( + principal, + localCredentialRepository.existsByUserId(principal.userId()) + ); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java index a25d3104..d79d368a 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java @@ -1,6 +1,7 @@ package com.iflytek.skillhub.controller; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.auth.local.LocalCredentialRepository; import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; import com.iflytek.skillhub.domain.user.UserAccount; @@ -64,6 +65,9 @@ class AuthControllerTest { @MockBean private UserRoleBindingRepository userRoleBindingRepository; + @MockBean + private LocalCredentialRepository localCredentialRepository; + @Test void meShouldReturnUnauthorizedForAnonymousRequest() throws Exception { mockMvc.perform(get("/api/v1/auth/me")) @@ -77,6 +81,7 @@ class AuthControllerTest { given(userAccountRepository.findById("user-42")) .willReturn(java.util.Optional.of(new UserAccount("user-42", "tester", "tester@example.com", "https://example.com/avatar.png"))); given(userRoleBindingRepository.findByUserId("user-42")).willReturn(List.of()); + given(localCredentialRepository.existsByUserId("user-42")).willReturn(false); PlatformPrincipal principal = new PlatformPrincipal( "user-42", @@ -102,6 +107,7 @@ class AuthControllerTest { .andExpect(jsonPath("$.data.userId").value("user-42")) .andExpect(jsonPath("$.data.displayName").value("tester")) .andExpect(jsonPath("$.data.oauthProvider").value("github")) + .andExpect(jsonPath("$.data.canChangePassword").value(false)) .andExpect(jsonPath("$.data.platformRoles[0]").value("USER")) .andExpect(jsonPath("$.timestamp").isNotEmpty()) .andExpect(jsonPath("$.requestId").isNotEmpty()); @@ -115,6 +121,7 @@ class AuthControllerTest { var user = new UserAccount("user-42", "UpdatedName", "tester@example.com", "https://example.com/avatar.png"); given(userAccountRepository.findById("user-42")).willReturn(java.util.Optional.of(user)); given(userRoleBindingRepository.findByUserId("user-42")).willReturn(List.of()); + given(localCredentialRepository.existsByUserId("user-42")).willReturn(true); PlatformPrincipal principal = new PlatformPrincipal( "user-42", @@ -134,7 +141,8 @@ class AuthControllerTest { mockMvc.perform(get("/api/v1/auth/me").with(authentication(auth))) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) - .andExpect(jsonPath("$.data.displayName").value("UpdatedName")); // should return DB value + .andExpect(jsonPath("$.data.displayName").value("UpdatedName")) // should return DB value + .andExpect(jsonPath("$.data.canChangePassword").value(true)); } @Test diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/DirectAuthControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/DirectAuthControllerTest.java index 8878e286..97db51d4 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/DirectAuthControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/DirectAuthControllerTest.java @@ -7,6 +7,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import com.iflytek.skillhub.auth.local.LocalCredentialRepository; import com.iflytek.skillhub.auth.local.LocalAuthService; import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; @@ -52,6 +53,9 @@ class DirectAuthControllerTest { @MockBean private UserRoleBindingRepository userRoleBindingRepository; + @MockBean + private LocalCredentialRepository localCredentialRepository; + @Test void directLoginShouldAuthenticateViaConfiguredProvider() throws Exception { PlatformPrincipal principal = new PlatformPrincipal( @@ -67,6 +71,7 @@ class DirectAuthControllerTest { given(userAccountRepository.findById("usr_direct_1")) .willReturn(java.util.Optional.of(new UserAccount("usr_direct_1", "direct-user", null, null))); given(userRoleBindingRepository.findByUserId("usr_direct_1")).willReturn(List.of()); + given(localCredentialRepository.existsByUserId("usr_direct_1")).willReturn(true); MockHttpSession session = (MockHttpSession) mockMvc.perform(post("/api/v1/auth/direct/login") .with(csrf()) @@ -77,6 +82,7 @@ class DirectAuthControllerTest { .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.data.userId").value("usr_direct_1")) + .andExpect(jsonPath("$.data.canChangePassword").value(true)) .andReturn() .getRequest() .getSession(false); @@ -84,7 +90,8 @@ class DirectAuthControllerTest { mockMvc.perform(get("/api/v1/auth/me").session(session)) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) - .andExpect(jsonPath("$.data.userId").value("usr_direct_1")); + .andExpect(jsonPath("$.data.userId").value("usr_direct_1")) + .andExpect(jsonPath("$.data.canChangePassword").value(true)); } @Test diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LocalAuthControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LocalAuthControllerTest.java index 440fe4bf..2425acde 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LocalAuthControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LocalAuthControllerTest.java @@ -12,6 +12,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. import com.iflytek.skillhub.auth.exception.AuthFlowException; import com.iflytek.skillhub.auth.local.LocalAuthService; +import com.iflytek.skillhub.auth.local.LocalCredentialRepository; import com.iflytek.skillhub.auth.local.PasswordResetService; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; @@ -55,6 +56,9 @@ class LocalAuthControllerTest { @MockBean private PasswordResetService passwordResetService; + @MockBean + private LocalCredentialRepository localCredentialRepository; + @Test void login_returnsCurrentUserEnvelope() throws Exception { PlatformPrincipal principal = new PlatformPrincipal( @@ -66,6 +70,7 @@ class LocalAuthControllerTest { Set.of("SUPER_ADMIN") ); given(localAuthService.login("alice", "Abcd123!")).willReturn(principal); + given(localCredentialRepository.existsByUserId("usr_1")).willReturn(true); mockMvc.perform(post("/api/v1/auth/local/login") .with(csrf()) @@ -76,7 +81,8 @@ class LocalAuthControllerTest { .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.data.userId").value("usr_1")) - .andExpect(jsonPath("$.data.oauthProvider").value("local")); + .andExpect(jsonPath("$.data.oauthProvider").value("local")) + .andExpect(jsonPath("$.data.canChangePassword").value(true)); verify(skillHubMetrics).recordLocalLogin(true); verify(skillHubMetrics, never()).recordLocalLogin(false); verify(authFailureThrottleService).resetIdentifier("local", "alice"); @@ -93,6 +99,7 @@ class LocalAuthControllerTest { Set.of() ); given(localAuthService.register("bob", "Abcd123!", "bob@example.com")).willReturn(principal); + given(localCredentialRepository.existsByUserId("usr_2")).willReturn(true); mockMvc.perform(post("/api/v1/auth/local/register") .with(csrf()) @@ -102,7 +109,8 @@ class LocalAuthControllerTest { """)) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) - .andExpect(jsonPath("$.data.displayName").value("bob")); + .andExpect(jsonPath("$.data.displayName").value("bob")) + .andExpect(jsonPath("$.data.canChangePassword").value(true)); verify(skillHubMetrics).incrementUserRegister(); } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SessionBootstrapControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SessionBootstrapControllerTest.java index b842efbc..36fd80bf 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SessionBootstrapControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SessionBootstrapControllerTest.java @@ -1,6 +1,7 @@ package com.iflytek.skillhub.controller; import com.iflytek.skillhub.auth.bootstrap.PassiveSessionAuthenticator; +import com.iflytek.skillhub.auth.local.LocalCredentialRepository; import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; @@ -48,12 +49,16 @@ class SessionBootstrapControllerTest { @MockBean private UserRoleBindingRepository userRoleBindingRepository; + @MockBean + private LocalCredentialRepository localCredentialRepository; + @Test void sessionBootstrapShouldEstablishSessionWhenAuthenticatorSucceeds() throws Exception { given(namespaceMemberRepository.findByUserId("sso-user-1")).willReturn(List.of()); given(userAccountRepository.findById("sso-user-1")) .willReturn(Optional.of(new UserAccount("sso-user-1", "Private SSO User", null, null))); given(userRoleBindingRepository.findByUserId("sso-user-1")).willReturn(List.of()); + given(localCredentialRepository.existsByUserId("sso-user-1")).willReturn(false); MockHttpSession session = (MockHttpSession) mockMvc.perform(post("/api/v1/auth/session/bootstrap") .with(csrf()) @@ -65,6 +70,7 @@ class SessionBootstrapControllerTest { .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.data.userId").value("sso-user-1")) .andExpect(jsonPath("$.data.displayName").value("Private SSO User")) + .andExpect(jsonPath("$.data.canChangePassword").value(false)) .andReturn() .getRequest() .getSession(false); @@ -73,7 +79,8 @@ class SessionBootstrapControllerTest { .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.data.userId").value("sso-user-1")) - .andExpect(jsonPath("$.data.oauthProvider").value("private-sso")); + .andExpect(jsonPath("$.data.oauthProvider").value("private-sso")) + .andExpect(jsonPath("$.data.canChangePassword").value(false)); } @Test diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalCredentialRepository.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalCredentialRepository.java index 8346b9c2..a80d44ac 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalCredentialRepository.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalCredentialRepository.java @@ -15,4 +15,6 @@ public interface LocalCredentialRepository extends JpaRepository findByUserId(String userId); boolean existsByUsernameIgnoreCase(String username); + + boolean existsByUserId(String userId); } diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java index 6b9f4344..b6eaf5af 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java @@ -230,6 +230,20 @@ class LocalAuthServiceTest { assertThat(principal.platformRoles()).containsExactly("USER"); } + @Test + void changePassword_withoutLocalCredential_rejectsRequest() { + given(credentialRepository.findByUserId("oauth-only")).willReturn(Optional.empty()); + + assertThatThrownBy(() -> service.changePassword("oauth-only", "old", "Newpass123!")) + .isInstanceOf(AuthFlowException.class) + .hasMessageContaining("error.auth.local.notEnabled") + .extracting("status") + .isEqualTo(HttpStatus.BAD_REQUEST); + + verify(passwordEncoder, never()).matches(any(), any()); + verify(credentialRepository, never()).save(any(LocalCredential.class)); + } + @Test void register_rejectsInvalidEmailFormat() { given(credentialRepository.existsByUsernameIgnoreCase("alice")).willReturn(false); diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index a141fb20..68d7ceb9 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -3846,6 +3846,7 @@ export interface components { email?: string; avatarUrl?: string; oauthProvider?: string; + canChangePassword?: boolean; platformRoles?: string[]; }; LocalRegisterRequest: { From 54006e72a4db90ced5e2ed6e7b4dfb309738edeb Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Mon, 22 Jun 2026 14:00:00 +0800 Subject: [PATCH 26/81] fix(web): ISSUE-62 gate security settings by capability Signed-off-by: dongmucat <1127093059@qq.com> --- web/e2e/settings-security-capability.spec.ts | 102 ++++++++++++++++ web/src/i18n/locales/en.json | 2 + web/src/i18n/locales/zh.json | 2 + web/src/pages/settings/security.test.ts | 61 ---------- web/src/pages/settings/security.test.tsx | 119 +++++++++++++++++++ web/src/pages/settings/security.tsx | 60 ++++++---- web/src/shared/components/user-menu.test.ts | 18 --- web/src/shared/components/user-menu.test.tsx | 108 +++++++++++++++++ web/src/shared/components/user-menu.tsx | 5 +- 9 files changed, 371 insertions(+), 106 deletions(-) create mode 100644 web/e2e/settings-security-capability.spec.ts delete mode 100644 web/src/pages/settings/security.test.ts create mode 100644 web/src/pages/settings/security.test.tsx delete mode 100644 web/src/shared/components/user-menu.test.ts create mode 100644 web/src/shared/components/user-menu.test.tsx diff --git a/web/e2e/settings-security-capability.spec.ts b/web/e2e/settings-security-capability.spec.ts new file mode 100644 index 00000000..30d94ea8 --- /dev/null +++ b/web/e2e/settings-security-capability.spec.ts @@ -0,0 +1,102 @@ +import { expect, test, type Page } from '@playwright/test' +import { setEnglishLocale } from './helpers/auth-fixtures' + +interface MockSessionUser { + userId: string + displayName: string + email: string + avatarUrl: string + oauthProvider: string + canChangePassword: boolean + platformRoles: string[] +} + +function apiEnvelope(data: unknown) { + return { + code: 0, + msg: 'OK', + data, + timestamp: new Date().toISOString(), + requestId: 'e2e-security-capability', + } +} + +async function mockSession(page: Page, user: MockSessionUser) { + await page.route('**/api/v1/auth/me', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(apiEnvelope(user)), + }) + }) + + await page.route('**/api/web/me/namespaces', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(apiEnvelope([])), + }) + }) + + await page.route('**/api/web/notifications/unread-count', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(apiEnvelope({ count: 0 })), + }) + }) + + await page.route('**/api/web/notifications/sse', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'text/event-stream', + body: ': ok\n\n', + }) + }) +} + +test.describe('Security Settings capability', () => { + test('shows the security menu entry and password form for local admin accounts', async ({ page }) => { + await setEnglishLocale(page) + await mockSession(page, { + userId: 'local-admin', + displayName: 'Local Admin', + email: 'local-admin@example.test', + avatarUrl: '', + oauthProvider: '', + canChangePassword: true, + platformRoles: ['USER', 'SUPER_ADMIN'], + }) + + await page.goto('/settings/security') + await expect(page.getByRole('heading', { name: 'Security Settings' })).toBeVisible() + await expect(page.getByLabel('Current Password')).toBeVisible() + await expect(page.getByLabel('New Password')).toBeVisible() + + await page.getByRole('button', { name: 'Local Admin' }).click() + await expect(page.getByRole('link', { name: 'Security Settings' })).toBeVisible() + }) + + test('hides the security menu entry and form when password changes are unavailable', async ({ page }) => { + await setEnglishLocale(page) + await mockSession(page, { + userId: 'oauth-only-user', + displayName: 'OAuth Only User', + email: 'oauth-only@example.test', + avatarUrl: '', + oauthProvider: 'github', + canChangePassword: false, + platformRoles: ['USER'], + }) + + await page.goto('/settings/security') + + await expect(page.getByRole('heading', { name: 'Security Settings' })).toBeVisible() + await expect(page.getByText('Password changes are unavailable for this account.')).toBeVisible() + await expect(page.getByLabel('Current Password')).toHaveCount(0) + await expect(page.getByRole('button', { name: 'Update Password' })).toHaveCount(0) + + await page.getByRole('button', { name: 'OAuth Only User' }).click() + await expect(page.getByRole('link', { name: 'Security Settings' })).toHaveCount(0) + }) +}) diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 56039107..6cf7fc71 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -743,6 +743,8 @@ "successTitle": "Password changed successfully", "successDescription": "Please sign in again with your new password.", "defaultError": "Failed to change password", + "unavailableTitle": "Password changes are unavailable for this account.", + "unavailableDescription": "This account signs in through an external identity provider or has no local password credential.", "submitting": "Submitting...", "submit": "Update Password" }, diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 1920b158..2086a5a4 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -743,6 +743,8 @@ "successTitle": "密码修改成功", "successDescription": "请使用新密码重新登录。", "defaultError": "修改密码失败", + "unavailableTitle": "此账号暂不可修改密码。", + "unavailableDescription": "此账号通过外部身份提供方登录,或尚未配置本地密码凭据。", "submitting": "提交中...", "submit": "更新密码" }, diff --git a/web/src/pages/settings/security.test.ts b/web/src/pages/settings/security.test.ts deleted file mode 100644 index 6c11ae40..00000000 --- a/web/src/pages/settings/security.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' - -vi.mock('@tanstack/react-router', () => ({ - useNavigate: () => vi.fn(), -})) - -vi.mock('@tanstack/react-query', () => ({ - useQueryClient: () => ({ setQueryData: vi.fn() }), -})) - -vi.mock('react-i18next', async () => { - const actual = await vi.importActual('react-i18next') - return { - ...actual, - useTranslation: () => ({ - t: (key: string) => key, - }), - } -}) - -vi.mock('@/api/client', () => ({ - ApiError: class ApiError extends Error { - status?: number - }, - authApi: { - changePassword: vi.fn(), - logout: vi.fn(), - }, -})) - -vi.mock('@/shared/lib/error-display', () => ({ - truncateErrorMessage: (v: string) => v, -})) - -vi.mock('@/shared/lib/toast', () => ({ - toast: { success: vi.fn(), error: vi.fn() }, -})) - -vi.mock('@/shared/ui/button', () => ({ - Button: ({ children }: { children: unknown }) => children, -})) - -vi.mock('@/shared/ui/card', () => ({ - Card: ({ children }: { children: unknown }) => children, - CardContent: ({ children }: { children: unknown }) => children, - CardDescription: ({ children }: { children: unknown }) => children, - CardHeader: ({ children }: { children: unknown }) => children, - CardTitle: ({ children }: { children: unknown }) => children, -})) - -vi.mock('@/shared/ui/input', () => ({ - Input: () => null, -})) - -import { SecuritySettingsPage } from './security' - -describe('SecuritySettingsPage', () => { - it('exports a named component function', () => { - expect(typeof SecuritySettingsPage).toBe('function') - }) -}) diff --git a/web/src/pages/settings/security.test.tsx b/web/src/pages/settings/security.test.tsx new file mode 100644 index 00000000..7f965912 --- /dev/null +++ b/web/src/pages/settings/security.test.tsx @@ -0,0 +1,119 @@ +import type { InputHTMLAttributes, ReactNode } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const useAuthMock = vi.hoisted(() => vi.fn()) + +vi.mock('@tanstack/react-router', () => ({ + useNavigate: () => vi.fn(), +})) + +vi.mock('@tanstack/react-query', () => ({ + useQueryClient: () => ({ setQueryData: vi.fn() }), +})) + +vi.mock('react-i18next', async () => { + const actual = await vi.importActual('react-i18next') + return { + ...actual, + useTranslation: () => ({ + t: (key: string) => key, + }), + } +}) + +vi.mock('@/api/client', () => ({ + ApiError: class ApiError extends Error { + status?: number + }, + authApi: { + changePassword: vi.fn(), + logout: vi.fn(), + }, +})) + +vi.mock('@/features/auth/use-auth', () => ({ + useAuth: useAuthMock, +})) + +vi.mock('@/shared/lib/error-display', () => ({ + truncateErrorMessage: (v: string) => v, +})) + +vi.mock('@/shared/lib/toast', () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})) + +vi.mock('@/shared/ui/button', () => ({ + Button: ({ + children, + disabled, + type, + }: { + children: ReactNode + disabled?: boolean + type?: 'button' | 'submit' | 'reset' + }) => ( + + ), +})) + +vi.mock('@/shared/ui/card', () => ({ + Card: ({ children }: { children: ReactNode }) => children, + CardContent: ({ children }: { children: ReactNode }) => children, + CardDescription: ({ children }: { children: ReactNode }) => children, + CardHeader: ({ children }: { children: ReactNode }) => children, + CardTitle: ({ children }: { children: ReactNode }) => children, +})) + +vi.mock('@/shared/ui/input', () => ({ + Input: (props: InputHTMLAttributes) => , +})) + +import { SecuritySettingsPage } from './security' + +beforeEach(() => { + useAuthMock.mockReturnValue({ + user: { + userId: 'user-1', + displayName: 'Local User', + platformRoles: ['USER'], + canChangePassword: true, + }, + }) +}) + +describe('SecuritySettingsPage', () => { + it('exports a named component function', () => { + expect(typeof SecuritySettingsPage).toBe('function') + }) + + it('renders the password form when password changes are allowed', () => { + const html = renderToStaticMarkup() + + expect(html).toContain('security.currentPassword') + expect(html).toContain('security.newPassword') + expect(html).toContain('security.submit') + }) + + it('renders a read-only unavailable state when password changes are not allowed', () => { + useAuthMock.mockReturnValue({ + user: { + userId: 'oauth-user', + displayName: 'OAuth User', + oauthProvider: 'github', + platformRoles: ['USER'], + canChangePassword: false, + }, + }) + + const html = renderToStaticMarkup() + + expect(html).toContain('security.unavailableTitle') + expect(html).toContain('security.unavailableDescription') + expect(html).not.toContain('security.currentPassword') + expect(html).not.toContain('security.submit') + }) +}) diff --git a/web/src/pages/settings/security.tsx b/web/src/pages/settings/security.tsx index d9285910..37e85d56 100644 --- a/web/src/pages/settings/security.tsx +++ b/web/src/pages/settings/security.tsx @@ -3,6 +3,7 @@ import { useNavigate } from '@tanstack/react-router' import { useQueryClient } from '@tanstack/react-query' import { useTranslation } from 'react-i18next' import { ApiError, authApi } from '@/api/client' +import { useAuth } from '@/features/auth/use-auth' import { clearSessionScopedQueries } from '@/features/notification/notification-session' import { truncateErrorMessage } from '@/shared/lib/error-display' import { toast } from '@/shared/lib/toast' @@ -19,10 +20,12 @@ export function SecuritySettingsPage() { const { t } = useTranslation() const navigate = useNavigate() const queryClient = useQueryClient() + const { user } = useAuth() const [currentPassword, setCurrentPassword] = useState('') const [newPassword, setNewPassword] = useState('') const [errorMessage, setErrorMessage] = useState('') const [isSubmitting, setIsSubmitting] = useState(false) + const passwordChangeUnavailable = user?.canChangePassword === false /** * Submits the password change request and clears local auth state afterward, @@ -78,32 +81,39 @@ export function SecuritySettingsPage() { {t('security.subtitle')} -
-
- - setCurrentPassword(event.target.value)} - /> + {passwordChangeUnavailable ? ( +
+

{t('security.unavailableTitle')}

+

{t('security.unavailableDescription')}

-
- - setNewPassword(event.target.value)} - /> -
- {errorMessage ?

{errorMessage}

: null} - - + ) : ( +
+
+ + setCurrentPassword(event.target.value)} + /> +
+
+ + setNewPassword(event.target.value)} + /> +
+ {errorMessage ?

{errorMessage}

: null} + +
+ )}
diff --git a/web/src/shared/components/user-menu.test.ts b/web/src/shared/components/user-menu.test.ts deleted file mode 100644 index a1877caf..00000000 --- a/web/src/shared/components/user-menu.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { describe, expect, it } from 'vitest' -import * as mod from './user-menu' - -/** - * UserMenu is a React component that renders a hover/click dropdown menu with - * role-based navigation links (dashboard, reviews, admin, etc.) and logout. - * Internal helpers (hasRole, closeMenu, handleMouseEnter/Leave) and the - * menuItemClassName constant are scoped inside the component function. - * There are no exported pure helpers or constants to test here. - * - * We verify the module shape so downstream consumers break fast - * if the export contract changes. - */ -describe('user-menu module exports', () => { - it('exports the UserMenu component', () => { - expect(mod.UserMenu).toBeTypeOf('function') - }) -}) diff --git a/web/src/shared/components/user-menu.test.tsx b/web/src/shared/components/user-menu.test.tsx new file mode 100644 index 00000000..3487bd8b --- /dev/null +++ b/web/src/shared/components/user-menu.test.tsx @@ -0,0 +1,108 @@ +import type { ReactNode } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' +import * as mod from './user-menu' +import { UserMenu } from './user-menu' + +vi.mock('react', async () => { + const actual = await vi.importActual('react') + return { + ...actual, + useState: (initialValue: unknown) => [ + typeof initialValue === 'boolean' ? true : initialValue, + vi.fn(), + ], + } +}) + +vi.mock('react-i18next', async () => { + const actual = await vi.importActual('react-i18next') + return { + ...actual, + useTranslation: () => ({ + t: (key: string) => key, + }), + } +}) + +vi.mock('@tanstack/react-router', () => ({ + Link: ({ + children, + className, + onClick, + to, + }: { + children: ReactNode + className?: string + onClick?: () => void + to: string + }) => ( + { + event.preventDefault() + onClick?.() + }} + > + {children} + + ), +})) + +vi.mock('@tanstack/react-query', () => ({ + useQueryClient: () => ({ + setQueryData: vi.fn(), + }), +})) + +vi.mock('@/api/client', () => ({ + authApi: { + logout: vi.fn(), + }, +})) + +vi.mock('@/shared/hooks/use-namespace-queries', () => ({ + useMyNamespaces: () => ({ data: [] }), +})) + +/** + * UserMenu is a React component that renders a hover/click dropdown menu with + * role-based navigation links (dashboard, reviews, admin, etc.) and logout. + */ +describe('user-menu module exports', () => { + it('exports the UserMenu component', () => { + expect(mod.UserMenu).toBeTypeOf('function') + }) +}) + +describe('UserMenu security settings visibility', () => { + it('shows security settings when password changes are allowed, independent of OAuth provider', () => { + const html = renderToStaticMarkup( + , + ) + + expect(html).toContain('user.menu.security') + }) + + it('hides security settings when password changes are not allowed, even for a local-looking account', () => { + const html = renderToStaticMarkup( + , + ) + + expect(html).not.toContain('user.menu.security') + }) +}) diff --git a/web/src/shared/components/user-menu.tsx b/web/src/shared/components/user-menu.tsx index 1cb10a31..d3eb3d39 100644 --- a/web/src/shared/components/user-menu.tsx +++ b/web/src/shared/components/user-menu.tsx @@ -14,6 +14,7 @@ interface User { avatarUrl?: string platformRoles?: string[] oauthProvider?: string + canChangePassword?: boolean } interface UserMenuProps { @@ -37,7 +38,7 @@ export function UserMenu({ user, triggerClassName }: UserMenuProps) { const isAuditor = hasRole('AUDITOR') || hasRole('SUPER_ADMIN') const isSuperAdmin = hasRole('SUPER_ADMIN') const reviewCenterVisible = canAccessReviewCenter(user.platformRoles, myNamespaces) - const isLocalAccount = !user.oauthProvider + const canChangePassword = user.canChangePassword === true const open = isHovered || isClickOpen const clearCloseTimer = () => { @@ -200,7 +201,7 @@ export function UserMenu({ user, triggerClassName }: UserMenuProps) { {t('user.menu.notifications')} - {isLocalAccount ? ( + {canChangePassword ? ( {t('user.menu.security')} From 9f927c12b05a24a49de410bee5363c56b6484723 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Mon, 22 Jun 2026 14:22:25 +0800 Subject: [PATCH 27/81] fix(PR): default deny security password changes Signed-off-by: dongmucat <1127093059@qq.com> --- web/src/pages/settings/security.test.tsx | 10 +++++++++ web/src/pages/settings/security.tsx | 27 ++++++++++++++++++------ 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/web/src/pages/settings/security.test.tsx b/web/src/pages/settings/security.test.tsx index 7f965912..9e2955a7 100644 --- a/web/src/pages/settings/security.test.tsx +++ b/web/src/pages/settings/security.test.tsx @@ -116,4 +116,14 @@ describe('SecuritySettingsPage', () => { expect(html).not.toContain('security.currentPassword') expect(html).not.toContain('security.submit') }) + + it('defaults to the unavailable state while the user capability is unknown', () => { + useAuthMock.mockReturnValue({ user: null }) + + const html = renderToStaticMarkup() + + expect(html).toContain('security.unavailableTitle') + expect(html).not.toContain('security.currentPassword') + expect(html).not.toContain('security.submit') + }) }) diff --git a/web/src/pages/settings/security.tsx b/web/src/pages/settings/security.tsx index 37e85d56..3726ad7d 100644 --- a/web/src/pages/settings/security.tsx +++ b/web/src/pages/settings/security.tsx @@ -11,6 +11,14 @@ import { Button } from '@/shared/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card' import { Input } from '@/shared/ui/input' +interface PasswordChangeCapabilityUser { + canChangePassword?: boolean +} + +function canUsePasswordChangeForm(user?: PasswordChangeCapabilityUser | null) { + return user?.canChangePassword === true +} + /** * Security settings page for password changes. After a successful change the * user is logged out so all existing authenticated state is re-established with @@ -25,7 +33,7 @@ export function SecuritySettingsPage() { const [newPassword, setNewPassword] = useState('') const [errorMessage, setErrorMessage] = useState('') const [isSubmitting, setIsSubmitting] = useState(false) - const passwordChangeUnavailable = user?.canChangePassword === false + const canChangePassword = canUsePasswordChangeForm(user) /** * Submits the password change request and clears local auth state afterward, @@ -35,6 +43,11 @@ export function SecuritySettingsPage() { event.preventDefault() setErrorMessage('') + if (!canChangePassword) { + setErrorMessage(t('security.unavailableTitle')) + return + } + if (!currentPassword.trim()) { setErrorMessage(t('security.currentPasswordRequired')) return @@ -81,12 +94,7 @@ export function SecuritySettingsPage() { {t('security.subtitle')} - {passwordChangeUnavailable ? ( -
-

{t('security.unavailableTitle')}

-

{t('security.unavailableDescription')}

-
- ) : ( + {canChangePassword ? (
@@ -113,6 +121,11 @@ export function SecuritySettingsPage() { {isSubmitting ? t('security.submitting') : t('security.submit')} + ) : ( +
+

{t('security.unavailableTitle')}

+

{t('security.unavailableDescription')}

+
)} From f61ce71daa998f5169e4bbe015cadead41f7f0f6 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Mon, 22 Jun 2026 17:22:19 +0800 Subject: [PATCH 28/81] test(web): ISSUE-61 cover security settings real requests Signed-off-by: dongmucat <1127093059@qq.com> --- web/e2e/settings-pages.spec.ts | 6 +- web/e2e/settings-security-capability.spec.ts | 102 +++++++------------ 2 files changed, 38 insertions(+), 70 deletions(-) diff --git a/web/e2e/settings-pages.spec.ts b/web/e2e/settings-pages.spec.ts index de2abd6e..38f28760 100644 --- a/web/e2e/settings-pages.spec.ts +++ b/web/e2e/settings-pages.spec.ts @@ -1,11 +1,13 @@ import { expect, test } from '@playwright/test' import { setEnglishLocale } from './helpers/auth-fixtures' -import { registerSession } from './helpers/session' +import { createFreshSession } from './helpers/session' test.describe('Settings Pages (Real API)', () => { + test.use({ baseURL: 'http://127.0.0.1:3000' }) + test.beforeEach(async ({ page }, testInfo) => { await setEnglishLocale(page) - await registerSession(page, testInfo) + await createFreshSession(page, testInfo) }) test('opens profile settings page', async ({ page }) => { diff --git a/web/e2e/settings-security-capability.spec.ts b/web/e2e/settings-security-capability.spec.ts index 30d94ea8..b2d45910 100644 --- a/web/e2e/settings-security-capability.spec.ts +++ b/web/e2e/settings-security-capability.spec.ts @@ -1,93 +1,50 @@ import { expect, test, type Page } from '@playwright/test' import { setEnglishLocale } from './helpers/auth-fixtures' +import { csrfHeaders } from './helpers/csrf' +import { loginWithCredentials } from './helpers/session' -interface MockSessionUser { - userId: string - displayName: string - email: string - avatarUrl: string - oauthProvider: string - canChangePassword: boolean - platformRoles: string[] +function getOptionalEnv(name: string): string | undefined { + const value = process.env[name]?.trim() + return value ? value : undefined } -function apiEnvelope(data: unknown) { +function adminCredentials() { return { - code: 0, - msg: 'OK', - data, - timestamp: new Date().toISOString(), - requestId: 'e2e-security-capability', + username: getOptionalEnv('E2E_ADMIN_USERNAME') ?? getOptionalEnv('BOOTSTRAP_ADMIN_USERNAME') ?? 'admin', + password: getOptionalEnv('E2E_ADMIN_PASSWORD') ?? getOptionalEnv('BOOTSTRAP_ADMIN_PASSWORD') ?? 'ChangeMe!2026', } } -async function mockSession(page: Page, user: MockSessionUser) { - await page.route('**/api/v1/auth/me', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify(apiEnvelope(user)), - }) - }) - - await page.route('**/api/web/me/namespaces', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify(apiEnvelope([])), - }) - }) - - await page.route('**/api/web/notifications/unread-count', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify(apiEnvelope({ count: 0 })), - }) - }) - - await page.route('**/api/web/notifications/sse', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'text/event-stream', - body: ': ok\n\n', - }) - }) +async function currentDisplayName(page: Page, headers?: Record): Promise { + const response = await page.context().request.get('/api/v1/auth/me', { headers }) + expect(response.ok()).toBeTruthy() + const body = await response.json() as { data: { displayName: string } } + return body.data.displayName } -test.describe('Security Settings capability', () => { - test('shows the security menu entry and password form for local admin accounts', async ({ page }) => { +test.describe('Security Settings capability (Real API)', () => { + test.use({ baseURL: 'http://127.0.0.1:3000' }) + + test('shows the security menu entry and password form for local admin accounts', async ({ page }, testInfo) => { await setEnglishLocale(page) - await mockSession(page, { - userId: 'local-admin', - displayName: 'Local Admin', - email: 'local-admin@example.test', - avatarUrl: '', - oauthProvider: '', - canChangePassword: true, - platformRoles: ['USER', 'SUPER_ADMIN'], - }) + await loginWithCredentials(page, adminCredentials(), testInfo) + const displayName = await currentDisplayName(page) await page.goto('/settings/security') await expect(page.getByRole('heading', { name: 'Security Settings' })).toBeVisible() await expect(page.getByLabel('Current Password')).toBeVisible() await expect(page.getByLabel('New Password')).toBeVisible() - await page.getByRole('button', { name: 'Local Admin' }).click() + await page.getByRole('button', { name: displayName }).click() await expect(page.getByRole('link', { name: 'Security Settings' })).toBeVisible() }) - test('hides the security menu entry and form when password changes are unavailable', async ({ page }) => { + test('hides the security menu entry and rejects password changes without a local credential', async ({ page }) => { await setEnglishLocale(page) - await mockSession(page, { - userId: 'oauth-only-user', - displayName: 'OAuth Only User', - email: 'oauth-only@example.test', - avatarUrl: '', - oauthProvider: 'github', - canChangePassword: false, - platformRoles: ['USER'], + await page.context().setExtraHTTPHeaders({ + 'X-Mock-User-Id': 'local-user', }) + const displayName = await currentDisplayName(page, { 'X-Mock-User-Id': 'local-user' }) await page.goto('/settings/security') @@ -96,7 +53,16 @@ test.describe('Security Settings capability', () => { await expect(page.getByLabel('Current Password')).toHaveCount(0) await expect(page.getByRole('button', { name: 'Update Password' })).toHaveCount(0) - await page.getByRole('button', { name: 'OAuth Only User' }).click() + await page.getByRole('button', { name: displayName }).click() await expect(page.getByRole('link', { name: 'Security Settings' })).toHaveCount(0) + + const response = await page.context().request.post('/api/v1/auth/local/change-password', { + data: { + currentPassword: 'Passw0rd!123', + newPassword: 'N3wPassw0rd!123', + }, + headers: await csrfHeaders(page, { 'X-Mock-User-Id': 'local-user' }), + }) + expect(response.status()).toBe(400) }) }) From 636f1edac23d8c457805739b51541e17d9e9fa9b Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Tue, 23 Jun 2026 13:54:33 +0800 Subject: [PATCH 29/81] docs(auth): align auth me example with #541 Signed-off-by: dongmucat <1127093059@qq.com> --- docs/03-authentication-design.md | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/docs/03-authentication-design.md b/docs/03-authentication-design.md index 7a7e46d6..b4fb0cd1 100644 --- a/docs/03-authentication-design.md +++ b/docs/03-authentication-design.md @@ -485,23 +485,20 @@ Session 中存储以下字段: "code": 0, "msg": "获取成功", "data": { - "userId": 42, + "userId": "usr_42", "displayName": "zhangsan", "email": "zhangsan@company.com", "avatarUrl": "https://...", - "oauthProvider": "github", - "platformRoles": ["SKILL_ADMIN", "AUDITOR"], - "namespaces": [ - { "slug": "ai-team", "role": "ADMIN" }, - { "slug": "global", "role": "MEMBER" } - ] + "oauthProvider": "local", + "canChangePassword": true, + "platformRoles": ["SKILL_ADMIN", "AUDITOR"] }, "timestamp": "2026-03-12T06:00:00Z", "requestId": "req-123" } ``` -前端权限判定基于 `platformRoles` + `namespaces[].role`,后端通过 `role_permission` 表查询权限码。 +前端平台级权限判定基于 `platformRoles`;是否展示修改密码入口和表单基于后端返回的 `canChangePassword`。后端通过 `role_permission` 表查询权限码。 统一约束: - `/api/v1/auth/me`、`/api/v1/auth/providers` 等 JSON 响应必须统一使用 `code/msg/data/timestamp/requestId` 外层结构。 From e501be9cf865f6750eab33382ddf7a044c290e41 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Tue, 23 Jun 2026 17:55:24 +0800 Subject: [PATCH 30/81] feat(promotion): improve promotion review dashboard Signed-off-by: dongmucat <1127093059@qq.com> --- .../portal/PromotionController.java | 14 +- .../skillhub/dto/PromotionResponseDto.java | 6 + .../JpaGovernanceQueryRepository.java | 6 + .../service/GovernanceWorkflowAppService.java | 9 +- .../service/PromotionPortalAppService.java | 87 ++++- .../src/main/resources/messages.properties | 4 + .../src/main/resources/messages_zh.properties | 4 + .../PromotionPortalControllerTest.java | 212 ++++++++++ .../PromotionApprovalFlowIntegrationTest.java | 85 ++++ .../PromotionPortalAppServiceTest.java | 6 + .../review/PromotionRequestRepository.java | 2 + .../jpa/PromotionRequestJpaRepository.java | 30 +- web/e2e/promotions-review.spec.ts | 365 ++++++++++++++++++ web/playwright.config.ts | 14 +- web/src/api/client.ts | 11 +- web/src/api/generated/schema.d.ts | 18 +- web/src/api/types.ts | 24 +- .../promotion/use-promotion-list.test.ts | 138 +++++-- .../features/promotion/use-promotion-list.ts | 27 +- web/src/i18n/locales/en.json | 18 +- web/src/i18n/locales/zh.json | 18 +- web/src/pages/dashboard/promotions.test.ts | 53 --- web/src/pages/dashboard/promotions.test.tsx | 225 +++++++++++ web/src/pages/dashboard/promotions.tsx | 295 +++++++++++--- 24 files changed, 1517 insertions(+), 154 deletions(-) create mode 100644 web/e2e/promotions-review.spec.ts delete mode 100644 web/src/pages/dashboard/promotions.test.ts create mode 100644 web/src/pages/dashboard/promotions.test.tsx diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/PromotionController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/PromotionController.java index 1fb9ba5a..7b4b1a44 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/PromotionController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/PromotionController.java @@ -10,6 +10,8 @@ import com.iflytek.skillhub.dto.PromotionRequestDto; import com.iflytek.skillhub.dto.PromotionResponseDto; import com.iflytek.skillhub.service.AuditRequestContext; import com.iflytek.skillhub.service.GovernanceWorkflowAppService; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.servlet.http.HttpServletRequest; import java.util.Map; import org.springframework.web.bind.annotation.GetMapping; @@ -79,11 +81,19 @@ public class PromotionController extends BaseApiController { } @GetMapping - public ApiResponse> listPromotions(@RequestParam(defaultValue = "PENDING") String status, + public ApiResponse> listPromotions(@Parameter(schema = @Schema(allowableValues = {"PENDING", "APPROVED", "REJECTED"}, defaultValue = "PENDING")) + @RequestParam(required = false) String status, @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, + @Parameter(schema = @Schema(allowableValues = {"reviewedAt"})) + @RequestParam(required = false) String sortBy, + @Parameter(schema = @Schema(allowableValues = {"ASC", "DESC"}, defaultValue = "DESC")) + @RequestParam(required = false) String sortDirection, @RequestAttribute("userId") String userId) { - return ok("response.success.read", governanceWorkflowAppService.listPromotions(status, page, size, userId)); + return ok( + "response.success.read", + governanceWorkflowAppService.listPromotions(status, page, size, sortBy, sortDirection, userId) + ); } @GetMapping("/pending") diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PromotionResponseDto.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PromotionResponseDto.java index 888f447f..62c0d535 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PromotionResponseDto.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PromotionResponseDto.java @@ -5,9 +5,15 @@ import java.time.Instant; public record PromotionResponseDto( Long id, Long sourceSkillId, + String sourceSkillDisplayName, + String sourceSkillSummary, String sourceNamespace, String sourceSkillSlug, String sourceVersion, + Integer sourceVersionFileCount, + Long sourceVersionTotalSize, + Long sourceSkillDownloadCount, + Integer sourceSkillStarCount, String targetNamespace, Long targetSkillId, String status, diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaGovernanceQueryRepository.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaGovernanceQueryRepository.java index 7fb2aec1..646f032e 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaGovernanceQueryRepository.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaGovernanceQueryRepository.java @@ -200,9 +200,15 @@ public class JpaGovernanceQueryRepository implements GovernanceQueryRepository { return new PromotionResponseDto( request.getId(), request.getSourceSkillId(), + skill.getDisplayName() != null ? skill.getDisplayName() : skill.getSlug(), + skill.getSummary(), sourceNamespace.getSlug(), skill.getSlug(), version.getVersion(), + version.getFileCount(), + version.getTotalSize(), + skill.getDownloadCount(), + skill.getStarCount(), targetNamespace.getSlug(), request.getTargetSkillId(), request.getStatus().name(), diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/GovernanceWorkflowAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/GovernanceWorkflowAppService.java index 6cca3952..9aac59d9 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/GovernanceWorkflowAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/GovernanceWorkflowAppService.java @@ -165,8 +165,13 @@ public class GovernanceWorkflowAppService { return promotionPortalAppService.rejectPromotion(promotionId, comment, userId, auditContext); } - public PageResponse listPromotions(String status, int page, int size, String userId) { - return promotionPortalAppService.listPromotions(status, page, size, userId); + public PageResponse listPromotions(String status, + int page, + int size, + String sortBy, + String sortDirection, + String userId) { + return promotionPortalAppService.listPromotions(status, page, size, sortBy, sortDirection, userId); } public PageResponse listPendingPromotions(int page, int size, String userId) { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/PromotionPortalAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/PromotionPortalAppService.java index ef4b1888..aab28382 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/PromotionPortalAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/PromotionPortalAppService.java @@ -7,17 +7,21 @@ import com.iflytek.skillhub.domain.review.PromotionRequest; import com.iflytek.skillhub.domain.review.PromotionRequestRepository; import com.iflytek.skillhub.domain.review.PromotionService; import com.iflytek.skillhub.domain.review.ReviewTaskStatus; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; import com.iflytek.skillhub.dto.PageResponse; import com.iflytek.skillhub.dto.PromotionResponseDto; import com.iflytek.skillhub.repository.GovernanceQueryRepository; +import java.util.Locale; import java.util.Map; import java.util.Set; import org.slf4j.MDC; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; @Service @@ -98,10 +102,12 @@ public class PromotionPortalAppService { public PageResponse listPromotions(String status, int page, int size, + String sortBy, + String sortDirection, String userId) { requirePromotionAdmin(userId); - ReviewTaskStatus reviewStatus = ReviewTaskStatus.valueOf(status.toUpperCase()); - Page requests = promotionRequestRepository.findByStatus(reviewStatus, PageRequest.of(page, size)); + ReviewTaskStatus reviewStatus = parsePromotionStatus(status); + Page requests = findPromotionRequests(reviewStatus, page, size, sortBy, sortDirection); return PageResponse.from(new PageImpl<>( governanceQueryRepository.getPromotionResponses(requests.getContent()), requests.getPageable(), @@ -112,7 +118,16 @@ public class PromotionPortalAppService { public PageResponse listPendingPromotions(int page, int size, String userId) { requirePromotionAdmin(userId); Page requests = promotionRequestRepository.findByStatus( - ReviewTaskStatus.PENDING, PageRequest.of(page, size)); + ReviewTaskStatus.PENDING, + PageRequest.of( + page, + size, + Sort.by( + new Sort.Order(Sort.Direction.DESC, "submittedAt"), + new Sort.Order(Sort.Direction.DESC, "id") + ) + ) + ); return PageResponse.from(new PageImpl<>( governanceQueryRepository.getPromotionResponses(requests.getContent()), requests.getPageable(), @@ -129,6 +144,72 @@ public class PromotionPortalAppService { return governanceQueryRepository.getPromotionResponse(promotion); } + private ReviewTaskStatus parsePromotionStatus(String status) { + if (status == null) { + return ReviewTaskStatus.PENDING; + } + if (status.isBlank()) { + throw new DomainBadRequestException("promotion.status.invalid", status); + } + try { + ReviewTaskStatus parsed = ReviewTaskStatus.valueOf(status.toUpperCase(Locale.ROOT)); + return switch (parsed) { + case PENDING, APPROVED, REJECTED -> parsed; + default -> throw new DomainBadRequestException("promotion.status.invalid", status); + }; + } catch (IllegalArgumentException ex) { + throw new DomainBadRequestException("promotion.status.invalid", status); + } + } + + private Page findPromotionRequests(ReviewTaskStatus status, + int page, + int size, + String sortBy, + String sortDirection) { + if (status == ReviewTaskStatus.PENDING) { + if (sortBy != null || sortDirection != null) { + throw new DomainBadRequestException("promotion.sort.pending_unsupported"); + } + return promotionRequestRepository.findByStatus( + status, + PageRequest.of( + page, + size, + Sort.by( + new Sort.Order(Sort.Direction.DESC, "submittedAt"), + new Sort.Order(Sort.Direction.DESC, "id") + ) + ) + ); + } + + if (sortBy != null && (sortBy.isBlank() || !"reviewedAt".equals(sortBy))) { + throw new DomainBadRequestException("promotion.sort.field.invalid", sortBy); + } + + Sort.Direction direction = parsePromotionSortDirection(sortDirection); + Pageable pageable = PageRequest.of(page, size); + if (direction == Sort.Direction.ASC) { + return promotionRequestRepository.findHistoryByStatusOrderByReviewedAtAsc(status, pageable); + } + return promotionRequestRepository.findHistoryByStatusOrderByReviewedAtDesc(status, pageable); + } + + private Sort.Direction parsePromotionSortDirection(String sortDirection) { + if (sortDirection == null) { + return Sort.Direction.DESC; + } + if (sortDirection.isBlank()) { + throw new DomainBadRequestException("promotion.sort.direction.invalid", sortDirection); + } + try { + return Sort.Direction.valueOf(sortDirection.toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException ex) { + throw new DomainBadRequestException("promotion.sort.direction.invalid", sortDirection); + } + } + private void requirePromotionAdmin(String userId) { Set platformRoles = platformRoles(userId); if (!platformRoles.contains("SKILL_ADMIN") && !platformRoles.contains("SUPER_ADMIN")) { diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index 8f7cf1df..25a63127 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -174,3 +174,7 @@ validation.auth.password.reset.code.notBlank=Verification code cannot be blank validation.auth.password.reset.code.invalid=Verification code must be 6 digits validation.auth.password.reset.newPassword.notBlank=New password cannot be blank promotion.target_skill_conflict=The target global skill "{0}" already exists +promotion.status.invalid=Unsupported promotion status: {0} +promotion.sort.field.invalid=Unsupported promotion sort field: {0} +promotion.sort.direction.invalid=Unsupported promotion sort direction: {0} +promotion.sort.pending_unsupported=Pending promotion requests do not support reviewed-time sorting diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index e608d247..99183ae5 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -174,3 +174,7 @@ validation.auth.password.reset.code.notBlank=验证码不能为空 validation.auth.password.reset.code.invalid=验证码必须为 6 位数字 validation.auth.password.reset.newPassword.notBlank=新密码不能为空 promotion.target_skill_conflict=目标全局技能“{0}”已存在 +promotion.status.invalid=不支持的提升审核状态:{0} +promotion.sort.field.invalid=不支持的提升审核排序字段:{0} +promotion.sort.direction.invalid=不支持的提升审核排序方向:{0} +promotion.sort.pending_unsupported=待审核提升请求不支持按处理时间排序 diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/PromotionPortalControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/PromotionPortalControllerTest.java index 9369b562..05aeecf0 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/PromotionPortalControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/PromotionPortalControllerTest.java @@ -20,6 +20,9 @@ import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMock import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.http.MediaType; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.test.context.ActiveProfiles; @@ -108,6 +111,179 @@ class PromotionPortalControllerTest { verify(promotionRequestRepository, never()).findByStatus(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any()); } + @Test + void listPromotions_defaultsToPendingWithStableSubmittedSort() throws Exception { + PromotionRequest request = createPromotionRequest(1L, "user-1"); + stubNamespaceRoles("admin", List.of()); + given(rbacService.getUserRoleCodes("admin")).willReturn(Set.of("SKILL_ADMIN")); + PageRequest pageable = PageRequest.of( + 0, + 20, + Sort.by( + new Sort.Order(Sort.Direction.DESC, "submittedAt"), + new Sort.Order(Sort.Direction.DESC, "id") + ) + ); + given(promotionRequestRepository.findByStatus(ReviewTaskStatus.PENDING, pageable)) + .willReturn(new PageImpl<>(List.of(request), pageable, 1)); + stubPromotionListResponse(List.of(request)); + + mockMvc.perform(get("/api/v1/promotions").with(auth("admin"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.items[0].id").value(1L)) + .andExpect(jsonPath("$.data.total").value(1)); + + verify(promotionRequestRepository).findByStatus(ReviewTaskStatus.PENDING, pageable); + } + + @Test + void listPromotions_sortsApprovedHistoryByReviewedAtDescendingByDefault() throws Exception { + PromotionRequest request = createPromotionRequest(1L, "user-1"); + stubNamespaceRoles("admin", List.of()); + given(rbacService.getUserRoleCodes("admin")).willReturn(Set.of("SKILL_ADMIN")); + PageRequest pageable = PageRequest.of(1, 5); + given(promotionRequestRepository.findHistoryByStatusOrderByReviewedAtDesc(ReviewTaskStatus.APPROVED, pageable)) + .willReturn(new PageImpl<>(List.of(request), pageable, 1)); + stubPromotionListResponse(List.of(request)); + + mockMvc.perform(get("/api/web/promotions") + .param("status", "APPROVED") + .param("page", "1") + .param("size", "5") + .param("sortBy", "reviewedAt") + .with(auth("admin"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + + verify(promotionRequestRepository).findHistoryByStatusOrderByReviewedAtDesc(ReviewTaskStatus.APPROVED, pageable); + } + + @Test + void listPromotions_sortsRejectedHistoryByReviewedAtAscending() throws Exception { + PromotionRequest request = createPromotionRequest(1L, "user-1"); + stubNamespaceRoles("admin", List.of()); + given(rbacService.getUserRoleCodes("admin")).willReturn(Set.of("SUPER_ADMIN")); + PageRequest pageable = PageRequest.of(0, 10); + given(promotionRequestRepository.findHistoryByStatusOrderByReviewedAtAsc(ReviewTaskStatus.REJECTED, pageable)) + .willReturn(new PageImpl<>(List.of(request), pageable, 1)); + stubPromotionListResponse(List.of(request)); + + mockMvc.perform(get("/api/web/promotions") + .param("status", "REJECTED") + .param("page", "0") + .param("size", "10") + .param("sortBy", "reviewedAt") + .param("sortDirection", "ASC") + .with(auth("admin"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + + verify(promotionRequestRepository).findHistoryByStatusOrderByReviewedAtAsc(ReviewTaskStatus.REJECTED, pageable); + } + + @Test + void listPromotions_rejectsInvalidStatus() throws Exception { + stubNamespaceRoles("admin", List.of()); + given(rbacService.getUserRoleCodes("admin")).willReturn(Set.of("SKILL_ADMIN")); + + mockMvc.perform(get("/api/v1/promotions") + .param("status", "DONE") + .header("Accept-Language", "en") + .locale(java.util.Locale.ENGLISH) + .with(auth("admin"))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.msg").value(org.hamcrest.Matchers.containsString("DONE"))); + } + + @Test + void listPromotions_rejectsBlankStatus() throws Exception { + stubNamespaceRoles("admin", List.of()); + given(rbacService.getUserRoleCodes("admin")).willReturn(Set.of("SKILL_ADMIN")); + + mockMvc.perform(get("/api/v1/promotions") + .param("status", "") + .header("Accept-Language", "en") + .locale(java.util.Locale.ENGLISH) + .with(auth("admin"))) + .andExpect(status().isBadRequest()); + } + + @Test + void listPromotions_rejectsPendingSortFieldEvenWhenBlank() throws Exception { + stubNamespaceRoles("admin", List.of()); + given(rbacService.getUserRoleCodes("admin")).willReturn(Set.of("SKILL_ADMIN")); + + mockMvc.perform(get("/api/v1/promotions") + .param("status", "PENDING") + .param("sortBy", "") + .header("Accept-Language", "en") + .locale(java.util.Locale.ENGLISH) + .with(auth("admin"))) + .andExpect(status().isBadRequest()); + } + + @Test + void listPromotions_rejectsPendingSortDirectionEvenWhenBlank() throws Exception { + stubNamespaceRoles("admin", List.of()); + given(rbacService.getUserRoleCodes("admin")).willReturn(Set.of("SKILL_ADMIN")); + + mockMvc.perform(get("/api/v1/promotions") + .param("status", "PENDING") + .param("sortDirection", "") + .header("Accept-Language", "en") + .locale(java.util.Locale.ENGLISH) + .with(auth("admin"))) + .andExpect(status().isBadRequest()); + } + + @Test + void listPromotions_rejectsInvalidHistorySortField() throws Exception { + stubNamespaceRoles("admin", List.of()); + given(rbacService.getUserRoleCodes("admin")).willReturn(Set.of("SKILL_ADMIN")); + + mockMvc.perform(get("/api/web/promotions") + .param("status", "APPROVED") + .param("sortBy", "submittedAt") + .param("sortDirection", "DESC") + .header("Accept-Language", "en") + .locale(java.util.Locale.ENGLISH) + .with(auth("admin"))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.msg").value(org.hamcrest.Matchers.containsString("submittedAt"))); + } + + @Test + void listPromotions_rejectsInvalidHistorySortDirection() throws Exception { + stubNamespaceRoles("admin", List.of()); + given(rbacService.getUserRoleCodes("admin")).willReturn(Set.of("SKILL_ADMIN")); + + mockMvc.perform(get("/api/web/promotions") + .param("status", "APPROVED") + .param("sortBy", "reviewedAt") + .param("sortDirection", "SIDEWAYS") + .header("Accept-Language", "en") + .locale(java.util.Locale.ENGLISH) + .with(auth("admin"))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.msg").value(org.hamcrest.Matchers.containsString("SIDEWAYS"))); + } + + @Test + void listPromotions_rejectsBlankHistorySortDirection() throws Exception { + stubNamespaceRoles("admin", List.of()); + given(rbacService.getUserRoleCodes("admin")).willReturn(Set.of("SKILL_ADMIN")); + + mockMvc.perform(get("/api/web/promotions") + .param("status", "APPROVED") + .param("sortBy", "reviewedAt") + .param("sortDirection", "") + .header("Accept-Language", "en") + .locale(java.util.Locale.ENGLISH) + .with(auth("admin"))) + .andExpect(status().isBadRequest()); + } + @Test void getPromotionDetail_allowsSubmitter() throws Exception { PromotionRequest request = createPromotionRequest(1L, "user-1"); @@ -140,9 +316,15 @@ class PromotionPortalControllerTest { given(governanceQueryRepository.getPromotionResponse(request)).willReturn(new PromotionResponseDto( request.getId(), request.getSourceSkillId(), + "Skill A", + "Skill A summary", "team-a", "skill-a", "1.0.0", + 3, + 2048L, + 7L, + 2, "global", request.getTargetSkillId(), request.getStatus().name(), @@ -156,6 +338,36 @@ class PromotionPortalControllerTest { )); } + private void stubPromotionListResponse(List requests) { + given(governanceQueryRepository.getPromotionResponses(requests)).willReturn( + requests.stream() + .map(request -> new PromotionResponseDto( + request.getId(), + request.getSourceSkillId(), + "Skill A", + "Skill A summary", + "team-a", + "skill-a", + "1.0.0", + 3, + 2048L, + 7L, + 2, + "global", + request.getTargetSkillId(), + request.getStatus().name(), + request.getSubmittedBy(), + "Submitter", + request.getReviewedBy(), + null, + request.getReviewComment(), + request.getSubmittedAt(), + request.getReviewedAt() + )) + .toList() + ); + } + private void stubNamespaceRoles(String userId, List members) { given(namespaceMemberRepository.findByUserId(userId)).willReturn(members); } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/PromotionApprovalFlowIntegrationTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/PromotionApprovalFlowIntegrationTest.java index a2268a67..aaa74655 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/PromotionApprovalFlowIntegrationTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/PromotionApprovalFlowIntegrationTest.java @@ -36,6 +36,7 @@ import org.springframework.security.authentication.UsernamePasswordAuthenticatio import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; @@ -44,6 +45,7 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -106,6 +108,12 @@ class PromotionApprovalFlowIntegrationTest { .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.data.id").value(graph.request().getId())) + .andExpect(jsonPath("$.data.sourceSkillDisplayName").value(org.hamcrest.Matchers.startsWith("Promote Skill"))) + .andExpect(jsonPath("$.data.sourceSkillSummary").value("Used to verify promotion approval flow.")) + .andExpect(jsonPath("$.data.sourceVersionFileCount").value(0)) + .andExpect(jsonPath("$.data.sourceVersionTotalSize").value(0)) + .andExpect(jsonPath("$.data.sourceSkillDownloadCount").value(0)) + .andExpect(jsonPath("$.data.sourceSkillStarCount").value(0)) .andExpect(jsonPath("$.data.status").value("APPROVED")) .andExpect(jsonPath("$.data.reviewedBy").value(REVIEWER_ID)) .andExpect(jsonPath("$.data.reviewComment").value("ship it")); @@ -187,6 +195,75 @@ class PromotionApprovalFlowIntegrationTest { assertThat(savedRequest.getTargetSkillId()).isNull(); } + @Test + @Transactional + void listPromotions_sortsApprovedAndRejectedHistoryByReviewedAtWithNullsLastAndTieBreaker() throws Exception { + when(rbacService.getUserRoleCodes(REVIEWER_ID)).thenReturn(Set.of("SUPER_ADMIN")); + + assertHistorySortForStatus(ReviewTaskStatus.APPROVED, "APPROVED"); + assertHistorySortForStatus(ReviewTaskStatus.REJECTED, "REJECTED"); + } + + private void assertHistorySortForStatus(ReviewTaskStatus reviewStatus, String statusParam) throws Exception { + promotionRequestRepository.deleteAll(); + promotionRequestRepository.flush(); + + PromotionGraph latest = createPromotionGraph(); + PromotionGraph sameTimeOlderId = createPromotionGraph(); + PromotionGraph sameTimeNewerId = createPromotionGraph(); + PromotionGraph legacyNullReviewedAt = createPromotionGraph(); + + Instant sameReviewedAt = Instant.parse("2026-06-18T08:00:00Z"); + markPromotionHistory(latest.request(), reviewStatus, Instant.parse("2026-06-18T09:00:00Z")); + markPromotionHistory(sameTimeOlderId.request(), reviewStatus, sameReviewedAt); + markPromotionHistory(sameTimeNewerId.request(), reviewStatus, sameReviewedAt); + markPromotionHistory(legacyNullReviewedAt.request(), reviewStatus, null); + + mockMvc.perform(get("/api/web/promotions") + .param("status", statusParam) + .param("page", "0") + .param("size", "2") + .param("sortBy", "reviewedAt") + .param("sortDirection", "DESC") + .with(authentication(portalAuth(REVIEWER_ID, "SUPER_ADMIN")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items[0].id").value(latest.request().getId())) + .andExpect(jsonPath("$.data.items[1].id").value(sameTimeNewerId.request().getId())); + + mockMvc.perform(get("/api/web/promotions") + .param("status", statusParam) + .param("page", "1") + .param("size", "2") + .param("sortBy", "reviewedAt") + .param("sortDirection", "DESC") + .with(authentication(portalAuth(REVIEWER_ID, "SUPER_ADMIN")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items[0].id").value(sameTimeOlderId.request().getId())) + .andExpect(jsonPath("$.data.items[1].id").value(legacyNullReviewedAt.request().getId())); + + mockMvc.perform(get("/api/web/promotions") + .param("status", statusParam) + .param("page", "0") + .param("size", "2") + .param("sortBy", "reviewedAt") + .param("sortDirection", "ASC") + .with(authentication(portalAuth(REVIEWER_ID, "SUPER_ADMIN")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items[0].id").value(sameTimeOlderId.request().getId())) + .andExpect(jsonPath("$.data.items[1].id").value(sameTimeNewerId.request().getId())); + + mockMvc.perform(get("/api/web/promotions") + .param("status", statusParam) + .param("page", "1") + .param("size", "2") + .param("sortBy", "reviewedAt") + .param("sortDirection", "ASC") + .with(authentication(portalAuth(REVIEWER_ID, "SUPER_ADMIN")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items[0].id").value(latest.request().getId())) + .andExpect(jsonPath("$.data.items[1].id").value(legacyNullReviewedAt.request().getId())); + } + private PromotionGraph createPromotionGraph() { return createPromotionGraph(SUBMITTER_ID); } @@ -236,6 +313,14 @@ class PromotionApprovalFlowIntegrationTest { } } + private void markPromotionHistory(PromotionRequest request, ReviewTaskStatus status, Instant reviewedAt) { + request.setStatus(status); + request.setReviewedBy(REVIEWER_ID); + request.setReviewComment(status == ReviewTaskStatus.APPROVED ? "approved" : "rejected"); + request.setReviewedAt(reviewedAt); + promotionRequestRepository.saveAndFlush(request); + } + private UsernamePasswordAuthenticationToken portalAuth(String userId, String... roles) { PlatformPrincipal principal = new PlatformPrincipal( userId, diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/PromotionPortalAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/PromotionPortalAppServiceTest.java index abede8fc..bc824b89 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/PromotionPortalAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/PromotionPortalAppServiceTest.java @@ -136,9 +136,15 @@ class PromotionPortalAppServiceTest { return new PromotionResponseDto( request.getId(), request.getSourceSkillId(), + "Skill A", + "Skill A summary", "team-a", "skill-a", "1.0.0", + 3, + 2048L, + 7L, + 2, "global", request.getTargetSkillId(), request.getStatus().name(), diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionRequestRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionRequestRepository.java index 05d07f04..8bfdf245 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionRequestRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionRequestRepository.java @@ -14,6 +14,8 @@ public interface PromotionRequestRepository { Optional findBySourceVersionIdAndStatus(Long sourceVersionId, ReviewTaskStatus status); Optional findBySourceSkillIdAndStatus(Long sourceSkillId, ReviewTaskStatus status); Page findByStatus(ReviewTaskStatus status, Pageable pageable); + Page findHistoryByStatusOrderByReviewedAtAsc(ReviewTaskStatus status, Pageable pageable); + Page findHistoryByStatusOrderByReviewedAtDesc(ReviewTaskStatus status, Pageable pageable); boolean existsByTargetNamespaceId(Long namespaceId); void deleteBySourceSkillIdOrTargetSkillId(Long sourceSkillId, Long targetSkillId); int updateStatusWithVersion(Long id, ReviewTaskStatus status, String reviewedBy, diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/PromotionRequestJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/PromotionRequestJpaRepository.java index c26f611e..93e61a9f 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/PromotionRequestJpaRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/PromotionRequestJpaRepository.java @@ -3,6 +3,7 @@ package com.iflytek.skillhub.infra.jpa; import com.iflytek.skillhub.domain.review.PromotionRequest; import com.iflytek.skillhub.domain.review.PromotionRequestRepository; import com.iflytek.skillhub.domain.review.ReviewTaskStatus; +import java.util.Optional; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; @@ -10,7 +11,6 @@ import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; -import java.util.Optional; /** * JPA-backed repository for promotion requests, including optimistic status updates. @@ -25,6 +25,34 @@ public interface PromotionRequestJpaRepository extends JpaRepository findByStatus(ReviewTaskStatus status, Pageable pageable); + @Query( + value = """ + SELECT p + FROM PromotionRequest p + WHERE p.status = :status + ORDER BY CASE WHEN p.reviewedAt IS NULL THEN 1 ELSE 0 END ASC, + p.reviewedAt ASC, + p.id ASC + """, + countQuery = "SELECT COUNT(p) FROM PromotionRequest p WHERE p.status = :status" + ) + Page findHistoryByStatusOrderByReviewedAtAsc(@Param("status") ReviewTaskStatus status, + Pageable pageable); + + @Query( + value = """ + SELECT p + FROM PromotionRequest p + WHERE p.status = :status + ORDER BY CASE WHEN p.reviewedAt IS NULL THEN 1 ELSE 0 END ASC, + p.reviewedAt DESC, + p.id DESC + """, + countQuery = "SELECT COUNT(p) FROM PromotionRequest p WHERE p.status = :status" + ) + Page findHistoryByStatusOrderByReviewedAtDesc(@Param("status") ReviewTaskStatus status, + Pageable pageable); + boolean existsByTargetNamespaceId(Long targetNamespaceId); void deleteBySourceSkillIdOrTargetSkillId(Long sourceSkillId, Long targetSkillId); diff --git a/web/e2e/promotions-review.spec.ts b/web/e2e/promotions-review.spec.ts new file mode 100644 index 00000000..af7d98a2 --- /dev/null +++ b/web/e2e/promotions-review.spec.ts @@ -0,0 +1,365 @@ +import { expect, test, type Page } from '@playwright/test' +import { setEnglishLocale } from './helpers/auth-fixtures' + +type PromotionStatus = 'PENDING' | 'APPROVED' | 'REJECTED' + +function promotion(id: number, status: PromotionStatus, name: string, reviewedAt: string | null = null) { + return { + id, + sourceSkillId: id + 100, + sourceSkillDisplayName: name, + sourceSkillSummary: `Summary for ${name}`, + sourceNamespace: 'team-ai', + sourceSkillSlug: name.toLowerCase().replaceAll(' ', '-'), + sourceVersion: '1.3.0', + sourceVersionFileCount: 23, + sourceVersionTotalSize: 1_843_200, + sourceSkillDownloadCount: 18, + sourceSkillStarCount: 5, + targetNamespace: 'global', + targetSkillId: status === 'PENDING' ? undefined : id + 200, + status, + submittedBy: 'owner-1', + submittedByName: 'Owner One', + reviewedBy: status === 'PENDING' ? undefined : 'admin-1', + reviewedByName: status === 'PENDING' ? undefined : 'Admin One', + reviewComment: status === 'REJECTED' ? 'Needs clearer documentation before promotion.' : 'Looks good.', + submittedAt: '2026-06-18T12:00:00Z', + reviewedAt, + } +} + +test.describe('Promotion review dashboard', () => { + let unexpectedPromotionRequests: string[] + let expectedPromotionRequests: string[] + + test.beforeEach(async ({ page }) => { + unexpectedPromotionRequests = [] + expectedPromotionRequests = [] + await setEnglishLocale(page) + await page.context().setExtraHTTPHeaders({ + 'X-Mock-User-Id': 'local-admin', + }) + + await page.route('**/api/v1/auth/me', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + code: 0, + msg: 'success', + data: { + userId: 'local-admin', + displayName: 'Local Admin', + email: 'local-admin@example.com', + avatarUrl: '', + oauthProvider: 'mock', + platformRoles: ['SUPER_ADMIN'], + }, + timestamp: new Date().toISOString(), + requestId: 'e2e-auth', + }), + }) + }) + await page.route('**/api/web/notifications/unread-count', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + code: 0, + msg: 'success', + data: { count: 0 }, + timestamp: new Date().toISOString(), + requestId: 'e2e-notifications', + }), + }) + }) + await page.route('**/api/web/me/namespaces', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + code: 0, + msg: 'success', + data: [], + timestamp: new Date().toISOString(), + requestId: 'e2e-namespaces', + }), + }) + }) + await page.route('**/api/web/notifications/sse', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'text/event-stream', + body: '', + }) + }) + }) + + async function installPromotionRouteMock(page: Page, expectedSignatures: string[]) { + expectedPromotionRequests = [...expectedSignatures] + + await page.route('**/api/web/promotions**', async (route) => { + const request = route.request() + const url = new URL(request.url()) + const allowedParams = new Set(['status', 'page', 'size', 'sortBy', 'sortDirection']) + const extraParams = Array.from(url.searchParams.keys()).filter((key) => !allowedParams.has(key)) + if (request.method() !== 'GET' || url.pathname !== '/api/web/promotions' || extraParams.length > 0) { + unexpectedPromotionRequests.push(url.toString()) + await route.fulfill({ + status: 400, + contentType: 'application/json', + body: JSON.stringify({ + code: 400, + msg: 'unexpected promotion request shape', + data: null, + timestamp: new Date().toISOString(), + requestId: 'e2e-promotions-error', + }), + }) + return + } + + const statusParam = url.searchParams.get('status') + if (statusParam === null) { + unexpectedPromotionRequests.push(url.toString()) + await route.fulfill({ + status: 400, + contentType: 'application/json', + body: JSON.stringify({ + code: 400, + msg: 'promotion request must include explicit status', + data: null, + timestamp: new Date().toISOString(), + requestId: 'e2e-promotions-error', + }), + }) + return + } + + const statusValues: PromotionStatus[] = ['PENDING', 'APPROVED', 'REJECTED'] + if (!statusValues.includes(statusParam as PromotionStatus)) { + unexpectedPromotionRequests.push(url.toString()) + await route.fulfill({ + status: 400, + contentType: 'application/json', + body: JSON.stringify({ + code: 400, + msg: `unexpected status ${statusParam}`, + data: null, + timestamp: new Date().toISOString(), + requestId: 'e2e-promotions-error', + }), + }) + return + } + + const status = statusParam as PromotionStatus + const sortBy = url.searchParams.get('sortBy') + const sortDirectionParam = url.searchParams.get('sortDirection') + const requestSignature = `${status}|${sortBy ?? 'none'}|${sortDirectionParam ?? 'none'}` + const expectedSignature = expectedPromotionRequests.shift() + if (requestSignature !== expectedSignature) { + unexpectedPromotionRequests.push(`${url.toString()} expected ${expectedSignature ?? 'no more requests'}`) + await route.fulfill({ + status: 400, + contentType: 'application/json', + body: JSON.stringify({ + code: 400, + msg: 'unexpected promotion request order', + data: null, + timestamp: new Date().toISOString(), + requestId: 'e2e-promotions-error', + }), + }) + return + } + + if (status === 'PENDING' && (sortBy !== null || sortDirectionParam !== null)) { + unexpectedPromotionRequests.push(url.toString()) + await route.fulfill({ + status: 400, + contentType: 'application/json', + body: JSON.stringify({ + code: 400, + msg: 'pending request must not include history sort params', + data: null, + timestamp: new Date().toISOString(), + requestId: 'e2e-promotions-error', + }), + }) + return + } + + if (status !== 'PENDING' && (sortBy !== 'reviewedAt' || !['ASC', 'DESC'].includes(sortDirectionParam ?? ''))) { + unexpectedPromotionRequests.push(url.toString()) + await route.fulfill({ + status: 400, + contentType: 'application/json', + body: JSON.stringify({ + code: 400, + msg: 'history request must include reviewedAt sort params', + data: null, + timestamp: new Date().toISOString(), + requestId: 'e2e-promotions-error', + }), + }) + return + } + + const dataByStatus: Record[]> = { + PENDING: [promotion(1, 'PENDING', 'Knowledge Helper')], + APPROVED: [ + promotion(2, 'APPROVED', 'Newest Approved', '2026-06-18T09:00:00Z'), + promotion(3, 'APPROVED', 'Oldest Approved', '2026-06-17T09:00:00Z'), + ], + REJECTED: [ + promotion(4, 'REJECTED', 'Newest Rejected', '2026-06-18T08:00:00Z'), + promotion(5, 'REJECTED', 'Oldest Rejected', '2026-06-16T08:00:00Z'), + ], + } + + const items = [...dataByStatus[status]] + if (status !== 'PENDING' && sortDirectionParam === 'ASC') { + items.reverse() + } + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + code: 0, + msg: 'success', + data: { items, total: items.length, page: 0, size: 20 }, + timestamp: new Date().toISOString(), + requestId: 'e2e-promotions', + }), + }) + }) + } + + function expectPromotionRequestsSatisfied() { + expect(unexpectedPromotionRequests).toEqual([]) + expect(expectedPromotionRequests).toEqual([]) + } + + test('shows enhanced pending cards and sorts approved/rejected history by reviewed time', async ({ page }) => { + await installPromotionRouteMock(page, [ + 'PENDING|none|none', + 'APPROVED|reviewedAt|DESC', + 'APPROVED|reviewedAt|ASC', + 'REJECTED|reviewedAt|DESC', + 'REJECTED|reviewedAt|ASC', + ]) + + const pendingRequest = page.waitForRequest((request) => { + const url = new URL(request.url()) + return url.pathname === '/api/web/promotions' + && url.searchParams.get('status') === 'PENDING' + && !url.searchParams.has('sortBy') + && !url.searchParams.has('sortDirection') + }) + + await page.goto('/dashboard/promotions') + await pendingRequest + + await expect(page.getByRole('heading', { name: 'Promotion Review' })).toBeVisible() + await expect(page.getByRole('heading', { name: 'Knowledge Helper' })).toBeVisible() + await expect(page.getByText('@team-ai/knowledge-helper -> @global')).toBeVisible() + await expect(page.getByText('Summary for Knowledge Helper')).toBeVisible() + await expect(page.getByText(/Jun 18, 2026/)).toBeVisible() + await expect(page.getByText('v1.3.0')).toBeVisible() + await expect(page.getByText('Submitter Owner One')).toBeVisible() + await expect(page.getByText('23 files')).toBeVisible() + await expect(page.getByText('1.8 MB')).toBeVisible() + await expect(page.getByText('18 downloads')).toBeVisible() + await expect(page.getByText('5 stars')).toBeVisible() + + const approvedDescRequest = page.waitForRequest((request) => { + const url = new URL(request.url()) + return url.pathname === '/api/web/promotions' + && url.searchParams.get('status') === 'APPROVED' + && url.searchParams.get('sortBy') === 'reviewedAt' + && url.searchParams.get('sortDirection') === 'DESC' + }) + await page.getByRole('tab', { name: 'Approved' }).click() + await approvedDescRequest + const approvedTable = page.getByRole('table', { name: 'Promotion history' }) + await expect(approvedTable).toBeVisible() + await expect(approvedTable.getByRole('row').nth(1)).toContainText('Newest Approved') + await expect(approvedTable.getByRole('row').nth(2)).toContainText('Oldest Approved') + + const approvedAscRequest = page.waitForRequest((request) => { + const url = new URL(request.url()) + return url.pathname === '/api/web/promotions' + && url.searchParams.get('status') === 'APPROVED' + && url.searchParams.get('sortBy') === 'reviewedAt' + && url.searchParams.get('sortDirection') === 'ASC' + }) + await page.getByRole('button', { name: 'Sort by reviewed time ascending' }).click() + await approvedAscRequest + await expect(page.getByRole('button', { name: 'Sort by reviewed time descending' })).toBeVisible() + await expect(approvedTable.getByRole('row').nth(1)).toContainText('Oldest Approved') + await expect(approvedTable.getByRole('row').nth(2)).toContainText('Newest Approved') + + const rejectedDescRequest = page.waitForRequest((request) => { + const url = new URL(request.url()) + return url.pathname === '/api/web/promotions' + && url.searchParams.get('status') === 'REJECTED' + && url.searchParams.get('sortBy') === 'reviewedAt' + && url.searchParams.get('sortDirection') === 'DESC' + }) + await page.getByRole('tab', { name: 'Rejected' }).click() + await rejectedDescRequest + await expect(page.getByRole('button', { name: 'Sort by reviewed time ascending' })).toBeVisible() + + const rejectedAscRequest = page.waitForRequest((request) => { + const url = new URL(request.url()) + return url.pathname === '/api/web/promotions' + && url.searchParams.get('status') === 'REJECTED' + && url.searchParams.get('sortBy') === 'reviewedAt' + && url.searchParams.get('sortDirection') === 'ASC' + }) + await page.getByRole('button', { name: 'Sort by reviewed time ascending' }).click() + await rejectedAscRequest + await expect(page.getByRole('button', { name: 'Sort by reviewed time descending' })).toBeVisible() + + await page.getByRole('tab', { name: 'Approved' }).click() + await expect(page.getByRole('button', { name: 'Sort by reviewed time descending' })).toBeVisible() + expectPromotionRequestsSatisfied() + }) + + test('sorter can be toggled from the keyboard', async ({ page }) => { + await installPromotionRouteMock(page, [ + 'PENDING|none|none', + 'APPROVED|reviewedAt|DESC', + 'APPROVED|reviewedAt|ASC', + ]) + + await page.goto('/dashboard/promotions') + + const approvedDescRequest = page.waitForRequest((request) => { + const url = new URL(request.url()) + return url.pathname === '/api/web/promotions' + && url.searchParams.get('status') === 'APPROVED' + && url.searchParams.get('sortBy') === 'reviewedAt' + && url.searchParams.get('sortDirection') === 'DESC' + }) + await page.getByRole('tab', { name: 'Approved' }).click() + await approvedDescRequest + + const approvedAscRequest = page.waitForRequest((request) => { + const url = new URL(request.url()) + return url.pathname === '/api/web/promotions' + && url.searchParams.get('status') === 'APPROVED' + && url.searchParams.get('sortBy') === 'reviewedAt' + && url.searchParams.get('sortDirection') === 'ASC' + }) + await page.getByRole('button', { name: 'Sort by reviewed time ascending' }).focus() + await page.keyboard.press('Enter') + await approvedAscRequest + + await expect(page.getByRole('button', { name: 'Sort by reviewed time descending' })).toBeVisible() + expectPromotionRequestsSatisfied() + }) +}) diff --git a/web/playwright.config.ts b/web/playwright.config.ts index 00b74867..36a60ebf 100644 --- a/web/playwright.config.ts +++ b/web/playwright.config.ts @@ -1,5 +1,15 @@ import { defineConfig, devices } from '@playwright/test' +const localNoProxyHosts = ['localhost', '127.0.0.1', '::1'] +const mergedNoProxy = Array.from(new Set([ + ...(process.env.NO_PROXY?.split(',').filter(Boolean) ?? []), + ...(process.env.no_proxy?.split(',').filter(Boolean) ?? []), + ...localNoProxyHosts, +])).join(',') + +process.env.NO_PROXY = mergedNoProxy +process.env.no_proxy = mergedNoProxy + export default defineConfig({ testDir: './e2e', fullyParallel: false, @@ -9,7 +19,7 @@ export default defineConfig({ workers: Number(process.env.PLAYWRIGHT_WORKERS ?? 1), reporter: 'html', use: { - baseURL: 'http://localhost:3000', + baseURL: 'http://127.0.0.1:3000', trace: 'on-first-retry', screenshot: 'on', }, @@ -21,7 +31,7 @@ export default defineConfig({ ], webServer: { command: 'pnpm exec vite --host 127.0.0.1 --port 3000 --strictPort', - url: 'http://localhost:3000', + url: 'http://127.0.0.1:3000', reuseExistingServer: true, timeout: 120000, }, diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 16d2e7fe..d701fd11 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -15,6 +15,9 @@ import type { MergeVerifyRequest, ReviewSkillDetail, ReviewTask, + PromotionSortBy, + PromotionSortDirection, + PromotionStatus, PromotionTask, AuditLogItem, SkillSummary, @@ -899,11 +902,17 @@ export const promotionApi = { }) }, - async list(params: { status?: string; page?: number; size?: number }) { + async list(params: { status?: PromotionStatus; page?: number; size?: number; sortBy?: PromotionSortBy; sortDirection?: PromotionSortDirection }) { const searchParams = new URLSearchParams() searchParams.set('status', params.status ?? 'PENDING') searchParams.set('page', String(params.page ?? 0)) searchParams.set('size', String(params.size ?? 20)) + if (params.sortBy) { + searchParams.set('sortBy', params.sortBy) + } + if (params.sortDirection) { + searchParams.set('sortDirection', params.sortDirection) + } return fetchJson<{ items: PromotionTask[]; total: number; page: number; size: number }>( `${WEB_API_PREFIX}/promotions?${searchParams.toString()}`, ) diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index a141fb20..fe5dc5a1 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -3692,9 +3692,19 @@ export interface components { id?: number; /** Format: int64 */ sourceSkillId?: number; + sourceSkillDisplayName?: string; + sourceSkillSummary?: string; sourceNamespace?: string; sourceSkillSlug?: string; sourceVersion?: string; + /** Format: int32 */ + sourceVersionFileCount?: number; + /** Format: int64 */ + sourceVersionTotalSize?: number; + /** Format: int64 */ + sourceSkillDownloadCount?: number; + /** Format: int32 */ + sourceSkillStarCount?: number; targetNamespace?: string; /** Format: int64 */ targetSkillId?: number; @@ -6933,9 +6943,11 @@ export interface operations { listPromotions: { parameters: { query?: { - status?: string; + status?: "PENDING" | "APPROVED" | "REJECTED"; page?: number; size?: number; + sortBy?: "reviewedAt"; + sortDirection?: "ASC" | "DESC"; }; header?: never; path?: never; @@ -6981,9 +6993,11 @@ export interface operations { listPromotions_1: { parameters: { query?: { - status?: string; + status?: "PENDING" | "APPROVED" | "REJECTED"; page?: number; size?: number; + sortBy?: "reviewedAt"; + sortDirection?: "ASC" | "DESC"; }; header?: never; path?: never; diff --git a/web/src/api/types.ts b/web/src/api/types.ts index bde5ec41..60bef288 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -385,22 +385,32 @@ export interface ReviewSkillDetail { activeVersion: string } +export type PromotionStatus = 'PENDING' | 'APPROVED' | 'REJECTED' +export type PromotionSortDirection = 'ASC' | 'DESC' +export type PromotionSortBy = 'reviewedAt' + export interface PromotionTask { id: number sourceSkillId: number + sourceSkillDisplayName: string + sourceSkillSummary?: string | null sourceNamespace: string sourceSkillSlug: string sourceVersion: string + sourceVersionFileCount: number + sourceVersionTotalSize: number + sourceSkillDownloadCount: number + sourceSkillStarCount: number targetNamespace: string - targetSkillId?: number - status: 'PENDING' | 'APPROVED' | 'REJECTED' + targetSkillId?: number | null + status: PromotionStatus submittedBy: string - submittedByName?: string - reviewedBy?: string - reviewedByName?: string - reviewComment?: string + submittedByName?: string | null + reviewedBy?: string | null + reviewedByName?: string | null + reviewComment?: string | null submittedAt: string - reviewedAt?: string + reviewedAt?: string | null } export interface SkillReport { diff --git a/web/src/features/promotion/use-promotion-list.test.ts b/web/src/features/promotion/use-promotion-list.test.ts index 638658f1..580713f1 100644 --- a/web/src/features/promotion/use-promotion-list.test.ts +++ b/web/src/features/promotion/use-promotion-list.test.ts @@ -1,35 +1,121 @@ -import { describe, expect, it } from 'vitest' -import * as mod from './use-promotion-list' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { PromotionTask } from '@/api/types' -/** - * use-promotion-list.ts exports four hooks (usePromotionList, - * usePromotionDetail, useApprovePromotion, useRejectPromotion) and - * re-exports the PromotionTask type. All hooks are thin wrappers around - * useQuery/useMutation with no exported pure helpers, query-key functions, - * or data transformations beyond unwrapping the backend page object - * (which cannot be tested without an API client mock). - * - * We verify the export contract so downstream consumers break fast if - * the module shape changes. - */ -describe('use-promotion-list module exports', () => { - it('exports usePromotionList as a function', () => { - expect(mod.usePromotionList).toBeDefined() - expect(typeof mod.usePromotionList).toBe('function') +const mocks = vi.hoisted(() => ({ + invalidateQueries: vi.fn(), + useMutation: vi.fn(), + useQuery: vi.fn(), + promotionList: vi.fn(), + promotionGet: vi.fn(), + promotionApprove: vi.fn(), + promotionReject: vi.fn(), +})) + +vi.mock('@tanstack/react-query', () => ({ + useMutation: mocks.useMutation, + useQuery: (options: unknown) => mocks.useQuery(options), + useQueryClient: () => ({ invalidateQueries: mocks.invalidateQueries }), +})) + +vi.mock('@/api/client', () => ({ + promotionApi: { + list: (...args: unknown[]) => mocks.promotionList(...args), + get: (...args: unknown[]) => mocks.promotionGet(...args), + approve: (...args: unknown[]) => mocks.promotionApprove(...args), + reject: (...args: unknown[]) => mocks.promotionReject(...args), + }, +})) + +import { usePromotionList } from './use-promotion-list' + +const promotion = { + id: 1, + sourceSkillId: 10, + sourceSkillDisplayName: 'Code Review Bot', + sourceSkillSummary: 'Reviews code changes.', + sourceNamespace: 'team-ai', + sourceSkillSlug: 'code-review-bot', + sourceVersion: '1.0.0', + sourceVersionFileCount: 3, + sourceVersionTotalSize: 2048, + sourceSkillDownloadCount: 7, + sourceSkillStarCount: 2, + targetNamespace: 'global', + targetSkillId: null, + status: 'PENDING', + submittedBy: 'owner-1', + submittedByName: 'Owner One', + reviewedBy: null, + reviewedByName: null, + reviewComment: null, + submittedAt: '2026-06-18T01:00:00Z', + reviewedAt: null, +} satisfies PromotionTask + +describe('usePromotionList', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.useQuery.mockImplementation((options: unknown) => options) + mocks.promotionList.mockResolvedValue({ items: [promotion], total: 1, page: 0, size: 20 }) }) - it('exports usePromotionDetail as a function', () => { - expect(mod.usePromotionDetail).toBeDefined() - expect(typeof mod.usePromotionDetail).toBe('function') + it('defaults to the pending queue without history sort params', async () => { + usePromotionList() + const options = mocks.useQuery.mock.calls[0]?.[0] as { queryKey: unknown; queryFn: () => Promise } + + expect(options.queryKey).toEqual(['promotions', { + status: 'PENDING', + page: 0, + size: 20, + sortBy: undefined, + sortDirection: undefined, + }]) + await expect(options.queryFn()).resolves.toEqual([promotion]) + expect(mocks.promotionList).toHaveBeenCalledWith({ + status: 'PENDING', + page: 0, + size: 20, + sortBy: undefined, + sortDirection: undefined, + }) }) - it('exports useApprovePromotion as a function', () => { - expect(mod.useApprovePromotion).toBeDefined() - expect(typeof mod.useApprovePromotion).toBe('function') + it('passes reviewed-time sort params for history queues', async () => { + usePromotionList({ status: 'APPROVED', sortBy: 'reviewedAt', sortDirection: 'ASC' }) + const options = mocks.useQuery.mock.calls[0]?.[0] as { queryKey: unknown; queryFn: () => Promise } + + expect(options.queryKey).toEqual(['promotions', { + status: 'APPROVED', + page: 0, + size: 20, + sortBy: 'reviewedAt', + sortDirection: 'ASC', + }]) + await options.queryFn() + expect(mocks.promotionList).toHaveBeenCalledWith({ + status: 'APPROVED', + page: 0, + size: 20, + sortBy: 'reviewedAt', + sortDirection: 'ASC', + }) }) - it('exports useRejectPromotion as a function', () => { - expect(mod.useRejectPromotion).toBeDefined() - expect(typeof mod.useRejectPromotion).toBe('function') + it('uses different query keys for opposite history sort directions', () => { + usePromotionList({ status: 'APPROVED', sortBy: 'reviewedAt', sortDirection: 'ASC' }) + const ascKey = mocks.useQuery.mock.calls[0]?.[0].queryKey + + mocks.useQuery.mockClear() + usePromotionList({ status: 'APPROVED', sortBy: 'reviewedAt', sortDirection: 'DESC' }) + const descKey = mocks.useQuery.mock.calls[0]?.[0].queryKey + + expect(ascKey).not.toEqual(descKey) + expect(descKey).toEqual(['promotions', { + status: 'APPROVED', + page: 0, + size: 20, + sortBy: 'reviewedAt', + sortDirection: 'DESC', + }]) }) }) diff --git a/web/src/features/promotion/use-promotion-list.ts b/web/src/features/promotion/use-promotion-list.ts index 74d88513..db0b1a14 100644 --- a/web/src/features/promotion/use-promotion-list.ts +++ b/web/src/features/promotion/use-promotion-list.ts @@ -1,18 +1,35 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { promotionApi } from '@/api/client' -import type { PromotionTask } from '@/api/types' +import type { PromotionSortBy, PromotionSortDirection, PromotionStatus, PromotionTask } from '@/api/types' + +export interface PromotionListParams { + status?: PromotionStatus + page?: number + size?: number + sortBy?: PromotionSortBy + sortDirection?: PromotionSortDirection +} /** * Returns the promotion queue for a given status. The hook unwraps the backend * page object because promotion screens currently consume the item list only. */ -export function usePromotionList(status = 'PENDING') { +export function usePromotionList(params: PromotionListParams = { status: 'PENDING' }) { + const normalizedParams = { + status: params.status ?? 'PENDING', + page: params.page ?? 0, + size: params.size ?? 20, + sortBy: params.sortBy, + sortDirection: params.sortDirection, + } + return useQuery({ - queryKey: ['promotions', status], + queryKey: ['promotions', normalizedParams], queryFn: async () => { - const page = await promotionApi.list({ status }) + const page = await promotionApi.list(normalizedParams) return page.items }, + staleTime: 30_000, }) } @@ -56,4 +73,4 @@ export function useRejectPromotion() { }) } -export type { PromotionTask } +export type { PromotionSortDirection, PromotionStatus, PromotionTask } diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 56039107..b060534a 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -554,7 +554,23 @@ "commentPlaceholder": "Review comment (optional)", "approve": "Approve", "reject": "Reject", - "empty": "No promotion requests" + "empty": "No promotion requests", + "historyTableLabel": "Promotion history", + "colSkill": "Skill", + "colVersion": "Version", + "colSubmitter": "Submitter", + "colReviewer": "Reviewer", + "colReviewedAt": "Reviewed At", + "colReviewComment": "Review Comment", + "sortReviewedTimeAsc": "Sort by reviewed time ascending", + "sortReviewedTimeDesc": "Sort by reviewed time descending", + "emptyValue": "-", + "versionTag": "v{{version}}", + "submitterTag": "Submitter {{user}}", + "fileCountTag": "{{count}} files", + "packageSizeTag": "{{size}}", + "downloadCountTag": "{{value}} downloads", + "starCountTag": "{{value}} stars" }, "adminUsers": { "title": "User Management", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 1920b158..d0d7c23a 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -554,7 +554,23 @@ "commentPlaceholder": "审核意见(可选)", "approve": "通过", "reject": "拒绝", - "empty": "暂无提升申请" + "empty": "暂无提升申请", + "historyTableLabel": "提升审核历史", + "colSkill": "技能", + "colVersion": "版本", + "colSubmitter": "提交人", + "colReviewer": "审核人", + "colReviewedAt": "处理时间", + "colReviewComment": "审核意见", + "sortReviewedTimeAsc": "按处理时间正序排序", + "sortReviewedTimeDesc": "按处理时间倒序排序", + "emptyValue": "-", + "versionTag": "v{{version}}", + "submitterTag": "提交人 {{user}}", + "fileCountTag": "{{count}} 个文件", + "packageSizeTag": "{{size}}", + "downloadCountTag": "{{value}} 次下载", + "starCountTag": "{{value}} 个星标" }, "adminUsers": { "title": "用户管理", diff --git a/web/src/pages/dashboard/promotions.test.ts b/web/src/pages/dashboard/promotions.test.ts deleted file mode 100644 index 827cd686..00000000 --- a/web/src/pages/dashboard/promotions.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' - -vi.mock('react-i18next', async () => { - const actual = await vi.importActual('react-i18next') - return { - ...actual, - useTranslation: () => ({ - t: (key: string) => key, - i18n: { language: 'en' }, - }), - } -}) - -vi.mock('@/features/promotion/use-promotion-list', () => ({ - useApprovePromotion: () => ({ mutateAsync: vi.fn(), isPending: false }), - usePromotionList: () => ({ data: [], isLoading: false }), - useRejectPromotion: () => ({ mutateAsync: vi.fn(), isPending: false }), -})) - -vi.mock('@/shared/lib/date-time', () => ({ - formatLocalDateTime: (v: string) => v, -})) - -vi.mock('@/shared/ui/button', () => ({ - Button: ({ children }: { children: unknown }) => children, -})) - -vi.mock('@/shared/ui/card', () => ({ - Card: ({ children }: { children: unknown }) => children, -})) - -vi.mock('@/shared/ui/input', () => ({ - Input: () => null, -})) - -vi.mock('@/shared/ui/tabs', () => ({ - Tabs: ({ children }: { children: unknown }) => children, - TabsContent: ({ children }: { children: unknown }) => children, - TabsList: ({ children }: { children: unknown }) => children, - TabsTrigger: ({ children }: { children: unknown }) => children, -})) - -vi.mock('@/shared/components/dashboard-page-header', () => ({ - DashboardPageHeader: () => null, -})) - -import { PromotionsPage } from './promotions' - -describe('PromotionsPage', () => { - it('exports a named component function', () => { - expect(typeof PromotionsPage).toBe('function') - }) -}) diff --git a/web/src/pages/dashboard/promotions.test.tsx b/web/src/pages/dashboard/promotions.test.tsx new file mode 100644 index 00000000..a04bc219 --- /dev/null +++ b/web/src/pages/dashboard/promotions.test.tsx @@ -0,0 +1,225 @@ +/** @vitest-environment jsdom */ +import { cleanup, fireEvent, render, screen, within } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { PromotionStatus, PromotionTask } from '@/api/types' + +const mocks = vi.hoisted(() => ({ + approveMutate: vi.fn(), + rejectMutate: vi.fn(), + usePromotionList: vi.fn(), + translations: { + 'promotions.approve': 'Approve', + 'promotions.colReviewComment': 'Review Comment', + 'promotions.colReviewedAt': 'Reviewed At', + 'promotions.colReviewer': 'Reviewer', + 'promotions.colSkill': 'Skill', + 'promotions.colSubmitter': 'Submitter', + 'promotions.colVersion': 'Version', + 'promotions.commentPlaceholder': 'Review comment (optional)', + 'promotions.downloadCountTag': '{{value}} downloads', + 'promotions.empty': 'No promotion requests', + 'promotions.emptyValue': '-', + 'promotions.fileCountTag': '{{count}} files', + 'promotions.historyTableLabel': 'Promotion history', + 'promotions.packageSizeTag': '{{size}}', + 'promotions.reject': 'Reject', + 'promotions.sortReviewedTimeAsc': 'Sort by reviewed time ascending', + 'promotions.sortReviewedTimeDesc': 'Sort by reviewed time descending', + 'promotions.starCountTag': '{{value}} stars', + 'promotions.submitterTag': 'Submitter {{user}}', + 'promotions.subtitle': 'Review promotion requests', + 'promotions.tabApproved': 'Approved', + 'promotions.tabPending': 'Pending', + 'promotions.tabRejected': 'Rejected', + 'promotions.title': 'Promotion Review', + 'promotions.versionTag': 'v{{version}}', + } as Record, +})) + +vi.mock('react-i18next', async () => { + const actual = await vi.importActual('react-i18next') + return { + ...actual, + useTranslation: () => ({ + i18n: { language: 'en' }, + t: (key: string, values?: Record) => { + const template = mocks.translations[key] ?? key + return Object.entries(values ?? {}).reduce( + (result, [name, value]) => result.split(`{{${name}}}`).join(String(value)), + template, + ) + }, + }), + } +}) + +vi.mock('@/features/promotion/use-promotion-list', () => ({ + useApprovePromotion: () => ({ mutate: mocks.approveMutate, isPending: false }), + usePromotionList: (params: unknown) => mocks.usePromotionList(params), + useRejectPromotion: () => ({ mutate: mocks.rejectMutate, isPending: false }), +})) + +vi.mock('@/shared/components/dashboard-page-header', () => ({ + DashboardPageHeader: ({ title, subtitle }: { title: string; subtitle: string }) => ( +
+

{title}

+

{subtitle}

+
+ ), +})) + +import { PromotionsPage } from './promotions' + +function createPromotion(overrides: Partial = {}): PromotionTask { + return { + id: 1, + sourceSkillId: 101, + sourceSkillDisplayName: 'Knowledge Helper', + sourceSkillSummary: 'Summary for Knowledge Helper', + sourceNamespace: 'team-ai', + sourceSkillSlug: 'knowledge-helper', + sourceVersion: '1.3.0', + sourceVersionFileCount: 23, + sourceVersionTotalSize: 1_843_200, + sourceSkillDownloadCount: 18, + sourceSkillStarCount: 5, + targetNamespace: 'global', + targetSkillId: null, + status: 'PENDING', + submittedBy: 'owner-1', + submittedByName: 'Owner One', + reviewedBy: null, + reviewedByName: null, + reviewComment: null, + submittedAt: '2026-06-18T12:00:00Z', + reviewedAt: null, + ...overrides, + } +} + +function installPromotionListMock(overrides: { + pending?: PromotionTask[] + approvedDesc?: PromotionTask[] + approvedAsc?: PromotionTask[] + rejectedDesc?: PromotionTask[] + rejectedAsc?: PromotionTask[] +} = {}) { + const pending = overrides.pending ?? [createPromotion()] + const approvedDesc = overrides.approvedDesc ?? [ + createPromotion({ + id: 2, + status: 'APPROVED', + sourceSkillDisplayName: 'Newest Approved', + sourceSkillSlug: 'newest-approved', + reviewedBy: 'admin-1', + reviewedByName: 'Admin', + reviewComment: 'Looks good.', + reviewedAt: '2026-06-18T09:00:00Z', + }), + createPromotion({ + id: 3, + status: 'APPROVED', + sourceSkillDisplayName: 'Oldest Approved', + sourceSkillSlug: 'oldest-approved', + reviewedBy: 'admin-1', + reviewedByName: 'Admin', + reviewComment: 'Approved after review.', + reviewedAt: '2026-06-17T09:00:00Z', + }), + ] + const rejectedDesc = overrides.rejectedDesc ?? [ + createPromotion({ + id: 4, + status: 'REJECTED', + sourceSkillDisplayName: 'Newest Rejected', + sourceSkillSlug: 'newest-rejected', + reviewedBy: 'admin-1', + reviewedByName: 'Admin', + reviewComment: 'Needs clearer docs before promotion.', + reviewedAt: '2026-06-18T08:00:00Z', + }), + createPromotion({ + id: 5, + status: 'REJECTED', + sourceSkillDisplayName: 'Oldest Rejected', + sourceSkillSlug: 'oldest-rejected', + reviewedBy: 'admin-1', + reviewedByName: 'Admin', + reviewComment: null, + reviewedAt: '2026-06-16T08:00:00Z', + }), + ] + const approvedAsc = overrides.approvedAsc ?? [...approvedDesc].reverse() + const rejectedAsc = overrides.rejectedAsc ?? [...rejectedDesc].reverse() + + mocks.usePromotionList.mockImplementation((params: { status?: PromotionStatus; sortDirection?: 'ASC' | 'DESC' } = {}) => { + if (params.status === 'APPROVED') { + return { data: params.sortDirection === 'ASC' ? approvedAsc : approvedDesc, isLoading: false } + } + if (params.status === 'REJECTED') { + return { data: params.sortDirection === 'ASC' ? rejectedAsc : rejectedDesc, isLoading: false } + } + return { data: pending, isLoading: false } + }) +} + +describe('PromotionsPage', () => { + beforeEach(() => { + vi.clearAllMocks() + installPromotionListMock() + }) + + afterEach(() => cleanup()) + + it('renders enhanced pending card review context', () => { + render() + + expect(screen.getByRole('heading', { name: 'Promotion Review' })).toBeTruthy() + expect(screen.getByText('Knowledge Helper')).toBeTruthy() + expect(screen.getByText('@team-ai/knowledge-helper -> @global')).toBeTruthy() + expect(screen.getByText('Summary for Knowledge Helper')).toBeTruthy() + expect(screen.getByText('v1.3.0')).toBeTruthy() + expect(screen.getByText('Submitter Owner One')).toBeTruthy() + expect(screen.getByText('23 files')).toBeTruthy() + expect(screen.getByText('1.8 MB')).toBeTruthy() + expect(screen.getByText('18 downloads')).toBeTruthy() + expect(screen.getByText('5 stars')).toBeTruthy() + }) + + it('renders approved history as a sortable table', () => { + render() + + fireEvent.click(screen.getByRole('tab', { name: 'Approved' })) + const table = screen.getByRole('table', { name: 'Promotion history' }) + let rows = within(table).getAllByRole('row') + expect(rows[1]?.textContent).toContain('Newest Approved') + expect(rows[2]?.textContent).toContain('Oldest Approved') + + const ascendingButton = screen.getByRole('button', { name: 'Sort by reviewed time ascending' }) + expect(ascendingButton.closest('th')?.getAttribute('aria-sort')).toBe('descending') + expect(ascendingButton.querySelector('[aria-hidden="true"]')).toBeTruthy() + + fireEvent.click(ascendingButton) + rows = within(screen.getByRole('table', { name: 'Promotion history' })).getAllByRole('row') + expect(rows[1]?.textContent).toContain('Oldest Approved') + expect(rows[2]?.textContent).toContain('Newest Approved') + const descendingButton = screen.getByRole('button', { name: 'Sort by reviewed time descending' }) + expect(descendingButton.closest('th')?.getAttribute('aria-sort')).toBe('ascending') + }) + + it('keeps approved and rejected sort state independent', () => { + render() + + fireEvent.click(screen.getByRole('tab', { name: 'Approved' })) + fireEvent.click(screen.getByRole('button', { name: 'Sort by reviewed time ascending' })) + expect(screen.getByRole('button', { name: 'Sort by reviewed time descending' })).toBeTruthy() + + fireEvent.click(screen.getByRole('tab', { name: 'Rejected' })) + expect(screen.getByRole('button', { name: 'Sort by reviewed time ascending' })).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Sort by reviewed time ascending' })) + expect(screen.getByRole('button', { name: 'Sort by reviewed time descending' })).toBeTruthy() + + fireEvent.click(screen.getByRole('tab', { name: 'Approved' })) + expect(screen.getByRole('button', { name: 'Sort by reviewed time descending' })).toBeTruthy() + }) +}) diff --git a/web/src/pages/dashboard/promotions.tsx b/web/src/pages/dashboard/promotions.tsx index a342443e..7ebcf901 100644 --- a/web/src/pages/dashboard/promotions.tsx +++ b/web/src/pages/dashboard/promotions.tsx @@ -1,20 +1,131 @@ import { useState } from 'react' import { useTranslation } from 'react-i18next' import { useApprovePromotion, usePromotionList, useRejectPromotion } from '@/features/promotion/use-promotion-list' +import { DashboardPageHeader } from '@/shared/components/dashboard-page-header' import { formatLocalDateTime } from '@/shared/lib/date-time' +import { formatCompactCount } from '@/shared/lib/number-format' +import { cn } from '@/shared/lib/utils' import { Button } from '@/shared/ui/button' import { Card } from '@/shared/ui/card' import { Input } from '@/shared/ui/input' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/shared/ui/table' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs' -import { DashboardPageHeader } from '@/shared/components/dashboard-page-header' +import type { PromotionTask } from '@/api/types' +import type { PromotionSortDirection, PromotionStatus } from '@/features/promotion/use-promotion-list' -/** - * Renders one promotion queue lane. Pending items expose moderation actions, - * while historical lanes stay read-only and surface the review comment only. - */ -function PromotionSection({ status }: { status: 'PENDING' | 'APPROVED' | 'REJECTED' }) { +type HistoryPromotionStatus = Extract + +function formatFileSize(bytes: number): string { + if (bytes < 1024) { + return `${bytes} B` + } + const units = ['KB', 'MB', 'GB'] + let value = bytes / 1024 + let unitIndex = 0 + while (value >= 1024 && unitIndex < units.length - 1) { + value /= 1024 + unitIndex += 1 + } + return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[unitIndex]}` +} + +function formatUserName(displayName: string | null | undefined, userId: string | null | undefined, fallback: string) { + return displayName || userId || fallback +} + +function sourceCoordinate(item: PromotionTask) { + return `@${item.sourceNamespace}/${item.sourceSkillSlug}` +} + +function promotionCoordinate(item: PromotionTask) { + return `${sourceCoordinate(item)} -> @${item.targetNamespace}` +} + +function SorterGlyph({ direction }: { direction: PromotionSortDirection }) { + return ( +
) From bf7c71ad2c3dfb795c40ba6d9901ea8b353a7268 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Mon, 29 Jun 2026 14:34:55 +0800 Subject: [PATCH 32/81] fix(scanner): backport local LLM base URL handling for #563 Also add Python CodeQL coverage in the security workflow so repository-level script regression checks stay green when Python source exists. Signed-off-by: dongmucat <1127093059@qq.com> --- .github/workflows/security.yml | 2 + deploy/k8s/README.md | 3 + deploy/k8s/base/scanner-deployment.yaml | 6 + deploy/k8s/base/secret.yaml.example | 1 + docs/security-scanning.md | 1 + docs/skillhub/en/guide/kubernetes.md | 2 + docs/skillhub/en/guide/scanner.md | 2 + docs/skillhub/guide/kubernetes.md | 2 + docs/skillhub/guide/scanner.md | 2 + scanner/Dockerfile | 10 +- .../apply_1_0_2_llm_base_url_backport.py | 62 +++++ scripts/tests/scanner-llm-base-url-test.sh | 232 ++++++++++++++++++ 12 files changed, 322 insertions(+), 3 deletions(-) create mode 100644 scanner/backports/apply_1_0_2_llm_base_url_backport.py create mode 100755 scripts/tests/scanner-llm-base-url-test.sh diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 9382d933..3329a267 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -50,6 +50,8 @@ jobs: build-mode: manual - language: javascript-typescript build-mode: none + - language: python + build-mode: none steps: - name: Check out repository diff --git a/deploy/k8s/README.md b/deploy/k8s/README.md index 681a1b40..a58d031d 100644 --- a/deploy/k8s/README.md +++ b/deploy/k8s/README.md @@ -63,6 +63,8 @@ cp secret.yaml.example secret.yaml | oauth2-github-client-id | GitHub OAuth ID | 否 | | oauth2-github-client-secret | GitHub OAuth 密钥 | 否 | | skill-scanner-llm-api-key | LLM API 密钥 | 否 | +| skill-scanner-llm-base-url | 本地/自定义 LLM 服务地址 | 否 | +| skill-scanner-llm-model | Scanner 使用的 LLM 模型名 | 否 | ### 3. 选择部署方式 @@ -192,6 +194,7 @@ kubectl apply -k overlays/with-infra/ # 或 overlays/external/ | oauth2-github-client-id | GitHub OAuth ID | 否 | | oauth2-github-client-secret | GitHub OAuth 密钥 | 否 | | skill-scanner-llm-api-key | LLM API 密钥 | 否 | +| skill-scanner-llm-base-url | 本地/自定义 LLM 服务地址 | 否 | | skill-scanner-llm-model | LLM 模型名称 | 否 | ### 存储配置 diff --git a/deploy/k8s/base/scanner-deployment.yaml b/deploy/k8s/base/scanner-deployment.yaml index 9cff8b93..91c7f3e3 100644 --- a/deploy/k8s/base/scanner-deployment.yaml +++ b/deploy/k8s/base/scanner-deployment.yaml @@ -28,6 +28,12 @@ spec: name: skillhub-secret key: skill-scanner-llm-api-key optional: true + - name: SKILL_SCANNER_LLM_BASE_URL + valueFrom: + secretKeyRef: + name: skillhub-secret + key: skill-scanner-llm-base-url + optional: true - name: SKILL_SCANNER_LLM_MODEL valueFrom: secretKeyRef: diff --git a/deploy/k8s/base/secret.yaml.example b/deploy/k8s/base/secret.yaml.example index 41b9ea5c..5ff967cc 100644 --- a/deploy/k8s/base/secret.yaml.example +++ b/deploy/k8s/base/secret.yaml.example @@ -24,6 +24,7 @@ stringData: # LLM 配置(可选,用于技能扫描) skill-scanner-llm-api-key: "" + skill-scanner-llm-base-url: "" skill-scanner-llm-model: "" # S3 存储配置(可选,使用 S3/OSS 时配置) diff --git a/docs/security-scanning.md b/docs/security-scanning.md index 2fab9717..bd8d80fc 100644 --- a/docs/security-scanning.md +++ b/docs/security-scanning.md @@ -61,6 +61,7 @@ Important environment variables: Scanner-side optional environment variables: - `SKILL_SCANNER_LLM_API_KEY` +- `SKILL_SCANNER_LLM_BASE_URL` - `SKILL_SCANNER_LLM_MODEL` If the LLM variables are absent, the scanner should still run with non-LLM analyzers. diff --git a/docs/skillhub/en/guide/kubernetes.md b/docs/skillhub/en/guide/kubernetes.md index 73eb2cee..c56caf9a 100644 --- a/docs/skillhub/en/guide/kubernetes.md +++ b/docs/skillhub/en/guide/kubernetes.md @@ -63,6 +63,8 @@ cp secret.yaml.example secret.yaml | oauth2-github-client-id | GitHub OAuth ID | No | | oauth2-github-client-secret | GitHub OAuth secret | No | | skill-scanner-llm-api-key | LLM API key | No | +| skill-scanner-llm-base-url | Local/custom LLM service base URL | No | +| skill-scanner-llm-model | LLM model name used by the scanner | No | ### 3. Choose Deployment Method diff --git a/docs/skillhub/en/guide/scanner.md b/docs/skillhub/en/guide/scanner.md index 20f48139..5c11902d 100644 --- a/docs/skillhub/en/guide/scanner.md +++ b/docs/skillhub/en/guide/scanner.md @@ -79,6 +79,8 @@ Enabling the LLM analysis engine can improve the accuracy of security detection: | `SKILLHUB_SCANNER_USE_LLM` | Enable LLM analysis | `false` | | `SKILLHUB_SCANNER_LLM_PROVIDER` | LLM provider (anthropic / openai / azure) | `anthropic` | | `SKILL_SCANNER_LLM_API_KEY` | LLM API key | - | +| `SKILL_SCANNER_LLM_BASE_URL` | Local/custom LLM service base URL | - | +| `SKILL_SCANNER_LLM_MODEL` | LLM model name | - | ### Deployment Notes diff --git a/docs/skillhub/guide/kubernetes.md b/docs/skillhub/guide/kubernetes.md index de8f505e..9a4b3a8d 100644 --- a/docs/skillhub/guide/kubernetes.md +++ b/docs/skillhub/guide/kubernetes.md @@ -63,6 +63,8 @@ cp secret.yaml.example secret.yaml | oauth2-github-client-id | GitHub OAuth ID | 否 | | oauth2-github-client-secret | GitHub OAuth 密钥 | 否 | | skill-scanner-llm-api-key | LLM API 密钥 | 否 | +| skill-scanner-llm-base-url | 本地/自定义 LLM 服务地址 | 否 | +| skill-scanner-llm-model | Scanner 使用的 LLM 模型名 | 否 | ### 3. 选择部署方式 diff --git a/docs/skillhub/guide/scanner.md b/docs/skillhub/guide/scanner.md index 8cbdc134..33837cbb 100644 --- a/docs/skillhub/guide/scanner.md +++ b/docs/skillhub/guide/scanner.md @@ -79,6 +79,8 @@ Skill Scanner 执行多引擎分析 | `SKILLHUB_SCANNER_USE_LLM` | 启用 LLM 分析 | `false` | | `SKILLHUB_SCANNER_LLM_PROVIDER` | LLM 提供商(anthropic / openai / azure) | `anthropic` | | `SKILL_SCANNER_LLM_API_KEY` | LLM API 密钥 | - | +| `SKILL_SCANNER_LLM_BASE_URL` | 本地/自定义 LLM 服务地址 | - | +| `SKILL_SCANNER_LLM_MODEL` | LLM 模型名称 | - | ### 部署说明 diff --git a/scanner/Dockerfile b/scanner/Dockerfile index cb0c82c8..90eac126 100644 --- a/scanner/Dockerfile +++ b/scanner/Dockerfile @@ -1,10 +1,14 @@ FROM python:3.11-alpine +ARG SKILL_SCANNER_VERSION=1.0.2 + WORKDIR /app -RUN apk add --no-cache --virtual .build-deps gcc musl-dev libffi-dev && \ - pip install --no-cache-dir cisco-ai-skill-scanner && \ - apk del .build-deps && \ +COPY backports/apply_1_0_2_llm_base_url_backport.py /tmp/apply_1_0_2_llm_base_url_backport.py + +RUN pip install --no-cache-dir "cisco-ai-skill-scanner==${SKILL_SCANNER_VERSION}" && \ + python /tmp/apply_1_0_2_llm_base_url_backport.py /usr/local/lib/python3.11/site-packages && \ + rm /tmp/apply_1_0_2_llm_base_url_backport.py && \ addgroup -S app && \ adduser -S app -G app && \ mkdir -p /tmp/skillhub-scans && \ diff --git a/scanner/backports/apply_1_0_2_llm_base_url_backport.py b/scanner/backports/apply_1_0_2_llm_base_url_backport.py new file mode 100644 index 00000000..7ea6bbd6 --- /dev/null +++ b/scanner/backports/apply_1_0_2_llm_base_url_backport.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +"""Backport SKILL_SCANNER_LLM_BASE_URL support into cisco-ai-skill-scanner 1.0.2.""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +EXPECTED_DIST_INFO = "cisco_ai_skill_scanner-1.0.2.dist-info" +ROUTER_RELATIVE_PATH = Path("skill_scanner/api/router.py") + +def replace_exact(content: str, old: str, new: str, expected_count: int, label: str) -> str: + actual_count = content.count(old) + if actual_count != expected_count: + raise SystemExit(f"Expected {expected_count} occurrences of {label}, found {actual_count}.") + return content.replace(old, new, expected_count) + + +def replace_regex(content: str, pattern: str, replacement: str, expected_count: int, label: str) -> str: + updated, actual_count = re.subn(pattern, replacement, content, count=expected_count, flags=re.MULTILINE) + if actual_count != expected_count: + raise SystemExit(f"Expected {expected_count} regex replacements for {label}, found {actual_count}.") + return updated + + +def main() -> int: + site_packages = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("/usr/local/lib/python3.11/site-packages") + dist_info = site_packages / EXPECTED_DIST_INFO + if not dist_info.exists(): + raise SystemExit(f"Expected {EXPECTED_DIST_INFO} under {site_packages}, but it was not found.") + + router_path = site_packages / ROUTER_RELATIVE_PATH + content = router_path.read_text(encoding="utf-8") + content = replace_regex( + content, + r'^(?P\s*)llm_model = os.getenv\("SKILL_SCANNER_LLM_MODEL"\)$', + r'\g<0>\n\gllm_base_url = os.getenv("SKILL_SCANNER_LLM_BASE_URL")', + 2, + "llm_model environment lookup", + ) + content = replace_exact( + content, + "LLMAnalyzer(model=llm_model)", + "LLMAnalyzer(model=llm_model, base_url=llm_base_url)", + 2, + "LLMAnalyzer model constructor", + ) + content = replace_exact( + content, + "LLMAnalyzer(provider=provider_str)", + "LLMAnalyzer(provider=provider_str, base_url=llm_base_url)", + 2, + "LLMAnalyzer provider constructor", + ) + + router_path.write_text(content, encoding="utf-8") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/tests/scanner-llm-base-url-test.sh b/scripts/tests/scanner-llm-base-url-test.sh new file mode 100755 index 00000000..79378b61 --- /dev/null +++ b/scripts/tests/scanner-llm-base-url-test.sh @@ -0,0 +1,232 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +SCANNER_DIR="$REPO_ROOT/scanner" +TMP_DIRS=() + +cleanup() { + local status=$? + local d + for d in "${TMP_DIRS[@]+"${TMP_DIRS[@]}"}"; do + rm -rf "$d" + done + exit "$status" +} +trap cleanup EXIT + +new_tmp() { + local d + d="$(mktemp -d)" + TMP_DIRS+=("$d") + echo "$d" +} + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +tmp="$(new_tmp)" +skill_dir="$tmp/skill" +mkdir -p "$skill_dir/demo-skill" + +cat >"$skill_dir/demo-skill/SKILL.md" <<'EOF' +--- +name: demo-skill +description: Minimal valid skill used for scanner integration coverage. +license: Apache-2.0 +--- + +This is a harmless demo skill used for scanner integration testing. +EOF + +cat >"$skill_dir/demo-skill/run.sh" <<'EOF' +#!/usr/bin/env sh +echo "demo" +EOF +chmod +x "$skill_dir/demo-skill/run.sh" + +IMAGE_TAG="skillhub-scanner-llm-base-url-test:$(date +%s)" +docker build --no-cache -t "$IMAGE_TAG" "$SCANNER_DIR" >/dev/null + +docker run --rm -i \ + -v "$skill_dir:/work/skill:ro" \ + --entrypoint python \ + "$IMAGE_TAG" - <<'PY' +import asyncio +from datetime import datetime, timezone +import http.server +import io +import inspect +import json +import os +from pathlib import Path +import threading +import urllib.request +import zipfile + +from fastapi.params import Query +from skill_scanner.core.models import ScanResult +import skill_scanner.api.router as router + +signature = inspect.signature(router.scan_uploaded_skill) +if not isinstance(signature.parameters["use_llm"].default, Query): + raise SystemExit("scan-upload use_llm should remain a Query parameter") +if not isinstance(signature.parameters["llm_provider"].default, Query): + raise SystemExit("scan-upload llm_provider should remain a Query parameter") + +state = {"base_urls": [], "paths": []} + + +class Handler(http.server.BaseHTTPRequestHandler): + def log_message(self, format, *args): # noqa: A003 + return + + def do_POST(self): # noqa: N802 + length = int(self.headers.get("content-length", "0")) + self.rfile.read(length) + state["paths"].append(self.path) + + payload = json.dumps( + { + "id": "chatcmpl-test", + "object": "chat.completion", + "created": int(datetime.now(timezone.utc).timestamp()), + "model": "local-model", + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": "No findings."}, + "finish_reason": "stop", + } + ], + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, + } + ).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + +server = http.server.HTTPServer(("127.0.0.1", 0), Handler) +thread = threading.Thread(target=server.serve_forever, daemon=True) +thread.start() + +target_base_url = f"http://127.0.0.1:{server.server_port}/v1" +os.environ["SKILL_SCANNER_LLM_BASE_URL"] = target_base_url +os.environ["SKILL_SCANNER_LLM_MODEL"] = "test-model" + + +class FakeStaticAnalyzer: + pass + + +class FakeLLMAnalyzer: + def __init__(self, model=None, provider=None, base_url=None): + self.model = model + self.provider = provider + self.base_url = base_url + state["base_urls"].append(base_url) + + def analyze(self, skill_path): + request = urllib.request.Request( + self.base_url + "/chat/completions", + data=b"{}", + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(request, timeout=5) as response: + response.read() + + +class FakeSkillScanner: + def __init__(self, analyzers): + self.analyzers = analyzers + + def scan_skill(self, skill_path): + for analyzer in self.analyzers: + analyze = getattr(analyzer, "analyze", None) + if callable(analyze): + analyze(skill_path) + + return ScanResult( + skill_name="demo-skill", + skill_directory=str(skill_path), + findings=[], + scan_duration_seconds=0.05, + analyzers_used=["fake-llm"], + timestamp=datetime.now(timezone.utc), + ) + + +router.StaticAnalyzer = FakeStaticAnalyzer +router.LLMAnalyzer = FakeLLMAnalyzer +router.SkillScanner = FakeSkillScanner +router.LLM_AVAILABLE = True + +request = router.ScanRequest( + skill_directory="/work/skill/demo-skill", + use_llm=True, + llm_provider="openai", + use_behavioral=False, + use_aidefense=False, + aidefense_api_key=None, +) + +def build_skill_archive_bytes(skill_root: str) -> bytes: + skill_path = Path(skill_root) + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_DEFLATED) as archive: + for path in skill_path.rglob("*"): + if path.is_file(): + archive.writestr(str(path.relative_to(skill_path.parent)), path.read_bytes()) + return buffer.getvalue() + +class FakeUploadFile: + def __init__(self, filename: str, payload: bytes): + self.filename = filename + self._payload = payload + + async def read(self) -> bytes: + return self._payload + +try: + direct_response = asyncio.run(router.scan_skill(request)) + + upload_response = asyncio.run( + router.scan_uploaded_skill( + file=FakeUploadFile("demo-skill.zip", build_skill_archive_bytes("/work/skill/demo-skill")), + use_llm=True, + llm_provider="openai", + use_behavioral=False, + use_aidefense=False, + aidefense_api_key=None, + ) + ) +finally: + server.shutdown() + thread.join(timeout=5) + +if not getattr(direct_response, "scan_id", None): + raise SystemExit("scan_skill should still return a scan response") +if not getattr(upload_response, "scan_id", None): + raise SystemExit("scan_uploaded_skill should still return a scan response") +if len(state["base_urls"]) != 2: + raise SystemExit(f"expected two LLM analyzer constructions, got {len(state['base_urls'])}") +if any(base_url != target_base_url for base_url in state["base_urls"]): + raise SystemExit(f"expected every base_url to be {target_base_url}, got {state['base_urls']}") +if len(state["paths"]) != 2: + raise SystemExit(f"expected two LLM requests, got {state['paths']}") +if not all(path.startswith("/v1/") for path in state["paths"]): + raise SystemExit(f"expected every request path to start with /v1/, got {state['paths']}") +PY + +grep -Fq "name: SKILL_SCANNER_LLM_BASE_URL" "$REPO_ROOT/deploy/k8s/base/scanner-deployment.yaml" \ + || fail "Kubernetes scanner deployment must expose SKILL_SCANNER_LLM_BASE_URL" +grep -Fq "skill-scanner-llm-base-url" "$REPO_ROOT/deploy/k8s/base/secret.yaml.example" \ + || fail "Kubernetes secret example must document skill-scanner-llm-base-url" + +echo "scanner-llm-base-url-test passed" From db8aa36f897293398c5bf7d0acc4735507050df3 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Tue, 30 Jun 2026 10:56:00 +0800 Subject: [PATCH 33/81] fix(frontend): patch undici alerts and harden staging web Signed-off-by: dongmucat <1127093059@qq.com> --- Makefile | 6 ++++-- docker-compose.staging.yml | 9 +++------ web/package.json | 1 + web/pnpm-lock.yaml | 11 ++++++----- 4 files changed, 14 insertions(+), 13 deletions(-) diff --git a/Makefile b/Makefile index bf3e7439..f90f8ef5 100644 --- a/Makefile +++ b/Makefile @@ -12,6 +12,7 @@ DEV_SCANNER_URL := http://localhost:8000 STAGING_API_URL := http://localhost:8080 STAGING_WEB_URL := http://localhost STAGING_SERVER_IMAGE := skillhub-server:staging +STAGING_WEB_IMAGE := skillhub-web:staging DEV_PROCESS := bash scripts/dev-process.sh DEV_SERVER_PREPARE := true DEV_SERVER_CMD := ./scripts/run-dev-app.sh @@ -296,12 +297,13 @@ db-reset: ## 重置数据库 validate-release-config: ## 校验发布环境变量文件(默认 .env.release) ./scripts/validate-release-config.sh .env.release -staging: ## 构建并启动 staging 环境,运行 smoke test(混合模式:后端镜像 + 前端静态文件) +staging: ## 构建并启动 staging 环境,运行 smoke test(后端/前端均走本地构建镜像) @echo "=== [1/5] Building backend JAR and Docker image ===" cd server && ./mvnw package -DskipTests -B -q docker build -t $(STAGING_SERVER_IMAGE) -f server/Dockerfile.dev server - @echo "=== [2/5] Building frontend static files ===" + @echo "=== [2/5] Building frontend static files and Docker image ===" cd web && pnpm run build + docker build -t $(STAGING_WEB_IMAGE) -f web/Dockerfile web @echo "=== [3/5] Starting dependency services ===" $(STAGING_BASE_COMPOSE) up -d --wait @echo "=== [4/5] Starting staging services ===" diff --git a/docker-compose.staging.yml b/docker-compose.staging.yml index 48d1fd1a..b619a0ac 100644 --- a/docker-compose.staging.yml +++ b/docker-compose.staging.yml @@ -1,6 +1,6 @@ -# Staging environment: hybrid mode +# Staging environment # - Backend: locally built Docker image -# - Frontend: locally built static files mounted into Nginx +# - Frontend: locally built Docker image # - Dependencies: reuses docker-compose.yml (postgres, redis, minio) # # Usage: make staging @@ -61,12 +61,9 @@ services: start_period: 60s web: - image: nginx:alpine + image: skillhub-web:staging ports: - "80:80" - volumes: - - ./web/dist:/usr/share/nginx/html:ro - - ./web/nginx.conf.template:/etc/nginx/templates/default.conf.template:ro environment: SKILLHUB_API_UPSTREAM: http://server:8080 SKILLHUB_WEB_API_BASE_URL: "" diff --git a/web/package.json b/web/package.json index 4b969147..a39046a8 100644 --- a/web/package.json +++ b/web/package.json @@ -12,6 +12,7 @@ "vite@<6.4.3": "^6.4.3", "esbuild@<0.28.1": "^0.28.1", "js-yaml@<4.2.0": "^4.2.0", + "undici@<7.28.0": "^7.28.0", "@babel/core@<7.29.6": "^7.29.6", "postcss@<8.5.10": "^8.5.10", "picomatch@<2.3.2": "^2.3.2", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 389b1abb..8ebbf05f 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -8,6 +8,7 @@ overrides: vite@<6.4.3: ^6.4.3 esbuild@<0.28.1: ^0.28.1 js-yaml@<4.2.0: ^4.2.0 + undici@<7.28.0: ^7.28.0 '@babel/core@<7.29.6': ^7.29.6 postcss@<8.5.10: ^8.5.10 picomatch@<2.3.2: ^2.3.2 @@ -2685,8 +2686,8 @@ packages: engines: {node: '>=14.17'} hasBin: true - undici@7.25.0: - resolution: {integrity: sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==} + undici@7.28.0: + resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} engines: {node: '>=20.18.1'} unified@11.0.5: @@ -3810,7 +3811,7 @@ snapshots: '@testing-library/dom@10.4.1': dependencies: - '@babel/code-frame': 7.29.0 + '@babel/code-frame': 7.29.7 '@babel/runtime': 7.28.6 '@types/aria-query': 5.0.4 aria-query: 5.3.0 @@ -4662,7 +4663,7 @@ snapshots: saxes: 6.0.0 symbol-tree: 3.2.4 tough-cookie: 6.0.1 - undici: 7.25.0 + undici: 7.28.0 w3c-xmlserializer: 5.0.0 webidl-conversions: 8.0.1 whatwg-mimetype: 5.0.0 @@ -5680,7 +5681,7 @@ snapshots: typescript@5.9.3: {} - undici@7.25.0: {} + undici@7.28.0: {} unified@11.0.5: dependencies: From 85332a22375cd016c6624d374e8c0dc4780d0852 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Tue, 30 Jun 2026 14:04:08 +0800 Subject: [PATCH 34/81] chore(staging): keep dependabot fix scoped Signed-off-by: dongmucat <1127093059@qq.com> --- Makefile | 6 ++---- docker-compose.staging.yml | 9 ++++++--- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/Makefile b/Makefile index f90f8ef5..bf3e7439 100644 --- a/Makefile +++ b/Makefile @@ -12,7 +12,6 @@ DEV_SCANNER_URL := http://localhost:8000 STAGING_API_URL := http://localhost:8080 STAGING_WEB_URL := http://localhost STAGING_SERVER_IMAGE := skillhub-server:staging -STAGING_WEB_IMAGE := skillhub-web:staging DEV_PROCESS := bash scripts/dev-process.sh DEV_SERVER_PREPARE := true DEV_SERVER_CMD := ./scripts/run-dev-app.sh @@ -297,13 +296,12 @@ db-reset: ## 重置数据库 validate-release-config: ## 校验发布环境变量文件(默认 .env.release) ./scripts/validate-release-config.sh .env.release -staging: ## 构建并启动 staging 环境,运行 smoke test(后端/前端均走本地构建镜像) +staging: ## 构建并启动 staging 环境,运行 smoke test(混合模式:后端镜像 + 前端静态文件) @echo "=== [1/5] Building backend JAR and Docker image ===" cd server && ./mvnw package -DskipTests -B -q docker build -t $(STAGING_SERVER_IMAGE) -f server/Dockerfile.dev server - @echo "=== [2/5] Building frontend static files and Docker image ===" + @echo "=== [2/5] Building frontend static files ===" cd web && pnpm run build - docker build -t $(STAGING_WEB_IMAGE) -f web/Dockerfile web @echo "=== [3/5] Starting dependency services ===" $(STAGING_BASE_COMPOSE) up -d --wait @echo "=== [4/5] Starting staging services ===" diff --git a/docker-compose.staging.yml b/docker-compose.staging.yml index b619a0ac..48d1fd1a 100644 --- a/docker-compose.staging.yml +++ b/docker-compose.staging.yml @@ -1,6 +1,6 @@ -# Staging environment +# Staging environment: hybrid mode # - Backend: locally built Docker image -# - Frontend: locally built Docker image +# - Frontend: locally built static files mounted into Nginx # - Dependencies: reuses docker-compose.yml (postgres, redis, minio) # # Usage: make staging @@ -61,9 +61,12 @@ services: start_period: 60s web: - image: skillhub-web:staging + image: nginx:alpine ports: - "80:80" + volumes: + - ./web/dist:/usr/share/nginx/html:ro + - ./web/nginx.conf.template:/etc/nginx/templates/default.conf.template:ro environment: SKILLHUB_API_UPSTREAM: http://server:8080 SKILLHUB_WEB_API_BASE_URL: "" From 3a254d752477b3c8e7f9486cfbdc15d531fd7e4d Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Wed, 1 Jul 2026 09:39:55 +0800 Subject: [PATCH 35/81] fix(auth): guard SUPER_ADMIN role mutations Signed-off-by: dongmucat <1127093059@qq.com> --- docs/06-api-design.md | 2 +- .../skillhub/service/AdminUserAppService.java | 10 +++++++--- .../src/main/resources/messages.properties | 2 +- .../src/main/resources/messages_zh.properties | 2 +- .../skillhub/service/AdminUserAppServiceTest.java | 14 ++++++++++++++ 5 files changed, 24 insertions(+), 6 deletions(-) diff --git a/docs/06-api-design.md b/docs/06-api-design.md index 673eb950..56cb5541 100644 --- a/docs/06-api-design.md +++ b/docs/06-api-design.md @@ -319,7 +319,7 @@ Admin API 按最小权限拆分,不再统一要求 SUPER_ADMIN: |------|------|------| | GET | `/api/v1/admin/users` | 用户列表 | | GET | `/api/v1/admin/users/{id}` | 用户详情 | -| PUT | `/api/v1/admin/users/{id}/roles` | 修改用户角色(USER_ADMIN 不可分配 SUPER_ADMIN) | +| PUT | `/api/v1/admin/users/{id}/role` | 修改用户角色(USER_ADMIN 不可分配 SUPER_ADMIN,也不可修改已有 SUPER_ADMIN 的角色状态) | | POST | `/api/v1/admin/users/{id}/approve` | 审批待准入用户 | | POST | `/api/v1/admin/users/{id}/disable` | 封禁用户 | | POST | `/api/v1/admin/users/{id}/enable` | 解封用户 | diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminUserAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminUserAppService.java index b684c744..b3e1bdc7 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminUserAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminUserAppService.java @@ -37,6 +37,8 @@ import java.util.stream.Collectors; public class AdminUserAppService { private static final Set MANAGEABLE_STATUSES = Set.of(UserStatus.ACTIVE, UserStatus.DISABLED); + private static final String SUPER_ADMIN_ROLE = "SUPER_ADMIN"; + private static final String USER_ROLE = "USER"; private final AdminUserSearchRepository adminUserSearchRepository; private final UserAccountRepository userAccountRepository; @@ -83,15 +85,17 @@ public class AdminUserAppService { UserAccount user = loadUser(userId); rejectSystemAccountMutation(user); String normalizedRoleCode = normalizeRoleCode(roleCode); + boolean targetHasSuperAdminRole = userRoleBindingRepository.findByUserId(user.getId()).stream() + .anyMatch(binding -> SUPER_ADMIN_ROLE.equals(binding.getRole().getCode())); - if ("SUPER_ADMIN".equals(normalizedRoleCode) - && (actorPlatformRoles == null || !actorPlatformRoles.contains("SUPER_ADMIN"))) { + if ((SUPER_ADMIN_ROLE.equals(normalizedRoleCode) || targetHasSuperAdminRole) + && (actorPlatformRoles == null || !actorPlatformRoles.contains(SUPER_ADMIN_ROLE))) { throw new DomainForbiddenException("error.admin.user.role.superAdmin.assignDenied"); } userRoleBindingRepository.deleteByUserId(user.getId()); - if (!"USER".equals(normalizedRoleCode)) { + if (!USER_ROLE.equals(normalizedRoleCode)) { Role role = roleRepository.findByCode(normalizedRoleCode) .orElseThrow(() -> new DomainBadRequestException("error.admin.user.role.invalid", roleCode)); userRoleBindingRepository.save(new UserRoleBinding(user.getId(), role)); diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index 25a63127..19189122 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -135,7 +135,7 @@ error.deviceAuth.deviceCode.invalid=Device code expired or invalid error.deviceAuth.deviceCode.used=Device code has already been used error.admin.user.notFound=User not found: {0} error.admin.user.role.invalid=Invalid role: {0} -error.admin.user.role.superAdmin.assignDenied=Only SUPER_ADMIN can assign SUPER_ADMIN role +error.admin.user.role.superAdmin.assignDenied=Only SUPER_ADMIN can mutate SUPER_ADMIN role state error.admin.user.systemAccount.immutable=System accounts cannot be modified from user management error.admin.user.status.invalid=Invalid user status: {0} error.admin.user.status.unsupported=Only ACTIVE or DISABLED status can be managed here diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index 99183ae5..6885dfd3 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -135,7 +135,7 @@ error.deviceAuth.deviceCode.invalid=设备验证码无效或已过期 error.deviceAuth.deviceCode.used=设备验证码已被使用 error.admin.user.notFound=用户不存在:{0} error.admin.user.role.invalid=无效的角色:{0} -error.admin.user.role.superAdmin.assignDenied=只有 SUPER_ADMIN 可以分配 SUPER_ADMIN 角色 +error.admin.user.role.superAdmin.assignDenied=只有 SUPER_ADMIN 可以修改 SUPER_ADMIN 角色状态 error.admin.user.systemAccount.immutable=系统账号不能在用户管理中修改 error.admin.user.status.invalid=无效的用户状态:{0} error.admin.user.status.unsupported=这里只允许管理 ACTIVE 或 DISABLED 状态的用户 diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminUserAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminUserAppServiceTest.java index 7f2cfc22..8296f940 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminUserAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminUserAppServiceTest.java @@ -89,6 +89,20 @@ class AdminUserAppServiceTest { () -> service.updateUserRole("user-1", "SUPER_ADMIN", Set.of("USER_ADMIN"))); } + @Test + void updateUserRole_nonSuperAdminCannotReplaceExistingSuperAdminRole() { + when(userAccountRepository.findById("user-1")) + .thenReturn(Optional.of(user("user-1", "alice", "alice@example.com", UserStatus.ACTIVE))); + when(userRoleBindingRepository.findByUserId("user-1")) + .thenReturn(List.of(new UserRoleBinding("user-1", role("SUPER_ADMIN")))); + + assertThrows(DomainForbiddenException.class, + () -> service.updateUserRole("user-1", "USER", Set.of("USER_ADMIN"))); + + verify(userRoleBindingRepository, never()).deleteByUserId(any()); + verify(userRoleBindingRepository, never()).save(any(UserRoleBinding.class)); + } + @Test void updateUserRole_rejectsSystemAccount() { when(userAccountRepository.findById("builtin-skill-publisher")) From 3b3905be63d550dcbce044f5bf86672b670835f8 Mon Sep 17 00:00:00 2001 From: lhb6540 Date: Tue, 14 Jul 2026 14:42:38 +0800 Subject: [PATCH 36/81] =?UTF-8?q?fix(helm):=20=E4=BF=AE=E6=AD=A3=20Bitnami?= =?UTF-8?q?=20=E4=BE=9D=E8=B5=96=E8=BF=9E=E7=BA=BF=E5=B9=B6=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E5=BA=94=E7=94=A8=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 基于当前 SkillHub 运行时契约和 Bitnami 依赖命名,更新原贡献者提交的 Helm Chart 配置。 - 将 Server 正确连接到实际的 PostgreSQL 和 Redis Service 与 Secret - 支持依赖组件的 existingSecret 名称和自定义密码 key,避免安装时 lookup - 同步 S3、匿名下载、Scanner LLM、公开地址、设备认证和直接认证配置 - 将应用版本和 Chart 版本对齐当前发布版本 - 收紧 Chart 发布触发条件和手动版本选择逻辑 - 增加依赖 Service、Secret 和密码 key 的 CI 语义断言 已通过 Helm lint、九组渲染场景、kubeconform、工作流安全检查和后端应用测试套件。 Signed-off-by: lhb6540 --- .github/workflows/pr-helm-chart.yml | 29 +++++- .github/workflows/publish-chart.yml | 29 +++++- charts/skillhub/Chart.yaml | 4 +- charts/skillhub/README.md | 25 +++-- charts/skillhub/templates/_helpers.tpl | 53 ++++++++-- charts/skillhub/templates/configmap.yaml | 12 +++ .../templates/scanner-deployment.yaml | 8 +- charts/skillhub/templates/secret.yaml | 57 +++++------ .../skillhub/templates/server-deployment.yaml | 96 +++++++++++++++---- charts/skillhub/templates/web-deployment.yaml | 17 +++- charts/skillhub/values.yaml | 20 +++- 11 files changed, 267 insertions(+), 83 deletions(-) diff --git a/.github/workflows/pr-helm-chart.yml b/.github/workflows/pr-helm-chart.yml index b1c38d37..37787c14 100644 --- a/.github/workflows/pr-helm-chart.yml +++ b/.github/workflows/pr-helm-chart.yml @@ -4,6 +4,8 @@ on: pull_request: paths: - charts/skillhub/** + - .github/workflows/pr-helm-chart.yml + - .github/workflows/publish-chart.yml types: - opened - synchronize @@ -30,11 +32,13 @@ jobs: steps: - name: Check out repository uses: actions/checkout@v4 + with: + persist-credentials: false - name: Set up Helm uses: azure/setup-helm@v4 with: - version: latest + version: v3.19.0 - name: Build dependencies run: helm dependency build . @@ -121,11 +125,13 @@ jobs: steps: - name: Check out repository uses: actions/checkout@v4 + with: + persist-credentials: false - name: Set up Helm uses: azure/setup-helm@v4 with: - version: latest + version: v3.19.0 - name: Build dependencies run: helm dependency build . @@ -145,6 +151,25 @@ jobs: exit 1 fi + - name: Validate default dependency wiring + if: ${{ matrix.scenario.name == 'bitnami-default' }} + run: | + helm template test-release . --show-only templates/server-deployment.yaml > server.yaml + grep -Fq 'value: test-release-postgresql' server.yaml + grep -Fq 'value: test-release-redis-master' server.yaml + grep -Fq 'name: test-release-postgresql' server.yaml + grep -Fq 'name: test-release-redis' server.yaml + grep -Fq 'key: password' server.yaml + grep -Fq 'key: redis-password' server.yaml + if grep -Fq 'test-release-skillhub-postgresql' server.yaml; then + echo 'ERROR: Server references a non-existent PostgreSQL service' + exit 1 + fi + if grep -Fq 'test-release-skillhub-redis' server.yaml; then + echo 'ERROR: Server references a non-existent Redis service' + exit 1 + fi + - name: Schema validation (kubeconform) uses: docker://ghcr.io/yannh/kubeconform:latest with: diff --git a/.github/workflows/publish-chart.yml b/.github/workflows/publish-chart.yml index 1e4994ae..13fdf039 100644 --- a/.github/workflows/publish-chart.yml +++ b/.github/workflows/publish-chart.yml @@ -4,6 +4,11 @@ on: release: types: [published] workflow_dispatch: + inputs: + version: + description: Chart and application version (for example, 0.2.13) + required: true + type: string concurrency: group: publish-chart-${{ github.ref }} @@ -15,6 +20,11 @@ permissions: jobs: release: + if: >- + github.event_name == 'workflow_dispatch' || + startsWith(github.ref_name, 'v') || + startsWith(github.ref_name, 'chart-v') || + startsWith(github.ref_name, 'helm-v') runs-on: ubuntu-latest defaults: run: @@ -23,11 +33,13 @@ jobs: steps: - name: Check out repository uses: actions/checkout@v4 + with: + persist-credentials: false - name: Set up Helm uses: azure/setup-helm@v4 with: - version: latest + version: v3.19.0 - name: Verify dependencies run: helm dependency build . @@ -38,12 +50,19 @@ jobs: - name: Parse version from tag id: ver run: | - REF="${{ github.ref_name }}" - # 兼容 v0.2.9、chart-v0.2.9、helm-v0.2.9 三种标签格式 - if [[ "$REF" =~ ^(helm|chart)-v([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then + if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then + VER="${{ inputs.version }}" + elif [[ "${{ github.ref_name }}" =~ ^(helm|chart)-v([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then VER="${BASH_REMATCH[2]}" + elif [[ "${{ github.ref_name }}" =~ ^v([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then + VER="${BASH_REMATCH[1]}" else - VER="${REF#v}" + echo "ERROR: Unsupported release tag: ${{ github.ref_name }}" + exit 1 + fi + if [[ ! "$VER" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "ERROR: Version must use MAJOR.MINOR.PATCH format: $VER" + exit 1 fi echo "version=$VER" >> "$GITHUB_OUTPUT" diff --git a/charts/skillhub/Chart.yaml b/charts/skillhub/Chart.yaml index ce981b6a..60ba84c2 100644 --- a/charts/skillhub/Chart.yaml +++ b/charts/skillhub/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: skillhub description: Self-hosted, open-source agent skill registry for enterprises. type: application -version: 0.3.0 -appVersion: 0.3.0 +version: 0.1.0 +appVersion: 0.2.13 keywords: - skillhub - ai diff --git a/charts/skillhub/README.md b/charts/skillhub/README.md index 19fcefc3..47f8339b 100644 --- a/charts/skillhub/README.md +++ b/charts/skillhub/README.md @@ -24,7 +24,8 @@ kubectl create namespace skillhub helm -n skillhub upgrade -i skillhub ./charts/skillhub \ - --set bootstrapAdmin.password=your-secure-password + --set bootstrapAdmin.password=your-secure-password \ + --set publicBaseUrl=https://skills.example.com ``` ### 高可用模式 @@ -55,22 +56,23 @@ helm -n skillhub upgrade -i skillhub ./charts/skillhub \ ### 使用 existingSecret -通过 `existingSecret` 引用已存在的 Secret 对象,避免在 values 中明文写入密码。该 Secret 必须包含以下 key: +通过 `existingSecret` 引用已存在的 Secret 对象,避免在 values 中明文写入密码。 +内置 PostgreSQL/Redis 使用各自的 Bitnami Secret,不需要复制到该 Secret。 | Key | 必填 | 说明 | |-----|------|------| -| `spring-datasource-url` | 是 | JDBC 连接 URL | -| `spring-datasource-username` | 是 | 数据库用户名 | -| `spring-datasource-password` | 是 | 数据库密码 | -| `redis-password` | 是 | Redis 密码 | -| `redis-sentinel-password` | 否 | Redis Sentinel 密码(sentinel 模式) | +| `spring-datasource-password` | 使用外部 PostgreSQL 时 | 数据库密码 | +| `redis-password` | 使用外部 Redis 时 | Redis 密码 | +| `redis-sentinel-password` | 使用外部 Sentinel 时 | Redis Sentinel 密码 | | `bootstrap-admin-password` | 是 | 初始管理员密码 | +| `skillhub-download-anon-cookie-secret` | 是 | 至少 32 字符的匿名下载 Cookie 签名密钥 | | `oauth2-github-client-id` | 否 | GitHub OAuth2 Client ID | | `oauth2-github-client-secret` | 否 | GitHub OAuth2 Client Secret | | `skill-scanner-llm-api-key` | 否 | Scanner LLM API Key | +| `skill-scanner-llm-base-url` | 否 | Scanner 自定义 LLM API 地址 | | `skill-scanner-llm-model` | 否 | Scanner LLM 模型名称 | -| `s3-access-key` | 否 | S3 Access Key | -| `s3-secret-key` | 否 | S3 Secret Key | +| `skillhub-storage-s3-access-key` | 否 | S3 Access Key | +| `skillhub-storage-s3-secret-key` | 否 | S3 Secret Key | ```bash helm -n skillhub upgrade -i skillhub ./charts/skillhub \ @@ -137,7 +139,11 @@ helm -n skillhub upgrade -i skillhub ./charts/skillhub \ | `s3.enabled` | 启用 S3 | `false` | | `s3.bucket` | Bucket 名称 | `skillhub-storage` | | `s3.endpoint` | S3 端点 | `""` | +| `s3.publicEndpoint` | S3 公网访问端点 | `""` | | `s3.region` | 区域 | `us-east-1` | +| `s3.forcePathStyle` | 强制 path-style 访问 | `true` | +| `s3.disableChunkedEncoding` | 禁用 aws-chunked 编码 | `false` | +| `s3.autoCreateBucket` | 自动创建 Bucket | `false` | | `s3.accessKey` | Access Key | `""` | | `s3.secretKey` | Secret Key | `""` | @@ -158,6 +164,7 @@ helm -n skillhub upgrade -i skillhub ./charts/skillhub \ helm -n skillhub upgrade -i skillhub ./charts/skillhub \ --set ingress.enabled=true \ --set ingress.host=skills.example.com \ + --set publicBaseUrl=https://skills.example.com \ --set ingress.tls.enabled=true \ --set ingress.certManager.enabled=true ``` diff --git a/charts/skillhub/templates/_helpers.tpl b/charts/skillhub/templates/_helpers.tpl index 7315f3b4..4331d770 100644 --- a/charts/skillhub/templates/_helpers.tpl +++ b/charts/skillhub/templates/_helpers.tpl @@ -70,10 +70,38 @@ app.kubernetes.io/component: scanner app.kubernetes.io/component: scanner {{- end }} +{{- /* Bitnami PostgreSQL subchart 完整名称 */}} +{{- define "skillhub.postgresql.fullname" -}} +{{- if .Values.postgresql.fullnameOverride -}} +{{- .Values.postgresql.fullnameOverride | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- $name := default "postgresql" .Values.postgresql.nameOverride -}} +{{- if contains $name .Release.Name -}} +{{- .Release.Name | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} +{{- end -}} +{{- end -}} +{{- end }} + +{{- /* Bitnami Redis subchart 完整名称 */}} +{{- define "skillhub.redis.fullname" -}} +{{- if .Values.redis.fullnameOverride -}} +{{- .Values.redis.fullnameOverride | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- $name := default "redis" .Values.redis.nameOverride -}} +{{- if contains $name .Release.Name -}} +{{- .Release.Name | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} +{{- end -}} +{{- end -}} +{{- end }} + {{- /* PostgreSQL Host */}} {{- define "skillhub.postgresql.host" -}} {{- if .Values.postgresql.enabled -}} -{{- $prefix := printf "%s-postgresql" (include "skillhub.fullname" .) -}} +{{- $prefix := include "skillhub.postgresql.fullname" . -}} {{- if eq .Values.postgresql.architecture "replication" -}} {{- printf "%s-primary" $prefix -}} {{- else -}} @@ -114,12 +142,17 @@ app.kubernetes.io/component: scanner {{- /* PostgreSQL Secret Name */}} {{- define "skillhub.postgresql.secretName" -}} {{- if .Values.postgresql.enabled -}} -{{- printf "%s-postgresql" (include "skillhub.fullname" .) -}} +{{- .Values.postgresql.auth.existingSecret | default (include "skillhub.postgresql.fullname" .) -}} {{- else -}} {{- include "skillhub.secretName" . -}} {{- end -}} {{- end }} +{{- /* PostgreSQL 应用用户密码 Secret key */}} +{{- define "skillhub.postgresql.passwordKey" -}} +{{- .Values.postgresql.auth.secretKeys.userPasswordKey | default "password" -}} +{{- end }} + {{- /* PostgreSQL JDBC URL */}} {{- define "skillhub.jdbcUrl" -}} {{- if .Values.postgresql.enabled -}} @@ -135,8 +168,9 @@ app.kubernetes.io/component: scanner {{- /* Redis Sentinel 节点列表(Redisson 需要具体 pod FQDN,格式: {pod}.{headless-svc}.{ns}.svc.cluster.local) */}} {{- define "skillhub.redis.sentinel.nodes" -}} -{{- $prefix := printf "%s-redis-node" (include "skillhub.fullname" .) -}} -{{- $headless := printf "%s-redis-headless" (include "skillhub.fullname" .) -}} +{{- $fullname := include "skillhub.redis.fullname" . -}} +{{- $prefix := printf "%s-node" $fullname -}} +{{- $headless := printf "%s-headless" $fullname -}} {{- $port := include "skillhub.redis.port" . -}} {{- $replicas := .Values.redis.replica.replicaCount | default 3 | int -}} {{- $nodes := list -}}{{- range $i := until $replicas -}}{{- $nodes = append $nodes (printf "%s-%d.%s.%s.svc.cluster.local:%s" $prefix $i $headless $.Release.Namespace $port) -}}{{- end -}}{{- join "," $nodes -}} @@ -146,9 +180,9 @@ app.kubernetes.io/component: scanner {{- define "skillhub.redis.host" -}} {{- if .Values.redis.enabled -}} {{- if .Values.redis.sentinel.enabled -}} -{{- printf "%s-redis" (include "skillhub.fullname" .) -}} +{{- include "skillhub.redis.fullname" . -}} {{- else -}} -{{- printf "%s-redis-master" (include "skillhub.fullname" .) -}} +{{- printf "%s-master" (include "skillhub.redis.fullname" .) -}} {{- end -}} {{- else -}} {{- .Values.externalRedis.host -}} @@ -171,12 +205,17 @@ app.kubernetes.io/component: scanner {{- /* Redis Password Secret Name */}} {{- define "skillhub.redis.secretName" -}} {{- if .Values.redis.enabled -}} -{{- printf "%s-redis" (include "skillhub.fullname" .) -}} +{{- .Values.redis.auth.existingSecret | default (include "skillhub.redis.fullname" .) -}} {{- else -}} {{- include "skillhub.secretName" . -}} {{- end -}} {{- end }} +{{- /* Redis 密码 Secret key */}} +{{- define "skillhub.redis.passwordKey" -}} +{{- .Values.redis.auth.existingSecretPasswordKey | default "redis-password" -}} +{{- end }} + {{- /* Secret 名称 */}} {{- define "skillhub.secretName" -}} {{- .Values.existingSecret | default (printf "%s-secret" (include "skillhub.fullname" .)) }} diff --git a/charts/skillhub/templates/configmap.yaml b/charts/skillhub/templates/configmap.yaml index aee9b16c..66ca9f32 100644 --- a/charts/skillhub/templates/configmap.yaml +++ b/charts/skillhub/templates/configmap.yaml @@ -22,7 +22,12 @@ data: # S3 配置 s3-bucket: {{ .Values.s3.bucket }} s3-endpoint: {{ .Values.s3.endpoint }} + s3-public-endpoint: {{ .Values.s3.publicEndpoint }} s3-region: {{ .Values.s3.region }} + s3-force-path-style: {{ .Values.s3.forcePathStyle | quote }} + s3-disable-chunked-encoding: {{ .Values.s3.disableChunkedEncoding | quote }} + s3-auto-create-bucket: {{ .Values.s3.autoCreateBucket | quote }} + s3-presign-expiry: {{ .Values.s3.presignExpiry | quote }} {{- end }} # 技能扫描器 @@ -39,3 +44,10 @@ data: # Session session-cookie-secure: {{ .Values.session.cookieSecure | quote }} + + # Public URL and authentication + public-base-url: {{ .Values.publicBaseUrl | quote }} + device-auth-verification-uri: {{ .Values.deviceAuthVerificationUri | quote }} + auth-direct-enabled: {{ .Values.auth.direct.enabled | quote }} + auth-direct-provider: {{ .Values.auth.direct.provider | quote }} + builtin-skills-enabled: {{ .Values.builtinSkills.enabled | quote }} diff --git a/charts/skillhub/templates/scanner-deployment.yaml b/charts/skillhub/templates/scanner-deployment.yaml index 5e466c39..b511465e 100644 --- a/charts/skillhub/templates/scanner-deployment.yaml +++ b/charts/skillhub/templates/scanner-deployment.yaml @@ -17,7 +17,7 @@ spec: labels: {{- include "skillhub.scanner.selectorLabels" . | nindent 8 }} annotations: - checksum/config: {{ toYaml (dict "scanner" .Values.scanner) | sha256sum }} + checksum/config: {{ toYaml (dict "scanner" .Values.scanner "secrets" .Values.secrets "existingSecret" .Values.existingSecret) | sha256sum }} {{- range $key, $val := .Values.scanner.podAnnotations }} {{ $key }}: {{ $val }} {{- end }} @@ -41,6 +41,12 @@ spec: name: {{ include "skillhub.secretName" . }} key: skill-scanner-llm-api-key optional: true + - name: SKILL_SCANNER_LLM_BASE_URL + valueFrom: + secretKeyRef: + name: {{ include "skillhub.secretName" . }} + key: skill-scanner-llm-base-url + optional: true - name: SKILL_SCANNER_LLM_MODEL valueFrom: secretKeyRef: diff --git a/charts/skillhub/templates/secret.yaml b/charts/skillhub/templates/secret.yaml index 648fd9e7..8e28c911 100644 --- a/charts/skillhub/templates/secret.yaml +++ b/charts/skillhub/templates/secret.yaml @@ -5,10 +5,6 @@ SkillHub 应用 Secret */}} {{- if not .Values.existingSecret }} {{- $secretName := include "skillhub.secretName" . }} -{{- $postgresSecretName := include "skillhub.postgresql.secretName" . }} -{{- $redisSecretName := include "skillhub.redis.secretName" . }} -{{- $postgresSecret := (lookup "v1" "Secret" $.Release.Namespace $postgresSecretName) }} -{{- $redisSecret := (lookup "v1" "Secret" $.Release.Namespace $redisSecretName) }} {{- $appSecret := (lookup "v1" "Secret" $.Release.Namespace $secretName) }} apiVersion: v1 kind: Secret @@ -18,41 +14,18 @@ metadata: {{- include "skillhub.labels" . | nindent 4 }} type: Opaque stringData: - # 数据库连接 URL - spring-datasource-url: {{ include "skillhub.jdbcUrl" . | quote }} - spring-datasource-username: {{ include "skillhub.postgresql.username" . | quote }} - - # 数据库密码 - # 优先级: lookup PG Secret → externalDatabase.password → postgresql.auth.password - {{- if and $postgresSecret (index $postgresSecret.data "password") }} - spring-datasource-password: {{ index $postgresSecret.data "password" | b64dec | quote }} - {{- else if not .Values.postgresql.enabled }} + {{- if not .Values.postgresql.enabled }} + # 外部数据库密码;内置 PostgreSQL 直接引用 Bitnami Secret spring-datasource-password: {{ .Values.externalDatabase.password | quote }} - {{- else }} - spring-datasource-password: {{ .Values.secrets.springDatasourcePassword | default .Values.postgresql.auth.password | quote }} {{- end }} - # Redis 密码 - # 优先级: lookup Redis Secret → externalRedis.password → redis.auth.password - {{- if $redisSecret }} - {{- if index $redisSecret.data "redis-password" }} - redis-password: {{ index $redisSecret.data "redis-password" | b64dec | quote }} - {{- end }} - {{- else if not .Values.redis.enabled }} + {{- if not .Values.redis.enabled }} + # 外部 Redis 密码;内置 Redis 直接引用 Bitnami Secret redis-password: {{ .Values.externalRedis.password | default "" | quote }} - {{- else if .Values.redis.auth.password }} - redis-password: {{ .Values.redis.auth.password | quote }} {{- end }} - # Redis Sentinel 密码(仅 sentinel 模式下生效) - # 优先级: lookup Bitnami Secret → sentinelPassword → auth.password → externalRedis.password - {{- if and .Values.redis.enabled .Values.redis.sentinel.enabled }} - {{- if and $redisSecret (index $redisSecret.data "redis-sentinel-password") }} - redis-sentinel-password: {{ index $redisSecret.data "redis-sentinel-password" | b64dec | quote }} - {{- else }} - redis-sentinel-password: {{ .Values.redis.auth.sentinelPassword | default .Values.redis.auth.password | quote }} - {{- end }} - {{- else if and (not .Values.redis.enabled) .Values.externalRedis.sentinel.enabled }} + {{- if and (not .Values.redis.enabled) .Values.externalRedis.sentinel.enabled }} + # 外部 Sentinel 可使用独立密码 redis-sentinel-password: {{ .Values.externalRedis.sentinel.password | default .Values.externalRedis.password | default "" | quote }} {{- end }} # Bootstrap 管理员密码 @@ -67,6 +40,17 @@ stringData: {{- end }} {{- end }} bootstrap-admin-password: {{ $baPwd | quote }} + + # 匿名下载限流 Cookie 签名密钥 + {{- $downloadSecret := .Values.secrets.downloadAnonCookieSecret | default "" }} + {{- if and (not $downloadSecret) $appSecret }} + {{- $downloadSecret = index $appSecret.data "skillhub-download-anon-cookie-secret" | default "" | b64dec }} + {{- end }} + {{- if not $downloadSecret }} + {{- $downloadSecret = randAlphaNum 48 }} + {{- end }} + skillhub-download-anon-cookie-secret: {{ $downloadSecret | quote }} + # OAuth2 GitHub (optional) {{- if .Values.secrets.oauth2GithubClientId }} oauth2-github-client-id: {{ .Values.secrets.oauth2GithubClientId | quote }} @@ -79,15 +63,18 @@ stringData: {{- if .Values.secrets.scannerLlmApiKey }} skill-scanner-llm-api-key: {{ .Values.secrets.scannerLlmApiKey | quote }} {{- end }} + {{- if .Values.secrets.scannerLlmBaseUrl }} + skill-scanner-llm-base-url: {{ .Values.secrets.scannerLlmBaseUrl | quote }} + {{- end }} {{- if .Values.secrets.scannerLlmModel }} skill-scanner-llm-model: {{ .Values.secrets.scannerLlmModel | quote }} {{- end }} # S3 配置 (optional) {{- if .Values.s3.accessKey }} - s3-access-key: {{ .Values.s3.accessKey | quote }} + skillhub-storage-s3-access-key: {{ .Values.s3.accessKey | quote }} {{- end }} {{- if .Values.s3.secretKey }} - s3-secret-key: {{ .Values.s3.secretKey | quote }} + skillhub-storage-s3-secret-key: {{ .Values.s3.secretKey | quote }} {{- end }} {{- end }} diff --git a/charts/skillhub/templates/server-deployment.yaml b/charts/skillhub/templates/server-deployment.yaml index 2363efe2..26345f3d 100644 --- a/charts/skillhub/templates/server-deployment.yaml +++ b/charts/skillhub/templates/server-deployment.yaml @@ -69,20 +69,19 @@ spec: # Database - name: SPRING_DATASOURCE_URL - valueFrom: - secretKeyRef: - name: {{ include "skillhub.secretName" . }} - key: spring-datasource-url + value: {{ include "skillhub.jdbcUrl" . | quote }} - name: SPRING_DATASOURCE_USERNAME - valueFrom: - secretKeyRef: - name: {{ include "skillhub.secretName" . }} - key: spring-datasource-username + value: {{ include "skillhub.postgresql.username" . | quote }} - name: SPRING_DATASOURCE_PASSWORD valueFrom: secretKeyRef: + {{- if .Values.postgresql.enabled }} + name: {{ include "skillhub.postgresql.secretName" . }} + key: {{ include "skillhub.postgresql.passwordKey" . }} + {{- else }} name: {{ include "skillhub.secretName" . }} key: spring-datasource-password + {{- end }} # Redis {{- if and .Values.redis.enabled .Values.redis.sentinel.enabled }} @@ -112,19 +111,24 @@ spec: - name: SPRING_DATA_REDIS_SENTINEL_PASSWORD valueFrom: secretKeyRef: - name: {{ include "skillhub.secretName" . }} - {{- if and (not .Values.redis.enabled) .Values.externalRedis.sentinel.enabled }} - key: redis-sentinel-password + {{- if .Values.redis.enabled }} + name: {{ include "skillhub.redis.secretName" . }} + key: {{ include "skillhub.redis.passwordKey" . }} {{- else }} - key: redis-password + name: {{ include "skillhub.secretName" . }} + key: redis-sentinel-password {{- end }} optional: true {{- else if or .Values.redis.enabled .Values.externalRedis.password }} - name: SPRING_DATA_REDIS_PASSWORD valueFrom: secretKeyRef: + {{- if .Values.redis.enabled }} + name: {{ include "skillhub.redis.secretName" . }} + {{- else }} name: {{ include "skillhub.secretName" . }} - key: redis-password + {{- end }} + key: {{ if .Values.redis.enabled }}{{ include "skillhub.redis.passwordKey" . }}{{ else }}redis-password{{ end }} optional: true {{- end }} @@ -141,32 +145,57 @@ spec: key: skillhub-storage-provider {{- if .Values.s3.enabled }} - - name: SKILLHUB_S3_BUCKET + - name: SKILLHUB_STORAGE_S3_BUCKET valueFrom: configMapKeyRef: name: {{ include "skillhub.fullname" . }}-config key: s3-bucket - - name: SKILLHUB_S3_ENDPOINT + - name: SKILLHUB_STORAGE_S3_ENDPOINT valueFrom: configMapKeyRef: name: {{ include "skillhub.fullname" . }}-config key: s3-endpoint - - name: SKILLHUB_S3_REGION + - name: SKILLHUB_STORAGE_S3_PUBLIC_ENDPOINT + valueFrom: + configMapKeyRef: + name: {{ include "skillhub.fullname" . }}-config + key: s3-public-endpoint + - name: SKILLHUB_STORAGE_S3_REGION valueFrom: configMapKeyRef: name: {{ include "skillhub.fullname" . }}-config key: s3-region - - name: SKILLHUB_S3_ACCESS_KEY + - name: SKILLHUB_STORAGE_S3_FORCE_PATH_STYLE + valueFrom: + configMapKeyRef: + name: {{ include "skillhub.fullname" . }}-config + key: s3-force-path-style + - name: SKILLHUB_STORAGE_S3_DISABLE_CHUNKED_ENCODING + valueFrom: + configMapKeyRef: + name: {{ include "skillhub.fullname" . }}-config + key: s3-disable-chunked-encoding + - name: SKILLHUB_STORAGE_S3_AUTO_CREATE_BUCKET + valueFrom: + configMapKeyRef: + name: {{ include "skillhub.fullname" . }}-config + key: s3-auto-create-bucket + - name: SKILLHUB_STORAGE_S3_PRESIGN_EXPIRY + valueFrom: + configMapKeyRef: + name: {{ include "skillhub.fullname" . }}-config + key: s3-presign-expiry + - name: SKILLHUB_STORAGE_S3_ACCESS_KEY valueFrom: secretKeyRef: name: {{ include "skillhub.secretName" . }} - key: s3-access-key + key: skillhub-storage-s3-access-key optional: true - - name: SKILLHUB_S3_SECRET_KEY + - name: SKILLHUB_STORAGE_S3_SECRET_KEY valueFrom: secretKeyRef: name: {{ include "skillhub.secretName" . }} - key: s3-secret-key + key: skillhub-storage-s3-secret-key optional: true {{- end }} @@ -194,6 +223,33 @@ spec: name: {{ include "skillhub.fullname" . }}-config key: session-cookie-secure + # Public URL and authentication + - name: SKILLHUB_PUBLIC_BASE_URL + valueFrom: + configMapKeyRef: + name: {{ include "skillhub.fullname" . }}-config + key: public-base-url + - name: DEVICE_AUTH_VERIFICATION_URI + valueFrom: + configMapKeyRef: + name: {{ include "skillhub.fullname" . }}-config + key: device-auth-verification-uri + - name: SKILLHUB_AUTH_DIRECT_ENABLED + valueFrom: + configMapKeyRef: + name: {{ include "skillhub.fullname" . }}-config + key: auth-direct-enabled + - name: SKILLHUB_BUILTIN_SKILLS_ENABLED + valueFrom: + configMapKeyRef: + name: {{ include "skillhub.fullname" . }}-config + key: builtin-skills-enabled + - name: SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET + valueFrom: + secretKeyRef: + name: {{ include "skillhub.secretName" . }} + key: skillhub-download-anon-cookie-secret + # Bootstrap Admin - name: BOOTSTRAP_ADMIN_ENABLED valueFrom: diff --git a/charts/skillhub/templates/web-deployment.yaml b/charts/skillhub/templates/web-deployment.yaml index 7392a398..bdbb3e75 100644 --- a/charts/skillhub/templates/web-deployment.yaml +++ b/charts/skillhub/templates/web-deployment.yaml @@ -16,7 +16,7 @@ spec: labels: {{- include "skillhub.web.selectorLabels" . | nindent 8 }} annotations: - checksum/config: {{ toYaml (dict "web" .Values.web) | sha256sum }} + checksum/config: {{ toYaml (dict "web" .Values.web "publicBaseUrl" .Values.publicBaseUrl "auth" .Values.auth) | sha256sum }} {{- range $key, $val := .Values.web.podAnnotations }} {{ $key }}: {{ $val }} {{- end }} @@ -33,6 +33,21 @@ spec: env: - name: SKILLHUB_API_UPSTREAM value: http://{{ include "skillhub.fullname" . }}-server:{{ .Values.server.service.port }} + - name: SKILLHUB_PUBLIC_BASE_URL + valueFrom: + configMapKeyRef: + name: {{ include "skillhub.fullname" . }}-config + key: public-base-url + - name: SKILLHUB_WEB_AUTH_DIRECT_ENABLED + valueFrom: + configMapKeyRef: + name: {{ include "skillhub.fullname" . }}-config + key: auth-direct-enabled + - name: SKILLHUB_WEB_AUTH_DIRECT_PROVIDER + valueFrom: + configMapKeyRef: + name: {{ include "skillhub.fullname" . }}-config + key: auth-direct-provider {{- with .Values.web.extraEnv }} {{- toYaml . | nindent 12 }} {{- end }} diff --git a/charts/skillhub/values.yaml b/charts/skillhub/values.yaml index 3fb16248..42f8ea3b 100644 --- a/charts/skillhub/values.yaml +++ b/charts/skillhub/values.yaml @@ -13,6 +13,18 @@ images: nameOverride: "" fullnameOverride: "" +# 浏览器、CLI 和 OAuth 回调访问的公开地址(不带末尾斜杠) +publicBaseUrl: "" +deviceAuthVerificationUri: "" + +auth: + direct: + enabled: true + provider: local + +builtinSkills: + enabled: true + # ============================================================================ # Ingress 配置 # ============================================================================ @@ -37,7 +49,12 @@ s3: enabled: false bucket: skillhub-storage endpoint: "" + publicEndpoint: "" region: us-east-1 + forcePathStyle: true + disableChunkedEncoding: false + autoCreateBucket: false + presignExpiry: PT10M accessKey: "" secretKey: "" @@ -69,11 +86,12 @@ springProfilesActive: docker existingSecret: "" secrets: - springDatasourcePassword: "" bootstrapAdminPassword: "" + downloadAnonCookieSecret: "" oauth2GithubClientId: "" oauth2GithubClientSecret: "" scannerLlmApiKey: "" + scannerLlmBaseUrl: "" scannerLlmModel: "" # ============================================================================ From 5d379dcaafea189b6761f3b2e62c90afbd802b42 Mon Sep 17 00:00:00 2001 From: lhb6540 Date: Wed, 15 Jul 2026 09:30:08 +0800 Subject: [PATCH 37/81] =?UTF-8?q?fix(helm):=20=E4=BF=AE=E6=AD=A3=E7=BB=84?= =?UTF-8?q?=E4=BB=B6=E5=90=AF=E5=81=9C=E4=B8=8E=E4=BE=9D=E8=B5=96=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E5=B9=B6=E5=A2=9E=E5=8A=A0=20values=20=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在配置进入 Kubernetes 前完成父 Chart 校验,并补齐剩余的依赖配置契约。 - 让 Service、HPA 和 PDB 正确遵循组件启用状态 - 校验 Ingress、自动扩缩容、外部依赖和共享存储的组合配置 - 增加 values.schema.json 和 Helm 配置契约测试并接入 CI - 统一 PostgreSQL Primary 与 Read Replica 的 max_connections 配置 - 修正 Redis Sentinel 节点、依赖等待和独立密码配置 - 允许覆盖依赖等待容器镜像,支持完整私有镜像仓库部署 - 兼容现代与旧式 IngressClass,并支持多域名 TLS 和证书 SAN - 增加 GitOps 稳定 Secret 模式,阻止离线渲染产生随机凭据漂移 - 引用用户可控的 ConfigMap 字符串,并推导 TLS 安全 Cookie 和设备认证默认值 - 补充 Sentinel、RWX 存储、TLS Cookie、PVC 保留、私有镜像和 GitOps 文档 - 增加 Redis 数据密码与 Sentinel 密码分离的应用配置测试 已通过 Helm 严格 lint、渲染场景、配置契约测试、kubeconform、后端测试套件和 Sentinel 专项配置测试。 Signed-off-by: lhb6540 --- .github/workflows/pr-helm-chart.yml | 10 +- .github/workflows/publish-chart.yml | 2 +- charts/skillhub/.helmignore | 3 + charts/skillhub/Chart.yaml | 1 + charts/skillhub/README.md | 244 +++++++++- charts/skillhub/templates/_helpers.tpl | 15 +- charts/skillhub/templates/certificate.yaml | 20 +- charts/skillhub/templates/configmap.yaml | 34 +- charts/skillhub/templates/hpa.yaml | 6 +- charts/skillhub/templates/ingress.yaml | 34 +- charts/skillhub/templates/pdb.yaml | 6 +- charts/skillhub/templates/pvc.yaml | 6 +- .../skillhub/templates/server-deployment.yaml | 24 +- charts/skillhub/templates/services.yaml | 6 +- charts/skillhub/templates/validate.yaml | 88 ++++ .../skillhub/tests/configuration-contracts.sh | 174 +++++++ charts/skillhub/tests/test-values.yaml | 14 + charts/skillhub/values.schema.json | 453 ++++++++++++++++++ charts/skillhub/values.yaml | 39 +- .../resources/application-redis-sentinel.yml | 2 +- ...RedisSentinelProfileConfigurationTest.java | 56 +++ 21 files changed, 1156 insertions(+), 81 deletions(-) create mode 100644 charts/skillhub/templates/validate.yaml create mode 100755 charts/skillhub/tests/configuration-contracts.sh create mode 100644 charts/skillhub/tests/test-values.yaml create mode 100644 charts/skillhub/values.schema.json create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/config/RedisSentinelProfileConfigurationTest.java diff --git a/.github/workflows/pr-helm-chart.yml b/.github/workflows/pr-helm-chart.yml index 37787c14..bc5d8e03 100644 --- a/.github/workflows/pr-helm-chart.yml +++ b/.github/workflows/pr-helm-chart.yml @@ -44,7 +44,10 @@ jobs: run: helm dependency build . - name: Lint chart - run: helm lint . + run: helm lint --strict . -f tests/test-values.yaml + + - name: Validate configuration contracts + run: bash tests/configuration-contracts.sh - name: Validate chart metadata run: | @@ -118,6 +121,7 @@ jobs: --set server.autoscaling.enabled=true --set web.autoscaling.enabled=true --set scanner.autoscaling.enabled=true + --set server.storage.accessMode=ReadWriteMany --set server.podDisruptionBudget.enabled=true --set web.podDisruptionBudget.enabled=true --set scanner.podDisruptionBudget.enabled=true @@ -155,8 +159,8 @@ jobs: if: ${{ matrix.scenario.name == 'bitnami-default' }} run: | helm template test-release . --show-only templates/server-deployment.yaml > server.yaml - grep -Fq 'value: test-release-postgresql' server.yaml - grep -Fq 'value: test-release-redis-master' server.yaml + grep -Fq 'value: "test-release-postgresql"' server.yaml + grep -Fq 'value: "test-release-redis-master"' server.yaml grep -Fq 'name: test-release-postgresql' server.yaml grep -Fq 'name: test-release-redis' server.yaml grep -Fq 'key: password' server.yaml diff --git a/.github/workflows/publish-chart.yml b/.github/workflows/publish-chart.yml index 13fdf039..823524d5 100644 --- a/.github/workflows/publish-chart.yml +++ b/.github/workflows/publish-chart.yml @@ -67,7 +67,7 @@ jobs: echo "version=$VER" >> "$GITHUB_OUTPUT" - name: Lint chart - run: helm lint . + run: helm lint . -f tests/test-values.yaml - name: Package and push run: | diff --git a/charts/skillhub/.helmignore b/charts/skillhub/.helmignore index 55d10a21..47499d20 100644 --- a/charts/skillhub/.helmignore +++ b/charts/skillhub/.helmignore @@ -19,3 +19,6 @@ CLAUDE.md # CI .github/ + +# Source-only contract tests +tests/ diff --git a/charts/skillhub/Chart.yaml b/charts/skillhub/Chart.yaml index 60ba84c2..3f125dd3 100644 --- a/charts/skillhub/Chart.yaml +++ b/charts/skillhub/Chart.yaml @@ -9,6 +9,7 @@ keywords: - ai - skills home: https://github.com/iflytek/skillhub +icon: https://raw.githubusercontent.com/iflytek/skillhub/main/skillhub-logo.svg sources: - https://github.com/iflytek/skillhub diff --git a/charts/skillhub/README.md b/charts/skillhub/README.md index 47f8339b..802c233b 100644 --- a/charts/skillhub/README.md +++ b/charts/skillhub/README.md @@ -7,7 +7,7 @@ - **微服务架构**:Server(Spring Boot)、Web(Nginx)、Scanner 分离部署 - **高可用**:支持 HPA 自动扩缩容、PDB Pod 中断预算 - **数据层**:使用 Bitnami PostgreSQL/Redis,支持主从复制、哨兵模式 -- **安全**:TLS 证书管理、Secret 密码保护、NetworkPolicy +- **安全**:TLS 证书管理、Secret 密码保护;Bitnami 数据组件默认提供 NetworkPolicy - **可观测性**:内置 Prometheus metrics exporter ## 快速开始 @@ -20,20 +20,47 @@ ### 安装 +先创建受保护的 `values-production.yaml`。以下值必须替换为实际随机强密码: + +```yaml +secrets: + allowAutoGenerated: false + bootstrapAdminPassword: "<固定管理员密码>" + downloadAnonCookieSecret: "<至少32字符的固定随机值>" + +postgresql: + auth: + postgresPassword: "<固定PostgreSQL管理员密码>" + password: "<固定skillhub用户密码>" + +redis: + auth: + password: "<固定Redis密码>" +``` + ```bash kubectl create namespace skillhub helm -n skillhub upgrade -i skillhub ./charts/skillhub \ - --set bootstrapAdmin.password=your-secure-password \ + -f values-production.yaml \ --set publicBaseUrl=https://skills.example.com ``` +未显式设置 `deviceAuthVerificationUri` 时,Chart 使用 +`/cli/auth`。所有 values 会先经过 `values.schema.json` 和跨字段校验, +无效的组件、Ingress、HPA 与存储组合会在安装前失败。 + +> **Ingress values 迁移:** 当前版本只支持结构化的 `ingress.hosts[]` 和 +> `ingress.tls[]`。旧的 `ingress.host`、`ingress.tls.enabled` 与 +> `ingress.tls.secretName` 不再接受,升级前必须改成本文 Ingress 示例中的数组结构。 + ### 高可用模式 ```bash helm -n skillhub upgrade -i skillhub ./charts/skillhub \ - --set bootstrapAdmin.password=your-secure-password \ + -f values-production.yaml \ --set postgresql.architecture=replication \ + --set postgresql.auth.replicationPassword=your-replication-password \ --set redis.architecture=replication ``` @@ -41,7 +68,7 @@ helm -n skillhub upgrade -i skillhub ./charts/skillhub \ ```bash helm -n skillhub upgrade -i skillhub ./charts/skillhub \ - --set bootstrapAdmin.password=your-secure-password \ + -f values-production.yaml \ --set postgresql.enabled=false \ --set redis.enabled=false \ --set externalDatabase.host=postgres.example.com \ @@ -76,9 +103,37 @@ helm -n skillhub upgrade -i skillhub ./charts/skillhub \ ```bash helm -n skillhub upgrade -i skillhub ./charts/skillhub \ + -f values-production.yaml \ --set existingSecret=my-custom-secret ``` +### GitOps 稳定 Secret + +Argo CD 等 GitOps 工具使用离线 `helm template`,无法通过 Helm `lookup` 读取集群 +中已有的 Secret。Bitnami 子 Chart 和父 Chart 的空密码会在每次渲染时重新随机 +生成。Chart 默认禁止自动生成并要求提供固定值: + +```yaml +secrets: + allowAutoGenerated: false + bootstrapAdminPassword: "<固定管理员密码>" + downloadAnonCookieSecret: "<至少32字符的固定随机值>" + +postgresql: + auth: + postgresPassword: "<固定PostgreSQL管理员密码>" + password: "<固定skillhub用户密码>" + # replication 架构还必须配置 replicationPassword + +redis: + auth: + password: "<固定Redis密码>" +``` + +也可以为三个组件分别配置 `existingSecret`。`allowAutoGenerated=false` 不会生成 +可预测密码,而是在任何随机密码缺失时终止渲染并指出具体配置项。敏感值应放在 +受保护的 values、External Secrets、Sealed Secrets 或密钥注入插件中。 + ## 配置参考 ### 副本数配置 @@ -92,11 +147,16 @@ helm -n skillhub upgrade -i skillhub ./charts/skillhub \ ```bash # 差异化副本配置 helm -n skillhub upgrade -i skillhub ./charts/skillhub \ + -f values-production.yaml \ --set server.replicaCount=3 \ + --set server.storage.accessMode=ReadWriteMany \ --set web.replicaCount=2 \ --set scanner.replicaCount=1 ``` +本地存储运行多个 Server 副本时,必须显式设置 `ReadWriteMany`,并使用支持 RWX +的 StorageClass。无法提供 RWX 时应改用 S3。 + ### 服务配置 | 参数 | 描述 | 默认值 | @@ -107,6 +167,80 @@ helm -n skillhub upgrade -i skillhub ./charts/skillhub \ | `web.service.port` | Web 端口 | `80` | | `scanner.service.port` | Scanner 端口 | `8000` | +### 私有镜像仓库 + +使用私有仓库时,需要分别覆盖 SkillHub 镜像、依赖等待镜像和 Bitnami 子 Chart +镜像。以下示例中的数据库镜像标签均为明确版本,不使用 `latest`: + +```yaml +global: + imagePullSecrets: + - private-registry + security: + allowInsecureImages: true + +images: + registry: registry.example.com/library + tag: v0.2.13 + pullPolicy: IfNotPresent + +server: + dependencyWait: + image: + registry: registry.example.com + repository: library/busybox + tag: "1.37" + pullPolicy: IfNotPresent + imagePullSecrets: + - name: private-registry + +web: + imagePullSecrets: + - name: private-registry + +scanner: + imagePullSecrets: + - name: private-registry + +postgresql: + image: + registry: registry.example.com + repository: library/postgresql + tag: 18.4.0 + metrics: + image: + registry: registry.example.com + repository: library/postgres-exporter + tag: 0.20.1 + +redis: + image: + registry: registry.example.com + repository: library/redis + tag: 8.8.0 + sentinel: + image: + registry: registry.example.com + repository: library/redis-sentinel + tag: 8.8.0 + metrics: + image: + registry: registry.example.com + repository: library/redis-exporter + tag: 1.86.0 +``` + +`global.security.allowInsecureImages` 是 Bitnami 对自定义镜像仓库和镜像名称的校验 +开关,并不表示使用不安全的 HTTP 仓库。先在目标 namespace 创建拉取凭据: + +```bash +kubectl create secret docker-registry private-registry \ + -n skillhub \ + --docker-server=registry.example.com \ + --docker-username='<用户名>' \ + --docker-password='<密码>' +``` + ### 数据库配置 | 参数 | 描述 | 默认值 | @@ -116,18 +250,62 @@ helm -n skillhub upgrade -i skillhub ./charts/skillhub \ | `redis.enabled` | 启用内置 Redis | `true` | | `redis.architecture` | 架构模式 | `standalone` | +#### 数据库架构支持边界 + +以下内置数据库目标架构已完成独立 namespace 的全新安装和运行时验证: + +| 数据组件 | 已验证架构 | 运行时验证 | +|----------|------------|------------| +| PostgreSQL | standalone | Server 连接、Flyway 和应用启动 | +| PostgreSQL | replication | 1 Primary + 2 Read Replicas,两个副本均处于 recovery,流复制状态为 `streaming` | +| Redis | standalone | Server 读写和应用启动 | +| Redis | replication | 1 Master + 2 Replicas,角色和数据复制正常 | +| Redis | replication + Sentinel | 3 个 Sentinel 节点 master 视图一致,Server 可通过 Sentinel 读写 | + +上述支持表示 Chart 能够全新部署目标架构,并为 SkillHub 配置正确的写节点或 +Sentinel 地址。Chart **不负责数据库架构切换时的数据迁移**,也不承诺仅修改 +`architecture` 或 `sentinel.enabled` 就能保留已有数据。已有数据的 PostgreSQL +standalone → replication、Redis standalone/replication → Sentinel 等切换,必须由 +运维人员在 Chart 之外完成备份、恢复、PVC 复用或其他迁移方案。 + +### Redis Sentinel + +内置 Sentinel 使用 Bitnami Redis 的同一份密码同时保护 Redis 数据节点和 +Sentinel。节点地址由副本数自动生成,不需要手动配置: + +```bash +helm -n skillhub upgrade -i skillhub ./charts/skillhub \ + -f values-production.yaml \ + --set redis.architecture=replication \ + --set redis.sentinel.enabled=true +``` + +外部 Sentinel 必须提供至少一个 `host:port` 节点。Redis 数据密码和 Sentinel +密码可以不同;使用 `existingSecret` 时分别对应 `redis-password` 和 +`redis-sentinel-password`: + +```bash +helm -n skillhub upgrade -i skillhub ./charts/skillhub \ + -f values-production.yaml \ + --set redis.enabled=false \ + --set externalRedis.password=redis-password \ + --set externalRedis.sentinel.enabled=true \ + --set externalRedis.sentinel.password=sentinel-password \ + --set-json 'externalRedis.sentinel.nodes=["sentinel-0.example.com:26379","sentinel-1.example.com:26379"]' +``` + ### 存储配置 | 参数 | 描述 | 默认值 | |------|------|--------| -| `server.storage.accessMode` | 访问模式:ReadWriteOnce(单副本)或 ReadWriteMany(多副本) | `""` | +| `server.storage.accessMode` | 留空时单副本使用 ReadWriteOnce;多副本必须显式使用 ReadWriteMany | `""` | | `server.storage.size` | PVC 大小 | `10Gi` | | `server.storage.storageClassName` | StorageClass | `""` | ```bash # 默认使用本地 PVC helm -n skillhub upgrade -i skillhub ./charts/skillhub \ - --set bootstrapAdmin.password=your-secure-password + -f values-production.yaml ``` ### S3 对象存储 @@ -149,7 +327,7 @@ helm -n skillhub upgrade -i skillhub ./charts/skillhub \ ```bash helm -n skillhub upgrade -i skillhub ./charts/skillhub \ - --set bootstrapAdmin.password=your-secure-password \ + -f values-production.yaml \ --set s3.enabled=true \ --set s3.bucket=your-bucket \ --set s3.endpoint=s3.amazonaws.com \ @@ -162,28 +340,74 @@ helm -n skillhub upgrade -i skillhub ./charts/skillhub \ ```bash helm -n skillhub upgrade -i skillhub ./charts/skillhub \ + -f values-production.yaml \ --set ingress.enabled=true \ - --set ingress.host=skills.example.com \ + --set-json 'ingress.hosts=[{"host":"skills.example.com","paths":[{"path":"/","pathType":"Prefix"}]}]' \ + --set-json 'ingress.tls=[{"hosts":["skills.example.com"],"secretName":"skills-tls"}]' \ --set publicBaseUrl=https://skills.example.com \ - --set ingress.tls.enabled=true \ --set ingress.certManager.enabled=true ``` +配置非空 `ingress.tls` 或启用 `ingress.certManager` 时,Chart 会自动将 Session Cookie +标记为 Secure。Ingress 要求 Server 和 Web Service 均保持启用。 + +`ingress.className` 和旧式 `kubernetes.io/ingress.class` annotation 均受支持, +可以任选其一,也可以同时输出。仅使用旧式 annotation 时将 `className` 留空: + +```yaml +ingress: + enabled: true + className: "" + annotations: + kubernetes.io/ingress.class: alb + alb.ingress.kubernetes.io/listen-ports: '[{"HTTPS":6443}]' +``` + +`hosts` 是至少包含一个条目的对象数组。Chart 自动将 `/api` 转发给 Server, +`hosts[].paths` 中的路径转发给 Web,因此 `/api` 是保留路径。`tls` 同样是数组, +可为不同证书分别配置域名;TLS 域名会写入 cert-manager Certificate SAN: + +```yaml +ingress: + hosts: + - host: skills.example.com + paths: + - path: / + pathType: Prefix + - host: skills.internal.example.com + paths: + - path: / + pathType: Prefix + tls: + - hosts: + - skills.example.com + - skills.internal.example.com + secretName: skills-tls +``` + ### 自动扩缩容 ```bash helm -n skillhub upgrade -i skillhub ./charts/skillhub \ + -f values-production.yaml \ --set server.autoscaling.enabled=true \ --set server.autoscaling.minReplicas=2 \ - --set server.autoscaling.maxReplicas=10 + --set server.autoscaling.maxReplicas=10 \ + --set server.storage.accessMode=ReadWriteMany ``` +每个 HPA 至少需要一个非零 CPU 或内存利用率目标。本地存储的 Server HPA 同样 +要求 RWX;也可以启用 S3 来避免共享 PVC。 + ## 卸载 ```bash helm -n skillhub uninstall skillhub ``` +Server 数据 PVC 带有 `helm.sh/resource-policy: keep`,卸载 release 后仍会保留, +需要确认数据不再使用后手动删除。 + ## 依赖 | 依赖 | 版本 | diff --git a/charts/skillhub/templates/_helpers.tpl b/charts/skillhub/templates/_helpers.tpl index 4331d770..d287cfeb 100644 --- a/charts/skillhub/templates/_helpers.tpl +++ b/charts/skillhub/templates/_helpers.tpl @@ -171,9 +171,10 @@ app.kubernetes.io/component: scanner {{- $fullname := include "skillhub.redis.fullname" . -}} {{- $prefix := printf "%s-node" $fullname -}} {{- $headless := printf "%s-headless" $fullname -}} -{{- $port := include "skillhub.redis.port" . -}} +{{- /* Headless Service DNS resolves directly to pod IPs, so use the container port. */ -}} +{{- $port := .Values.redis.sentinel.containerPorts.sentinel | default 26379 -}} {{- $replicas := .Values.redis.replica.replicaCount | default 3 | int -}} -{{- $nodes := list -}}{{- range $i := until $replicas -}}{{- $nodes = append $nodes (printf "%s-%d.%s.%s.svc.cluster.local:%s" $prefix $i $headless $.Release.Namespace $port) -}}{{- end -}}{{- join "," $nodes -}} +{{- $nodes := list -}}{{- range $i := until $replicas -}}{{- $nodes = append $nodes (printf "%s-%d.%s.%s.svc.cluster.local:%v" $prefix $i $headless $.Release.Namespace $port) -}}{{- end -}}{{- join "," $nodes -}} {{- end }} {{- /* Redis Host */}} @@ -198,8 +199,13 @@ app.kubernetes.io/component: scanner {{- print "6379" -}} {{- end -}} {{- else -}} +{{- if .Values.externalRedis.sentinel.enabled -}} +{{- $node := first .Values.externalRedis.sentinel.nodes -}} +{{- last (splitList ":" $node) -}} +{{- else -}} {{- .Values.externalRedis.port | default 6379 | int -}} {{- end -}} +{{- end -}} {{- end }} {{- /* Redis Password Secret Name */}} @@ -235,6 +241,11 @@ app.kubernetes.io/component: scanner {{- if .Values.redis.enabled -}} {{- include "skillhub.redis.host" . -}} {{- else -}} +{{- if .Values.externalRedis.sentinel.enabled -}} +{{- $node := first .Values.externalRedis.sentinel.nodes -}} +{{- first (splitList ":" $node) -}} +{{- else -}} {{- .Values.externalRedis.host -}} {{- end -}} +{{- end -}} {{- end }} diff --git a/charts/skillhub/templates/certificate.yaml b/charts/skillhub/templates/certificate.yaml index bd886df4..e5675edc 100644 --- a/charts/skillhub/templates/certificate.yaml +++ b/charts/skillhub/templates/certificate.yaml @@ -1,18 +1,24 @@ -{{- $secretName := .Values.ingress.tls.secretName | default (printf "%s-tls" (include "skillhub.fullname" .)) }} {{- if and .Values.ingress.enabled .Values.ingress.certManager.enabled }} +{{- range $index, $tls := .Values.ingress.tls }} +{{- if $index }} +--- +{{- end }} apiVersion: cert-manager.io/v1 kind: Certificate metadata: - name: {{ $secretName }}-cert + name: {{ $tls.secretName }}-cert labels: - {{- include "skillhub.labels" . | nindent 4 }} + {{- include "skillhub.labels" $ | nindent 4 }} spec: - secretName: {{ $secretName }} + secretName: {{ $tls.secretName }} duration: 2160h renewBefore: 360h dnsNames: - - {{ .Values.ingress.host }} + {{- range $tls.hosts }} + - {{ . | quote }} + {{- end }} issuerRef: - name: {{ .Values.ingress.certManager.issuerName }} - kind: {{ .Values.ingress.certManager.issuerKind }} + name: {{ $.Values.ingress.certManager.issuerName | quote }} + kind: {{ $.Values.ingress.certManager.issuerKind | quote }} +{{- end }} {{- end }} diff --git a/charts/skillhub/templates/configmap.yaml b/charts/skillhub/templates/configmap.yaml index 66ca9f32..78e9e016 100644 --- a/charts/skillhub/templates/configmap.yaml +++ b/charts/skillhub/templates/configmap.yaml @@ -9,21 +9,21 @@ metadata: {{- include "skillhub.labels" . | nindent 4 }} data: # Redis 配置 - redis-host: {{ include "skillhub.redis.host" . }} + redis-host: {{ include "skillhub.redis.host" . | quote }} redis-port: {{ include "skillhub.redis.port" . | quote }} # 存储路径 - storage-base-path: /var/lib/skillhub/storage + storage-base-path: "/var/lib/skillhub/storage" # 存储提供者: local | s3 - skillhub-storage-provider: {{ if .Values.s3.enabled }}s3{{ else }}local{{ end }} + skillhub-storage-provider: {{ if .Values.s3.enabled }}"s3"{{ else }}"local"{{ end }} {{- if .Values.s3.enabled }} # S3 配置 - s3-bucket: {{ .Values.s3.bucket }} - s3-endpoint: {{ .Values.s3.endpoint }} - s3-public-endpoint: {{ .Values.s3.publicEndpoint }} - s3-region: {{ .Values.s3.region }} + s3-bucket: {{ .Values.s3.bucket | quote }} + s3-endpoint: {{ .Values.s3.endpoint | quote }} + s3-public-endpoint: {{ .Values.s3.publicEndpoint | quote }} + s3-region: {{ .Values.s3.region | quote }} s3-force-path-style: {{ .Values.s3.forcePathStyle | quote }} s3-disable-chunked-encoding: {{ .Values.s3.disableChunkedEncoding | quote }} s3-auto-create-bucket: {{ .Values.s3.autoCreateBucket | quote }} @@ -32,22 +32,26 @@ data: # 技能扫描器 skill-scanner-enabled: {{ .Values.scanner.enabled | quote }} - skill-scanner-url: http://{{ include "skillhub.fullname" . }}-scanner:{{ .Values.scanner.service.port }} - skill-scanner-mode: upload + skill-scanner-url: {{ printf "http://%s-scanner:%v" (include "skillhub.fullname" .) .Values.scanner.service.port | quote }} + skill-scanner-mode: "upload" # Bootstrap 管理员 bootstrap-admin-enabled: {{ .Values.bootstrapAdmin.enabled | quote }} - bootstrap-admin-user-id: {{ .Values.bootstrapAdmin.userId }} - bootstrap-admin-username: {{ .Values.bootstrapAdmin.username }} - bootstrap-admin-display-name: {{ .Values.bootstrapAdmin.displayName }} - bootstrap-admin-email: {{ .Values.bootstrapAdmin.email }} + bootstrap-admin-user-id: {{ .Values.bootstrapAdmin.userId | quote }} + bootstrap-admin-username: {{ .Values.bootstrapAdmin.username | quote }} + bootstrap-admin-display-name: {{ .Values.bootstrapAdmin.displayName | quote }} + bootstrap-admin-email: {{ .Values.bootstrapAdmin.email | quote }} # Session - session-cookie-secure: {{ .Values.session.cookieSecure | quote }} + session-cookie-secure: {{ or .Values.session.cookieSecure (not (empty .Values.ingress.tls)) .Values.ingress.certManager.enabled | quote }} # Public URL and authentication public-base-url: {{ .Values.publicBaseUrl | quote }} - device-auth-verification-uri: {{ .Values.deviceAuthVerificationUri | quote }} + {{- $deviceAuthVerificationUri := .Values.deviceAuthVerificationUri }} + {{- if and (not $deviceAuthVerificationUri) .Values.publicBaseUrl }} + {{- $deviceAuthVerificationUri = printf "%s/cli/auth" (trimSuffix "/" .Values.publicBaseUrl) }} + {{- end }} + device-auth-verification-uri: {{ $deviceAuthVerificationUri | quote }} auth-direct-enabled: {{ .Values.auth.direct.enabled | quote }} auth-direct-provider: {{ .Values.auth.direct.provider | quote }} builtin-skills-enabled: {{ .Values.builtinSkills.enabled | quote }} diff --git a/charts/skillhub/templates/hpa.yaml b/charts/skillhub/templates/hpa.yaml index 15fca857..fd2d0600 100644 --- a/charts/skillhub/templates/hpa.yaml +++ b/charts/skillhub/templates/hpa.yaml @@ -1,6 +1,10 @@ {{- range $name := list "server" "web" "scanner" }} {{- $component := index $.Values $name }} -{{- if and (default true $component.enabled) $component.autoscaling.enabled }} +{{- $enabled := true }} +{{- if hasKey $component "enabled" }} +{{- $enabled = $component.enabled }} +{{- end }} +{{- if and $enabled $component.autoscaling.enabled }} --- apiVersion: autoscaling/v2 kind: HorizontalPodAutoscaler diff --git a/charts/skillhub/templates/ingress.yaml b/charts/skillhub/templates/ingress.yaml index 8c95928c..60512116 100644 --- a/charts/skillhub/templates/ingress.yaml +++ b/charts/skillhub/templates/ingress.yaml @@ -1,39 +1,43 @@ {{- if .Values.ingress.enabled }} -{{- $secretName := .Values.ingress.tls.secretName | default (printf "%s-tls" (include "skillhub.fullname" .)) }} +{{- $hosts := .Values.ingress.hosts }} apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: {{ include "skillhub.fullname" . }} labels: {{- include "skillhub.labels" . | nindent 4 }} + {{- if .Values.ingress.annotations }} annotations: - {{- if .Values.ingress.annotations }} {{- toYaml .Values.ingress.annotations | nindent 4 }} - {{- end }} + {{- end }} spec: - ingressClassName: {{ .Values.ingress.className }} - {{- if or .Values.ingress.tls.enabled .Values.ingress.certManager.enabled }} + {{- if .Values.ingress.className }} + ingressClassName: {{ .Values.ingress.className | quote }} + {{- end }} + {{- if .Values.ingress.tls }} tls: - - hosts: - - {{ .Values.ingress.host }} - secretName: {{ $secretName }} + {{- toYaml .Values.ingress.tls | nindent 4 }} {{- end }} rules: - - host: {{ .Values.ingress.host }} + {{- range $host := $hosts }} + - host: {{ $host.host | quote }} http: paths: - path: /api pathType: Prefix backend: service: - name: {{ include "skillhub.fullname" . }}-server + name: {{ include "skillhub.fullname" $ }}-server port: - number: {{ .Values.server.service.port }} - - path: / - pathType: Prefix + number: {{ $.Values.server.service.port }} + {{- range $path := $host.paths }} + - path: {{ $path.path | quote }} + pathType: {{ $path.pathType }} backend: service: - name: {{ include "skillhub.fullname" . }}-web + name: {{ include "skillhub.fullname" $ }}-web port: - number: {{ .Values.web.service.port }} + number: {{ $.Values.web.service.port }} + {{- end }} + {{- end }} {{- end }} diff --git a/charts/skillhub/templates/pdb.yaml b/charts/skillhub/templates/pdb.yaml index f5138066..b8869158 100644 --- a/charts/skillhub/templates/pdb.yaml +++ b/charts/skillhub/templates/pdb.yaml @@ -1,6 +1,10 @@ {{- range $name := list "server" "web" "scanner" }} {{- $component := index $.Values $name }} -{{- if and (default true $component.enabled) $component.podDisruptionBudget.enabled }} +{{- $enabled := true }} +{{- if hasKey $component "enabled" }} +{{- $enabled = $component.enabled }} +{{- end }} +{{- if and $enabled $component.podDisruptionBudget.enabled }} --- apiVersion: policy/v1 kind: PodDisruptionBudget diff --git a/charts/skillhub/templates/pvc.yaml b/charts/skillhub/templates/pvc.yaml index 3881cbbe..cdc48ad3 100644 --- a/charts/skillhub/templates/pvc.yaml +++ b/charts/skillhub/templates/pvc.yaml @@ -10,16 +10,12 @@ metadata: spec: {{- $accessMode := .Values.server.storage.accessMode }} {{- if not $accessMode }} - {{- if or (gt (.Values.server.replicaCount | int) 1) (and .Values.server.autoscaling.enabled (gt (.Values.server.autoscaling.maxReplicas | int) 1)) }} - {{- $accessMode = "ReadWriteMany" }} - {{- else }} {{- $accessMode = "ReadWriteOnce" }} {{- end }} - {{- end }} accessModes: - {{ $accessMode }} {{- if .Values.server.storage.storageClassName }} - storageClassName: {{ .Values.server.storage.storageClassName }} + storageClassName: {{ .Values.server.storage.storageClassName | quote }} {{- end }} resources: requests: diff --git a/charts/skillhub/templates/server-deployment.yaml b/charts/skillhub/templates/server-deployment.yaml index 26345f3d..a39d9d85 100644 --- a/charts/skillhub/templates/server-deployment.yaml +++ b/charts/skillhub/templates/server-deployment.yaml @@ -30,14 +30,15 @@ spec: {{- end }} initContainers: - name: wait-for-dependencies - image: busybox:1.37 + image: {{ printf "%s/%s:%s" .Values.server.dependencyWait.image.registry .Values.server.dependencyWait.image.repository .Values.server.dependencyWait.image.tag | quote }} + imagePullPolicy: {{ .Values.server.dependencyWait.image.pullPolicy }} env: - name: DB_HOST - value: {{ include "skillhub.postgresql.serviceName" . }} + value: {{ include "skillhub.postgresql.serviceName" . | quote }} - name: DB_PORT value: {{ include "skillhub.postgresql.port" . | quote }} - name: REDIS_HOST - value: {{ include "skillhub.redis.serviceName" . }} + value: {{ include "skillhub.redis.serviceName" . | quote }} - name: REDIS_PORT value: {{ include "skillhub.redis.port" . | quote }} command: @@ -65,7 +66,7 @@ spec: {{- else if and (not .Values.redis.enabled) .Values.externalRedis.sentinel.enabled }} {{- $profiles = printf "%s,redis-sentinel" $profiles }} {{- end }} - value: {{ $profiles }} + value: {{ $profiles | quote }} # Database - name: SPRING_DATASOURCE_URL @@ -88,12 +89,12 @@ spec: - name: SPRING_DATA_REDIS_SENTINEL_MASTER value: {{ .Values.redis.sentinel.masterSet | default "mymaster" | quote }} - name: SPRING_DATA_REDIS_SENTINEL_NODES - value: {{ include "skillhub.redis.sentinel.nodes" . }} + value: {{ include "skillhub.redis.sentinel.nodes" . | quote }} {{- else if and (not .Values.redis.enabled) .Values.externalRedis.sentinel.enabled }} - name: SPRING_DATA_REDIS_SENTINEL_MASTER value: {{ .Values.externalRedis.sentinel.masterSet | default "mymaster" | quote }} - name: SPRING_DATA_REDIS_SENTINEL_NODES - value: {{ join "," .Values.externalRedis.sentinel.nodes }} + value: {{ join "," .Values.externalRedis.sentinel.nodes | quote }} {{- else }} - name: SPRING_DATA_REDIS_HOST valueFrom: @@ -108,6 +109,17 @@ spec: {{- end }} {{- if or (and .Values.redis.enabled .Values.redis.sentinel.enabled) (and (not .Values.redis.enabled) .Values.externalRedis.sentinel.enabled) }} + - name: SPRING_DATA_REDIS_PASSWORD + valueFrom: + secretKeyRef: + {{- if .Values.redis.enabled }} + name: {{ include "skillhub.redis.secretName" . }} + key: {{ include "skillhub.redis.passwordKey" . }} + {{- else }} + name: {{ include "skillhub.secretName" . }} + key: redis-password + {{- end }} + optional: true - name: SPRING_DATA_REDIS_SENTINEL_PASSWORD valueFrom: secretKeyRef: diff --git a/charts/skillhub/templates/services.yaml b/charts/skillhub/templates/services.yaml index f3b2be89..94c73e34 100644 --- a/charts/skillhub/templates/services.yaml +++ b/charts/skillhub/templates/services.yaml @@ -6,7 +6,11 @@ SkillHub Service 资源 {{- range $name := list "server" "web" }} {{- $component := index $.Values $name }} -{{- if $component.service.enabled }} +{{- $enabled := true }} +{{- if hasKey $component "enabled" }} +{{- $enabled = $component.enabled }} +{{- end }} +{{- if and $enabled $component.service.enabled }} --- apiVersion: v1 kind: Service diff --git a/charts/skillhub/templates/validate.yaml b/charts/skillhub/templates/validate.yaml new file mode 100644 index 00000000..e5a34aa2 --- /dev/null +++ b/charts/skillhub/templates/validate.yaml @@ -0,0 +1,88 @@ +{{- /* Cross-field validation that JSON Schema cannot express reliably. */ -}} +{{- if not .Values.server.enabled -}} +{{- fail "server.enabled=false is unsupported because the bundled web component requires the SkillHub server" -}} +{{- end -}} +{{- if and .Values.auth.direct.enabled (not .Values.auth.direct.provider) -}} +{{- fail "auth.direct.enabled=true requires auth.direct.provider" -}} +{{- end -}} + +{{- if and .Values.ingress.enabled (not .Values.server.service.enabled) -}} +{{- fail "ingress.enabled=true requires server.service.enabled=true" -}} +{{- end -}} +{{- if and .Values.ingress.enabled (not .Values.web.service.enabled) -}} +{{- fail "ingress.enabled=true requires web.service.enabled=true" -}} +{{- end -}} +{{- if and .Values.ingress.enabled .Values.ingress.certManager.enabled (not .Values.ingress.tls) -}} +{{- fail "ingress.certManager.enabled=true requires at least one ingress.tls entry" -}} +{{- end -}} +{{- range $host := .Values.ingress.hosts -}} +{{- range $path := $host.paths -}} +{{- if regexMatch "^/api(?:/|$)" $path.path -}} +{{- fail "ingress.hosts[].paths reserves /api for the SkillHub server" -}} +{{- end -}} +{{- end -}} +{{- end -}} + +{{- range $name := list "server" "web" "scanner" -}} +{{- $component := index $.Values $name -}} +{{- $enabled := true -}} +{{- if hasKey $component "enabled" -}} +{{- $enabled = $component.enabled -}} +{{- end -}} +{{- if and $enabled $component.autoscaling.enabled -}} +{{- if gt ($component.autoscaling.minReplicas | int) ($component.autoscaling.maxReplicas | int) -}} +{{- fail (printf "%s.autoscaling.minReplicas must not exceed maxReplicas" $name) -}} +{{- end -}} +{{- if and (not $component.autoscaling.targetCPUUtilizationPercentage) (not $component.autoscaling.targetMemoryUtilizationPercentage) -}} +{{- fail (printf "%s.autoscaling requires at least one CPU or memory utilization target" $name) -}} +{{- end -}} +{{- end -}} +{{- end -}} + +{{- $localStorageReplicas := .Values.server.replicaCount | int -}} +{{- if .Values.server.autoscaling.enabled -}} +{{- $localStorageReplicas = .Values.server.autoscaling.maxReplicas | int -}} +{{- end -}} +{{- if and (not .Values.s3.enabled) (gt $localStorageReplicas 1) -}} +{{- if not .Values.server.storage.accessMode -}} +{{- fail "local storage with multiple server replicas requires server.storage.accessMode=ReadWriteMany and an RWX-capable StorageClass; use S3 otherwise" -}} +{{- end -}} +{{- if ne .Values.server.storage.accessMode "ReadWriteMany" -}} +{{- fail "local storage with multiple server replicas requires server.storage.accessMode=ReadWriteMany" -}} +{{- end -}} +{{- end -}} + +{{- if and (not .Values.postgresql.enabled) (not .Values.externalDatabase.host) -}} +{{- fail "postgresql.enabled=false requires externalDatabase.host for dependency checks" -}} +{{- end -}} +{{- if and (not .Values.redis.enabled) (not .Values.externalRedis.sentinel.enabled) (not .Values.externalRedis.host) -}} +{{- fail "redis.enabled=false requires externalRedis.host" -}} +{{- end -}} +{{- 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 not .Values.secrets.allowAutoGenerated -}} +{{- if not .Values.existingSecret -}} +{{- if not (or .Values.secrets.bootstrapAdminPassword .Values.bootstrapAdmin.password) -}} +{{- fail "secrets.allowAutoGenerated=false requires secrets.bootstrapAdminPassword or bootstrapAdmin.password" -}} +{{- end -}} +{{- if not .Values.secrets.downloadAnonCookieSecret -}} +{{- fail "secrets.allowAutoGenerated=false requires secrets.downloadAnonCookieSecret" -}} +{{- end -}} +{{- end -}} +{{- if and .Values.postgresql.enabled (not .Values.postgresql.auth.existingSecret) -}} +{{- if and .Values.postgresql.auth.enablePostgresUser (not .Values.postgresql.auth.postgresPassword) -}} +{{- fail "secrets.allowAutoGenerated=false requires postgresql.auth.postgresPassword or postgresql.auth.existingSecret" -}} +{{- end -}} +{{- if and .Values.postgresql.auth.username (ne .Values.postgresql.auth.username "postgres") (not .Values.postgresql.auth.password) -}} +{{- fail "secrets.allowAutoGenerated=false requires postgresql.auth.password or postgresql.auth.existingSecret" -}} +{{- end -}} +{{- if and (eq .Values.postgresql.architecture "replication") (not .Values.postgresql.auth.replicationPassword) -}} +{{- fail "secrets.allowAutoGenerated=false requires postgresql.auth.replicationPassword for replication architecture" -}} +{{- end -}} +{{- end -}} +{{- if and .Values.redis.enabled .Values.redis.auth.enabled (not .Values.redis.auth.existingSecret) (not .Values.redis.auth.password) -}} +{{- fail "secrets.allowAutoGenerated=false requires redis.auth.password or redis.auth.existingSecret" -}} +{{- end -}} +{{- end -}} diff --git a/charts/skillhub/tests/configuration-contracts.sh b/charts/skillhub/tests/configuration-contracts.sh new file mode 100755 index 00000000..e1089174 --- /dev/null +++ b/charts/skillhub/tests/configuration-contracts.sh @@ -0,0 +1,174 @@ +#!/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" + +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 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" + +render external-sentinel "$CHART_DIR" \ + --set postgresql.enabled=false \ + --set externalDatabase.host=db.example.com \ + --set redis.enabled=false \ + --set externalRedis.password=redis-password \ + --set externalRedis.sentinel.enabled=true \ + --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" + +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 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" + +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 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 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 + +echo "Helm configuration contract tests passed" diff --git a/charts/skillhub/tests/test-values.yaml b/charts/skillhub/tests/test-values.yaml new file mode 100644 index 00000000..1be1d359 --- /dev/null +++ b/charts/skillhub/tests/test-values.yaml @@ -0,0 +1,14 @@ +# Non-production credentials used only for deterministic chart tests. +secrets: + bootstrapAdminPassword: test-bootstrap-password + downloadAnonCookieSecret: test-download-cookie-secret-at-least-32-chars + +postgresql: + auth: + postgresPassword: test-postgres-password + password: test-postgresql-user-password + replicationPassword: test-postgresql-replication-password + +redis: + auth: + password: test-redis-password diff --git a/charts/skillhub/values.schema.json b/charts/skillhub/values.schema.json new file mode 100644 index 00000000..da736cf9 --- /dev/null +++ b/charts/skillhub/values.schema.json @@ -0,0 +1,453 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "additionalProperties": false, + "properties": { + "global": { "type": "object" }, + "images": { + "type": "object", + "additionalProperties": false, + "required": ["registry", "tag", "pullPolicy"], + "properties": { + "registry": { "type": "string", "minLength": 1 }, + "tag": { "type": "string" }, + "pullPolicy": { "enum": ["Always", "IfNotPresent", "Never"] } + } + }, + "nameOverride": { "$ref": "#/definitions/optionalDnsLabel" }, + "fullnameOverride": { "$ref": "#/definitions/optionalDnsLabel" }, + "publicBaseUrl": { "type": "string" }, + "deviceAuthVerificationUri": { "type": "string" }, + "auth": { + "type": "object", + "additionalProperties": false, + "required": ["direct"], + "properties": { + "direct": { + "type": "object", + "additionalProperties": false, + "required": ["enabled", "provider"], + "properties": { + "enabled": { "type": "boolean" }, + "provider": { "type": "string" } + } + } + } + }, + "builtinSkills": { + "type": "object", + "additionalProperties": false, + "required": ["enabled"], + "properties": { "enabled": { "type": "boolean" } } + }, + "ingress": { + "type": "object", + "additionalProperties": false, + "required": ["enabled", "className", "hosts", "annotations", "tls", "certManager"], + "properties": { + "enabled": { "type": "boolean" }, + "className": { + "oneOf": [ + { "type": "string", "enum": [""] }, + { "$ref": "#/definitions/dnsSubdomain" } + ] + }, + "hosts": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["host", "paths"], + "properties": { + "host": { "type": "string", "minLength": 1, "pattern": "^(\\*\\.)?[A-Za-z0-9.-]+$" }, + "paths": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["path", "pathType"], + "properties": { + "path": { "type": "string", "pattern": "^/" }, + "pathType": { "enum": ["Exact", "Prefix", "ImplementationSpecific"] } + } + } + } + } + } + }, + "annotations": { "$ref": "#/definitions/stringMap" }, + "tls": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["hosts", "secretName"], + "properties": { + "hosts": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "type": "string", "minLength": 1, "pattern": "^(\\*\\.)?[A-Za-z0-9.-]+$" } + }, + "secretName": { "type": "string", "minLength": 1 } + } + } + }, + "certManager": { + "type": "object", + "additionalProperties": false, + "required": ["enabled", "issuerName", "issuerKind"], + "properties": { + "enabled": { "type": "boolean" }, + "issuerName": { "type": "string", "minLength": 1 }, + "issuerKind": { "type": "string", "minLength": 1 } + } + } + } + }, + "s3": { + "type": "object", + "additionalProperties": false, + "required": ["enabled", "bucket", "endpoint", "publicEndpoint", "region", "forcePathStyle", "disableChunkedEncoding", "autoCreateBucket", "presignExpiry", "accessKey", "secretKey"], + "properties": { + "enabled": { "type": "boolean" }, + "bucket": { "type": "string", "minLength": 1 }, + "endpoint": { "type": "string" }, + "publicEndpoint": { "type": "string" }, + "region": { "type": "string", "minLength": 1 }, + "forcePathStyle": { "type": "boolean" }, + "disableChunkedEncoding": { "type": "boolean" }, + "autoCreateBucket": { "type": "boolean" }, + "presignExpiry": { "type": "string", "pattern": "^P" }, + "accessKey": { "type": "string" }, + "secretKey": { "type": "string" } + } + }, + "session": { + "type": "object", + "additionalProperties": false, + "required": ["cookieSecure"], + "properties": { "cookieSecure": { "type": "boolean" } } + }, + "bootstrapAdmin": { + "type": "object", + "additionalProperties": false, + "required": ["enabled", "userId", "username", "displayName", "email", "password"], + "properties": { + "enabled": { "type": "boolean" }, + "userId": { "type": "string", "minLength": 1 }, + "username": { "type": "string", "minLength": 1 }, + "displayName": { "type": "string", "minLength": 1 }, + "email": { "type": "string", "minLength": 1 }, + "password": { "type": "string" } + } + }, + "springProfilesActive": { "type": "string", "minLength": 1 }, + "existingSecret": { "type": "string" }, + "secrets": { + "type": "object", + "additionalProperties": false, + "required": ["allowAutoGenerated"], + "properties": { + "allowAutoGenerated": { "type": "boolean" }, + "bootstrapAdminPassword": { "type": "string" }, + "downloadAnonCookieSecret": { "type": "string" }, + "oauth2GithubClientId": { "type": "string" }, + "oauth2GithubClientSecret": { "type": "string" }, + "scannerLlmApiKey": { "type": "string" }, + "scannerLlmBaseUrl": { "type": "string" }, + "scannerLlmModel": { "type": "string" } + } + }, + "postgresql": { + "type": "object", + "properties": { + "enabled": { "type": "boolean" }, + "architecture": { "enum": ["standalone", "replication"] }, + "auth": { "type": "object" }, + "primary": { + "type": "object", + "properties": { "extraEnv": false } + } + } + }, + "externalDatabase": { + "type": "object", + "additionalProperties": false, + "required": ["host", "port", "database", "username", "password", "jdbcUrl"], + "properties": { + "host": { "type": "string", "pattern": "^$|^[A-Za-z0-9._-]+$" }, + "port": { "type": "integer", "minimum": 1, "maximum": 65535 }, + "database": { "type": "string", "minLength": 1 }, + "username": { "type": "string", "minLength": 1 }, + "password": { "type": "string" }, + "jdbcUrl": { "type": "string" } + } + }, + "redis": { + "type": "object", + "properties": { + "enabled": { "type": "boolean" }, + "architecture": { "enum": ["standalone", "replication"] }, + "auth": { + "type": "object", + "properties": { "sentinelPassword": false } + }, + "sentinel": { + "type": "object", + "properties": { + "nodes": false, + "service": { + "type": "object", + "properties": { "enabled": false } + } + } + } + } + }, + "externalRedis": { + "type": "object", + "additionalProperties": false, + "required": ["host", "port", "password", "sentinel"], + "properties": { + "host": { "type": "string", "pattern": "^$|^[A-Za-z0-9._-]+$" }, + "port": { "type": "integer", "minimum": 1, "maximum": 65535 }, + "password": { "type": "string" }, + "sentinel": { + "type": "object", + "additionalProperties": false, + "required": ["enabled", "masterSet", "nodes", "password"], + "properties": { + "enabled": { "type": "boolean" }, + "masterSet": { "type": "string", "minLength": 1 }, + "nodes": { + "type": "array", + "items": { "type": "string", "pattern": "^[^:]+:[0-9]+$" } + }, + "password": { "type": "string" } + } + } + } + }, + "server": { "$ref": "#/definitions/serverComponent" }, + "web": { "$ref": "#/definitions/webComponent" }, + "scanner": { "$ref": "#/definitions/scannerComponent" } + }, + "required": ["images", "auth", "builtinSkills", "ingress", "s3", "session", "bootstrapAdmin", "springProfilesActive", "secrets", "postgresql", "externalDatabase", "redis", "externalRedis", "server", "web", "scanner"], + "definitions": { + "dnsLabel": { + "type": "string", + "minLength": 1, + "maxLength": 63, + "pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$" + }, + "dnsSubdomain": { + "type": "string", + "minLength": 1, + "maxLength": 253, + "pattern": "^[a-z0-9]([-a-z0-9.]*[a-z0-9])?$" + }, + "optionalDnsLabel": { + "type": "string", + "maxLength": 63, + "pattern": "^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?$" + }, + "stringMap": { + "type": "object", + "additionalProperties": { "type": "string" } + }, + "image": { + "type": "object", + "additionalProperties": false, + "required": ["registry", "tag"], + "properties": { + "registry": { "type": "string" }, + "tag": { "type": "string" } + } + }, + "service": { + "type": "object", + "additionalProperties": false, + "required": ["enabled", "type", "port", "nodePort", "loadBalancerIP", "loadBalancerSourceRanges"], + "properties": { + "enabled": { "type": "boolean" }, + "type": { "enum": ["ClusterIP", "NodePort", "LoadBalancer"] }, + "port": { "type": "integer", "minimum": 1, "maximum": 65535 }, + "nodePort": { + "oneOf": [ + { "type": "string", "enum": [""] }, + { "type": "integer", "minimum": 1, "maximum": 65535 } + ] + }, + "loadBalancerIP": { "type": "string" }, + "loadBalancerSourceRanges": { + "type": "array", + "items": { "type": "string" } + } + } + }, + "resources": { "type": "object" }, + "autoscaling": { + "type": "object", + "additionalProperties": false, + "required": ["enabled", "minReplicas", "maxReplicas", "targetCPUUtilizationPercentage", "targetMemoryUtilizationPercentage"], + "properties": { + "enabled": { "type": "boolean" }, + "minReplicas": { "type": "integer", "minimum": 1 }, + "maxReplicas": { "type": "integer", "minimum": 1 }, + "targetCPUUtilizationPercentage": { "type": "integer", "minimum": 0 }, + "targetMemoryUtilizationPercentage": { "type": "integer", "minimum": 0 } + } + }, + "pdb": { + "type": "object", + "additionalProperties": false, + "required": ["enabled", "minAvailable"], + "properties": { + "enabled": { "type": "boolean" }, + "minAvailable": { + "oneOf": [ + { "type": "integer", "minimum": 0 }, + { "type": "string", "pattern": "^[0-9]+%$" } + ] + } + } + }, + "commonPod": { + "type": "object", + "properties": { + "resources": { "$ref": "#/definitions/resources" }, + "extraEnv": { "type": "array", "items": { "type": "object" } }, + "podAnnotations": { "$ref": "#/definitions/stringMap" }, + "imagePullSecrets": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["name"], + "properties": { "name": { "type": "string", "minLength": 1 } } + } + }, + "nodeSelector": { "$ref": "#/definitions/stringMap" }, + "tolerations": { "type": "array", "items": { "type": "object" } }, + "affinity": { "type": "object" }, + "probes": { "type": "object" }, + "autoscaling": { "$ref": "#/definitions/autoscaling" }, + "podDisruptionBudget": { "$ref": "#/definitions/pdb" } + } + }, + "serverComponent": { + "allOf": [ + { "$ref": "#/definitions/commonPod" }, + { + "type": "object", + "additionalProperties": false, + "required": ["enabled", "replicaCount", "image", "dependencyWait", "service", "storage", "resources", "javaOpts", "extraEnv", "podAnnotations", "imagePullSecrets", "nodeSelector", "tolerations", "affinity", "probes", "autoscaling", "podDisruptionBudget"], + "properties": { + "enabled": { "type": "boolean" }, + "replicaCount": { "type": "integer", "minimum": 1 }, + "image": { "$ref": "#/definitions/image" }, + "dependencyWait": { + "type": "object", + "additionalProperties": false, + "required": ["image"], + "properties": { + "image": { + "type": "object", + "additionalProperties": false, + "required": ["registry", "repository", "tag", "pullPolicy"], + "properties": { + "registry": { "type": "string", "minLength": 1 }, + "repository": { "type": "string", "minLength": 1 }, + "tag": { "type": "string", "minLength": 1 }, + "pullPolicy": { "enum": ["Always", "IfNotPresent", "Never"] } + } + } + } + }, + "service": { "$ref": "#/definitions/service" }, + "storage": { + "type": "object", + "additionalProperties": false, + "required": ["accessMode", "size", "storageClassName"], + "properties": { + "accessMode": { "enum": ["", "ReadWriteOnce", "ReadWriteMany"] }, + "size": { "type": "string", "minLength": 1 }, + "storageClassName": { "type": "string" } + } + }, + "resources": { "$ref": "#/definitions/resources" }, + "javaOpts": { "type": "string" }, + "extraEnv": { "type": "array", "items": { "type": "object" } }, + "podAnnotations": { "$ref": "#/definitions/stringMap" }, + "imagePullSecrets": { "type": "array", "items": { "type": "object" } }, + "nodeSelector": { "$ref": "#/definitions/stringMap" }, + "tolerations": { "type": "array", "items": { "type": "object" } }, + "affinity": { "type": "object" }, + "probes": { "type": "object" }, + "autoscaling": { "$ref": "#/definitions/autoscaling" }, + "podDisruptionBudget": { "$ref": "#/definitions/pdb" } + } + } + ] + }, + "webComponent": { + "allOf": [ + { "$ref": "#/definitions/commonPod" }, + { + "type": "object", + "additionalProperties": false, + "required": ["replicaCount", "image", "service", "resources", "extraEnv", "podAnnotations", "imagePullSecrets", "nodeSelector", "tolerations", "affinity", "probes", "autoscaling", "podDisruptionBudget"], + "properties": { + "replicaCount": { "type": "integer", "minimum": 1 }, + "image": { "$ref": "#/definitions/image" }, + "service": { "$ref": "#/definitions/service" }, + "resources": { "$ref": "#/definitions/resources" }, + "extraEnv": { "type": "array", "items": { "type": "object" } }, + "podAnnotations": { "$ref": "#/definitions/stringMap" }, + "imagePullSecrets": { "type": "array", "items": { "type": "object" } }, + "nodeSelector": { "$ref": "#/definitions/stringMap" }, + "tolerations": { "type": "array", "items": { "type": "object" } }, + "affinity": { "type": "object" }, + "probes": { "type": "object" }, + "autoscaling": { "$ref": "#/definitions/autoscaling" }, + "podDisruptionBudget": { "$ref": "#/definitions/pdb" } + } + } + ] + }, + "scannerComponent": { + "allOf": [ + { "$ref": "#/definitions/commonPod" }, + { + "type": "object", + "additionalProperties": false, + "required": ["enabled", "replicaCount", "image", "service", "resources", "extraEnv", "podAnnotations", "imagePullSecrets", "nodeSelector", "tolerations", "affinity", "probes", "autoscaling", "podDisruptionBudget"], + "properties": { + "enabled": { "type": "boolean" }, + "replicaCount": { "type": "integer", "minimum": 1 }, + "image": { "$ref": "#/definitions/image" }, + "service": { + "type": "object", + "additionalProperties": false, + "required": ["port"], + "properties": { "port": { "type": "integer", "minimum": 1, "maximum": 65535 } } + }, + "resources": { "$ref": "#/definitions/resources" }, + "extraEnv": { "type": "array", "items": { "type": "object" } }, + "podAnnotations": { "$ref": "#/definitions/stringMap" }, + "imagePullSecrets": { "type": "array", "items": { "type": "object" } }, + "nodeSelector": { "$ref": "#/definitions/stringMap" }, + "tolerations": { "type": "array", "items": { "type": "object" } }, + "affinity": { "type": "object" }, + "probes": { "type": "object" }, + "autoscaling": { "$ref": "#/definitions/autoscaling" }, + "podDisruptionBudget": { "$ref": "#/definitions/pdb" } + } + } + ] + } + } +} diff --git a/charts/skillhub/values.yaml b/charts/skillhub/values.yaml index 42f8ea3b..48997726 100644 --- a/charts/skillhub/values.yaml +++ b/charts/skillhub/values.yaml @@ -31,12 +31,13 @@ builtinSkills: ingress: enabled: false className: nginx - host: skills.example.com - annotations: - nginx.ingress.kubernetes.io/proxy-body-size: 100m - tls: - enabled: false - secretName: "" + hosts: + - host: skills.example.com + paths: + - path: / + pathType: Prefix + annotations: {} + tls: [] certManager: enabled: false issuerName: letsencrypt-prod @@ -86,6 +87,8 @@ springProfilesActive: docker existingSecret: "" secrets: + # 默认禁止随机 Secret;仅在非 GitOps 临时环境中按需启用 + allowAutoGenerated: false bootstrapAdminPassword: "" downloadAnonCookieSecret: "" oauth2GithubClientId: "" @@ -122,8 +125,8 @@ postgresql: limits: cpu: 500m memory: 1Gi - extraEnv: - - name: POSTGRES_MAX_CONNECTIONS + extraEnvVars: + - name: POSTGRESQL_MAX_CONNECTIONS value: "500" podAnnotations: {} podSecurityContext: @@ -155,6 +158,10 @@ postgresql: limits: cpu: 500m memory: 1Gi + # Hot standbys must not use a lower max_connections than the primary. + extraEnvVars: + - name: POSTGRESQL_MAX_CONNECTIONS + value: "500" metrics: enabled: true @@ -162,7 +169,7 @@ postgresql: enabled: false externalDatabase: - host: postgres.example.com + host: "" port: 5432 database: skillhub username: skillhub @@ -180,7 +187,6 @@ redis: auth: enabled: true password: "" - sentinelPassword: "" master: persistence: @@ -222,11 +228,11 @@ redis: sentinel: enabled: false masterSet: mymaster - nodes: "" service: - enabled: true ports: sentinel: 26379 + containerPorts: + sentinel: 26379 metrics: enabled: true @@ -234,7 +240,7 @@ redis: enabled: false externalRedis: - host: redis.example.com + host: "" port: 6379 password: "" sentinel: @@ -254,6 +260,13 @@ server: registry: "" tag: "" + dependencyWait: + image: + registry: docker.io + repository: library/busybox + tag: "1.37" + pullPolicy: IfNotPresent + service: enabled: true type: ClusterIP diff --git a/server/skillhub-app/src/main/resources/application-redis-sentinel.yml b/server/skillhub-app/src/main/resources/application-redis-sentinel.yml index b203b0cd..e5caff3e 100644 --- a/server/skillhub-app/src/main/resources/application-redis-sentinel.yml +++ b/server/skillhub-app/src/main/resources/application-redis-sentinel.yml @@ -1,7 +1,7 @@ spring: data: redis: - password: ${SPRING_DATA_REDIS_SENTINEL_PASSWORD:${SPRING_DATA_REDIS_PASSWORD:${REDIS_PASSWORD:}}} + password: ${SPRING_DATA_REDIS_PASSWORD:${REDIS_PASSWORD:${SPRING_DATA_REDIS_SENTINEL_PASSWORD:}}} sentinel: master: ${SPRING_DATA_REDIS_SENTINEL_MASTER:${REDIS_SENTINEL_MASTER:mymaster}} nodes: ${SPRING_DATA_REDIS_SENTINEL_NODES:${REDIS_SENTINEL_NODES:}} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/RedisSentinelProfileConfigurationTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/RedisSentinelProfileConfigurationTest.java new file mode 100644 index 00000000..65a446b5 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/RedisSentinelProfileConfigurationTest.java @@ -0,0 +1,56 @@ +package com.iflytek.skillhub.config; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.env.YamlPropertySourceLoader; +import org.springframework.core.env.MapPropertySource; +import org.springframework.core.env.MutablePropertySources; +import org.springframework.core.env.PropertySource; +import org.springframework.core.env.PropertySourcesPropertyResolver; +import org.springframework.core.io.ClassPathResource; + +import java.io.IOException; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class RedisSentinelProfileConfigurationTest { + + private final PropertySource sentinelProfile = loadSentinelProfile(); + + @Test + void separateDataAndSentinelPasswordsResolveIndependently() { + assertThat(resolve("spring.data.redis.password", Map.of( + "SPRING_DATA_REDIS_PASSWORD", "data-password", + "SPRING_DATA_REDIS_SENTINEL_PASSWORD", "sentinel-password" + ))).isEqualTo("data-password"); + + assertThat(resolve("spring.data.redis.sentinel.password", Map.of( + "SPRING_DATA_REDIS_PASSWORD", "data-password", + "SPRING_DATA_REDIS_SENTINEL_PASSWORD", "sentinel-password" + ))).isEqualTo("sentinel-password"); + } + + @Test + void sentinelPasswordRemainsADataPasswordFallback() { + assertThat(resolve("spring.data.redis.password", Map.of( + "SPRING_DATA_REDIS_SENTINEL_PASSWORD", "legacy-password" + ))).isEqualTo("legacy-password"); + } + + private String resolve(String propertyName, Map environment) { + MutablePropertySources sources = new MutablePropertySources(); + sources.addFirst(new MapPropertySource("test-environment", environment)); + PropertySourcesPropertyResolver resolver = new PropertySourcesPropertyResolver(sources); + return resolver.resolveRequiredPlaceholders((String) sentinelProfile.getProperty(propertyName)); + } + + private static PropertySource loadSentinelProfile() { + try { + return new YamlPropertySourceLoader() + .load("redis-sentinel", new ClassPathResource("application-redis-sentinel.yml")) + .getFirst(); + } catch (IOException e) { + throw new IllegalStateException("Failed to load Redis Sentinel profile", e); + } + } +} From 9978d82cb155832e196f29810be59377c95ed603 Mon Sep 17 00:00:00 2001 From: lhb6540 Date: Thu, 16 Jul 2026 20:04:07 +0800 Subject: [PATCH 38/81] =?UTF-8?q?fix(helm):=20=E4=BF=AE=E6=AD=A3=20Server?= =?UTF-8?q?=20PVC=20=E6=9D=83=E9=99=90=E4=B8=8E=20RWO=20=E5=8D=87=E7=BA=A7?= =?UTF-8?q?=E7=AD=96=E7=95=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本地 PVC 会覆盖 Server 镜像内预先设置的目录所有者,导致非 root app 用户无法写入技能文件。 - 为 Server Pod 增加可覆盖的 fsGroup,默认匹配 v0.2.13 镜像的 app 组 101 - 本地 ReadWriteOnce 存储自动使用 Recreate,避免滚动升级时新旧 Pod 抢占卷 - ReadWriteMany 与 S3 部署继续使用 RollingUpdate - 补充 values schema、配置契约测试和运维文档 Signed-off-by: lhb6540 --- charts/skillhub/README.md | 11 ++++++++++ .../skillhub/templates/server-deployment.yaml | 10 ++++++++++ .../skillhub/tests/configuration-contracts.sh | 20 +++++++++++++++++++ charts/skillhub/values.schema.json | 11 +++++++++- charts/skillhub/values.yaml | 5 +++++ 5 files changed, 56 insertions(+), 1 deletion(-) diff --git a/charts/skillhub/README.md b/charts/skillhub/README.md index 802c233b..445873d4 100644 --- a/charts/skillhub/README.md +++ b/charts/skillhub/README.md @@ -301,6 +301,17 @@ helm -n skillhub upgrade -i skillhub ./charts/skillhub \ | `server.storage.accessMode` | 留空时单副本使用 ReadWriteOnce;多副本必须显式使用 ReadWriteMany | `""` | | `server.storage.size` | PVC 大小 | `10Gi` | | `server.storage.storageClassName` | StorageClass | `""` | +| `server.podSecurityContext.fsGroup` | Server 本地存储的可写组 ID,应与镜像内 app 用户组一致 | `101` | +| `server.podSecurityContext.fsGroupChangePolicy` | kubelet 调整 PVC 组权限的策略 | `OnRootMismatch` | + +本地 PVC 会覆盖镜像内预先设置的目录所有者。Chart 默认通过 Pod `fsGroup=101` +使 Server 的非 root `app` 用户可以创建和更新技能文件。使用自定义 Server 镜像且其 +运行组 ID 不同时,必须同步覆盖 `server.podSecurityContext.fsGroup`。 + +使用本地 `ReadWriteOnce` PVC 时,Server Deployment 自动采用 `Recreate`,避免 +滚动升级期间新旧 Pod 同时挂载非共享卷而触发 Multi-Attach。单副本升级会有短暂 +停机;使用支持 RWX 的 `ReadWriteMany` 存储或启用 S3 时,Chart 保留 +`RollingUpdate`。 ```bash # 默认使用本地 PVC diff --git a/charts/skillhub/templates/server-deployment.yaml b/charts/skillhub/templates/server-deployment.yaml index a39d9d85..84e7ac9d 100644 --- a/charts/skillhub/templates/server-deployment.yaml +++ b/charts/skillhub/templates/server-deployment.yaml @@ -9,6 +9,12 @@ spec: {{- if not .Values.server.autoscaling.enabled }} replicas: {{ .Values.server.replicaCount }} {{- end }} + strategy: + {{- if and (not .Values.s3.enabled) (ne .Values.server.storage.accessMode "ReadWriteMany") }} + type: Recreate + {{- else }} + type: RollingUpdate + {{- end }} selector: matchLabels: {{- include "skillhub.server.selectorLabels" . | nindent 6 }} @@ -23,6 +29,10 @@ spec: {{ $key }}: {{ $val }} {{- end }} spec: + {{- with .Values.server.podSecurityContext }} + securityContext: + {{- toYaml . | nindent 8 }} + {{- end }} {{- $secrets := .Values.server.imagePullSecrets }} {{- if $secrets }} imagePullSecrets: diff --git a/charts/skillhub/tests/configuration-contracts.sh b/charts/skillhub/tests/configuration-contracts.sh index e1089174..e3b33b7b 100755 --- a/charts/skillhub/tests/configuration-contracts.sh +++ b/charts/skillhub/tests/configuration-contracts.sh @@ -27,6 +27,16 @@ 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 '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 @@ -143,6 +153,16 @@ 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 \ diff --git a/charts/skillhub/values.schema.json b/charts/skillhub/values.schema.json index da736cf9..b1c6f3a0 100644 --- a/charts/skillhub/values.schema.json +++ b/charts/skillhub/values.schema.json @@ -344,7 +344,7 @@ { "type": "object", "additionalProperties": false, - "required": ["enabled", "replicaCount", "image", "dependencyWait", "service", "storage", "resources", "javaOpts", "extraEnv", "podAnnotations", "imagePullSecrets", "nodeSelector", "tolerations", "affinity", "probes", "autoscaling", "podDisruptionBudget"], + "required": ["enabled", "replicaCount", "image", "dependencyWait", "service", "storage", "podSecurityContext", "resources", "javaOpts", "extraEnv", "podAnnotations", "imagePullSecrets", "nodeSelector", "tolerations", "affinity", "probes", "autoscaling", "podDisruptionBudget"], "properties": { "enabled": { "type": "boolean" }, "replicaCount": { "type": "integer", "minimum": 1 }, @@ -378,6 +378,15 @@ "storageClassName": { "type": "string" } } }, + "podSecurityContext": { + "type": "object", + "additionalProperties": false, + "required": ["fsGroup", "fsGroupChangePolicy"], + "properties": { + "fsGroup": { "type": "integer", "minimum": 1 }, + "fsGroupChangePolicy": { "enum": ["Always", "OnRootMismatch"] } + } + }, "resources": { "$ref": "#/definitions/resources" }, "javaOpts": { "type": "string" }, "extraEnv": { "type": "array", "items": { "type": "object" } }, diff --git a/charts/skillhub/values.yaml b/charts/skillhub/values.yaml index 48997726..d70f2406 100644 --- a/charts/skillhub/values.yaml +++ b/charts/skillhub/values.yaml @@ -281,6 +281,11 @@ server: size: 10Gi storageClassName: "" + # PVC 挂载会覆盖镜像内目录权限;使用镜像中 app 用户的组 ID 使本地存储可写 + podSecurityContext: + fsGroup: 101 + fsGroupChangePolicy: OnRootMismatch + resources: requests: cpu: 500m From 8b8420151626b773f33987eb9f092281da396ec5 Mon Sep 17 00:00:00 2001 From: betterlmy Date: Fri, 17 Jul 2026 11:26:20 +0800 Subject: [PATCH 39/81] feat: add generic user-level agent install target Signed-off-by: betterlmy --- cli/src/agents/resolver.ts | 12 +++++++++++ .../unit/agents/resolver-interactive.test.ts | 21 +++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/cli/src/agents/resolver.ts b/cli/src/agents/resolver.ts index 2290ee09..04ed5c63 100644 --- a/cli/src/agents/resolver.ts +++ b/cli/src/agents/resolver.ts @@ -68,6 +68,18 @@ async function resolveScopedTargets( } candidates = dedupeByRoot(candidates) + if (scope === 'user' && agentList.length === 0 && options.interactive && !options.json) { + candidates = dedupeByRoot([ + ...candidates, + { + agent: 'generic', + rootDir: `${scopedHome}/.agents/skills`, + scope: 'user', + source: 'fallback' + } + ]) + } + if (candidates.length === 0) { const fallbackRoot = scope === 'user' ? `${scopedHome}/.agents/skills` diff --git a/cli/test/unit/agents/resolver-interactive.test.ts b/cli/test/unit/agents/resolver-interactive.test.ts index 55bc99ac..8de156da 100644 --- a/cli/test/unit/agents/resolver-interactive.test.ts +++ b/cli/test/unit/agents/resolver-interactive.test.ts @@ -33,4 +33,25 @@ describe('resolveInstallTargets interactive prompt', () => { expect(targets).toEqual([highlighted]) }) + + test('offers the generic user target alongside detected agent targets', async () => { + const targets = await resolveInstallTargets({ + cwd: '/repo', + home: '/home/u', + agents: [], + scope: 'user', + json: false, + interactive: true, + detected: [ + { agent: 'codex', rootDir: '/home/u/.codex/skills', scope: 'user', source: 'detected' } + ] + }) + + expect(targets).toEqual([{ + agent: 'generic', + rootDir: '/home/u/.agents/skills', + scope: 'user', + source: 'fallback' + }]) + }) }) From f519b08a7313a8461ca308378c7398b7e608bb36 Mon Sep 17 00:00:00 2001 From: betterlmy Date: Fri, 17 Jul 2026 17:20:22 +0800 Subject: [PATCH 40/81] fix(cli): preflight canonical install targets Signed-off-by: betterlmy --- cli/README.md | 4 +- cli/src/agents/resolver.ts | 23 +++++--- cli/src/platform/paths.ts | 9 +++ cli/src/services/install-service.ts | 46 +++++++++++---- .../unit/agents/resolver-interactive.test.ts | 43 ++++++++++---- cli/test/unit/agents/resolver.test.ts | 36 ++++++++++++ .../unit/services/install-service.test.ts | 58 ++++++++++++++++++- docs/skillhub/en/guide/cli.md | 4 +- docs/skillhub/guide/cli.md | 4 +- 9 files changed, 188 insertions(+), 39 deletions(-) diff --git a/cli/README.md b/cli/README.md index 6d98df80..b2a8cdf3 100644 --- a/cli/README.md +++ b/cli/README.md @@ -160,7 +160,7 @@ The CLI determines the installation location using the following logic: 1. If `--dir` is specified: Install to that directory, agent marked as `custom`. `--dir` is mutually exclusive with `--scope` and `--agent`. 2. If `--scope user|project` is specified: Limit detection to the chosen scope. - With `--agent `: Install to that profile's user or project skills directory directly. - - Without `--agent`: Detect existing skills directories within the chosen scope only. + - Without `--agent`: Detect existing skills directories within the chosen scope only. In interactive user scope, the `generic` target (`/.agents/skills/`) is always also offered and can be selected alone or together with detected targets. - No detected directory in the chosen scope → Fallback to `/.agents/skills/` for `--scope user` or `/.agents/skills/` for `--scope project`. 3. If `--agent` is specified (no `--scope`): Install to the corresponding Agent's skills directory (existing behaviour, unchanged). 4. If none of the above is specified: @@ -191,7 +191,7 @@ Each Agent has both project-level and user-level skills directories. Use `--scop | `kilo` | `/.kilo/skills/` | `~/.kilo/skills/` | | _fallback_ | `/.agents/skills/` | `~/.agents/skills/` | -For Agents not in the list, use `--dir` to specify the installation path. When `--scope user|project` finds no matching agent directory, the CLI falls back to the `_fallback_` row above. +For a custom path or an unsupported Agent directory, use `--dir` to specify the installation path. In interactive user scope, the `generic` target is offered alongside detected Agent targets. When `--scope user|project` finds no matching agent directory, the CLI falls back to the `_fallback_` row above. ### File Structure After Installation diff --git a/cli/src/agents/resolver.ts b/cli/src/agents/resolver.ts index 04ed5c63..190e1a8d 100644 --- a/cli/src/agents/resolver.ts +++ b/cli/src/agents/resolver.ts @@ -1,7 +1,7 @@ import { homedir } from 'node:os' import { CliError } from '../shared/errors' import { EXIT } from '../shared/constants' -import { pathExists } from '../platform/paths' +import { canonicalizeExistingPath, pathExists } from '../platform/paths' import type { AgentCandidate } from './types' import { allProfiles, profileMap } from './detector' @@ -66,10 +66,10 @@ async function resolveScopedTargets( } else { candidates = await generateScopedCandidates(scope, options.cwd, scopedHome) } - candidates = dedupeByRoot(candidates) + candidates = await dedupeByRoot(candidates) if (scope === 'user' && agentList.length === 0 && options.interactive && !options.json) { - candidates = dedupeByRoot([ + candidates = await dedupeByRoot([ ...candidates, { agent: 'generic', @@ -161,13 +161,18 @@ async function resolveExplicitAgents( return results } -function dedupeByRoot(candidates: AgentCandidate[]): AgentCandidate[] { +async function dedupeByRoot(candidates: AgentCandidate[]): Promise { const seen = new Set() - return candidates.filter(c => { - if (seen.has(c.rootDir)) return false - seen.add(c.rootDir) - return true - }) + const deduped: AgentCandidate[] = [] + + for (const candidate of candidates) { + const canonicalRootDir = await canonicalizeExistingPath(candidate.rootDir) + if (seen.has(canonicalRootDir)) continue + seen.add(canonicalRootDir) + deduped.push(candidate) + } + + return deduped } async function selectTargetsInteractively(candidates: AgentCandidate[]): Promise { diff --git a/cli/src/platform/paths.ts b/cli/src/platform/paths.ts index e139b2cc..7811a766 100644 --- a/cli/src/platform/paths.ts +++ b/cli/src/platform/paths.ts @@ -24,6 +24,15 @@ export async function pathExists(path: string): Promise { } } +export async function canonicalizeExistingPath(path: string): Promise { + const { realpath } = await import('node:fs/promises') + try { + return await realpath(path) + } catch { + return path + } +} + export async function applyCredentialPermissions(path: string): Promise { if (process.platform === 'win32') return const { chmod } = await import('node:fs/promises') diff --git a/cli/src/services/install-service.ts b/cli/src/services/install-service.ts index 4293ff6e..bba71345 100644 --- a/cli/src/services/install-service.ts +++ b/cli/src/services/install-service.ts @@ -6,7 +6,7 @@ import { CliError } from '../shared/errors' import { EXIT } from '../shared/constants' import { extractZip } from '../platform/archive' import { readBoundedResponseBody } from '../platform/download' -import { pathExists } from '../platform/paths' +import { canonicalizeExistingPath, pathExists } from '../platform/paths' import type { AgentCandidate } from '../agents/types' export interface InstallOptions { @@ -20,7 +20,40 @@ export interface InstallOptions { home?: string | undefined } +async function preflightInstallTargets( + targets: AgentCandidate[], + slug: string, + force: boolean +): Promise> { + const seenSkillDirs = new Set() + const preparedTargets: Array<{ target: AgentCandidate; skillDir: string }> = [] + + for (const target of targets) { + const canonicalRootDir = await canonicalizeExistingPath(target.rootDir) + const canonicalSkillDir = join(canonicalRootDir, slug) + if (seenSkillDirs.has(canonicalSkillDir)) { + throw new CliError(`multiple install targets resolve to ${canonicalSkillDir}`, EXIT.usage, { + path: canonicalSkillDir, + next: 'select only one target for this directory' + }) + } + seenSkillDirs.add(canonicalSkillDir) + + const skillDir = join(target.rootDir, slug) + if (await pathExists(skillDir) && !force) { + throw new CliError(`skill already installed at ${skillDir}`, EXIT.filesystem, { + path: skillDir, + next: 'pass --force to overwrite' + }) + } + preparedTargets.push({ target, skillDir }) + } + + return preparedTargets +} + export async function installSkill(options: InstallOptions): Promise<{ installed: Array<{ agent: string; dir: string }> }> { + const preparedTargets = await preflightInstallTargets(options.targets, options.slug, options.force) const client = new SkillHubClient(options.registry, options.token) const resolved = await client.resolve(options.namespace, options.slug, options.version) const response = await client.download(options.namespace, options.slug, resolved.version) @@ -29,16 +62,7 @@ export async function installSkill(options: InstallOptions): Promise<{ installed const installed: Array<{ agent: string; dir: string }> = [] const store = new InventoryStore(options.home) - for (const target of options.targets) { - const skillDir = join(target.rootDir, options.slug) - - if (await pathExists(skillDir) && !options.force) { - throw new CliError(`skill already installed at ${skillDir}`, EXIT.filesystem, { - path: skillDir, - next: 'pass --force to overwrite' - }) - } - + for (const { target, skillDir } of preparedTargets) { await mkdir(target.rootDir, { recursive: true }) const tempDir = await mkdtemp(join(target.rootDir, `.${options.slug}.install-`)) let movedIntoPlace = false diff --git a/cli/test/unit/agents/resolver-interactive.test.ts b/cli/test/unit/agents/resolver-interactive.test.ts index 8de156da..f669f80d 100644 --- a/cli/test/unit/agents/resolver-interactive.test.ts +++ b/cli/test/unit/agents/resolver-interactive.test.ts @@ -1,18 +1,30 @@ -import { describe, expect, mock, test } from 'bun:test' +import { afterEach, describe, expect, mock, test } from 'bun:test' import type { AgentCandidate } from '../../../src/agents/types' +interface PromptChoice { + value: AgentCandidate +} + interface PromptOptions { + choices?: PromptChoice[] onRender?: (this: { cursor?: number }) => void format?: (selectedTargets: AgentCandidate[]) => AgentCandidate[] } +const defaultSelectedTargets = (options: PromptOptions): AgentCandidate[] => options.format?.([]) ?? [] +let selectPromptTargets = defaultSelectedTargets + mock.module('prompts', () => ({ default: (options: PromptOptions) => { options.onRender?.call({ cursor: 1 }) - return { selected: options.format?.([]) ?? [] } + return { selected: selectPromptTargets(options) } } })) +afterEach(() => { + selectPromptTargets = defaultSelectedTargets +}) + const { resolveInstallTargets } = await import('../../../src/agents/resolver') describe('resolveInstallTargets interactive prompt', () => { @@ -34,7 +46,21 @@ describe('resolveInstallTargets interactive prompt', () => { expect(targets).toEqual([highlighted]) }) - test('offers the generic user target alongside detected agent targets', async () => { + test('allows selecting generic alongside detected user targets', async () => { + selectPromptTargets = options => options.choices?.map(choice => choice.value) ?? [] + const codex: AgentCandidate = { + agent: 'codex', + rootDir: '/home/u/.codex/skills', + scope: 'user', + source: 'detected' + } + const generic: AgentCandidate = { + agent: 'generic', + rootDir: '/home/u/.agents/skills', + scope: 'user', + source: 'fallback' + } + const targets = await resolveInstallTargets({ cwd: '/repo', home: '/home/u', @@ -42,16 +68,9 @@ describe('resolveInstallTargets interactive prompt', () => { scope: 'user', json: false, interactive: true, - detected: [ - { agent: 'codex', rootDir: '/home/u/.codex/skills', scope: 'user', source: 'detected' } - ] + detected: [codex] }) - expect(targets).toEqual([{ - agent: 'generic', - rootDir: '/home/u/.agents/skills', - scope: 'user', - source: 'fallback' - }]) + expect(targets).toEqual([codex, generic]) }) }) diff --git a/cli/test/unit/agents/resolver.test.ts b/cli/test/unit/agents/resolver.test.ts index 36e8170b..96eda770 100644 --- a/cli/test/unit/agents/resolver.test.ts +++ b/cli/test/unit/agents/resolver.test.ts @@ -1,5 +1,9 @@ +import { mkdir, mkdtemp, rm, symlink } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { describe, expect, test } from 'bun:test' import { resolveInstallTargets } from '../../../src/agents/resolver' +import type { AgentCandidate } from '../../../src/agents/types' describe('resolveInstallTargets', () => { test('rejects dir and agent together before filesystem writes', async () => { @@ -216,4 +220,36 @@ describe('resolveInstallTargets', () => { expect(targets[0]!.rootDir).toBe('/home/u/.codex/skills') expect(targets[0]!.scope).toBe('user') }) + + test('deduplicates a symlinked detected target and the generic user target', async () => { + const home = await mkdtemp(join(tmpdir(), 'skillhub-resolver-home-')) + const genericRoot = join(home, '.agents', 'skills') + const codexRoot = join(home, '.codex', 'skills') + const codex: AgentCandidate = { + agent: 'codex', + rootDir: codexRoot, + scope: 'user', + source: 'detected' + } + + try { + await mkdir(genericRoot, { recursive: true }) + await mkdir(join(home, '.codex'), { recursive: true }) + await symlink(genericRoot, codexRoot, process.platform === 'win32' ? 'junction' : 'dir') + + const targets = await resolveInstallTargets({ + cwd: '/repo', + home, + agents: [], + scope: 'user', + json: false, + interactive: true, + detected: [codex] + }) + + expect(targets).toEqual([codex]) + } finally { + await rm(home, { recursive: true, force: true }) + } + }) }) diff --git a/cli/test/unit/services/install-service.test.ts b/cli/test/unit/services/install-service.test.ts index fd08e1b6..5d3b0ec5 100644 --- a/cli/test/unit/services/install-service.test.ts +++ b/cli/test/unit/services/install-service.test.ts @@ -1,4 +1,4 @@ -import { access, mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises' +import { access, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, test } from 'bun:test' @@ -72,6 +72,62 @@ describe('installSkill', () => { })).rejects.toThrow('skill already installed') }) + test('preflights all targets before writing when a later target is occupied', async () => { + globalThis.fetch = installFetch({ 'SKILL.md': '# Demo' }) + const home = await mkdtemp(join(tmpdir(), 'skillhub-install-home-')) + const firstRoot = await mkdtemp(join(tmpdir(), 'skillhub-install-first-root-')) + const secondRoot = await mkdtemp(join(tmpdir(), 'skillhub-install-second-root-')) + const firstSkillDir = join(firstRoot, 'demo') + const secondSkillDir = join(secondRoot, 'demo') + await mkdir(secondSkillDir, { recursive: true }) + + await expect(installSkill({ + registry: 'http://registry.test', + namespace: 'global', + slug: 'demo', + targets: [ + { agent: 'codex', rootDir: firstRoot, scope: 'project', source: 'explicit' }, + { agent: 'claude-code', rootDir: secondRoot, scope: 'project', source: 'explicit' } + ], + force: false, + home + })).rejects.toThrow(`skill already installed at ${secondSkillDir}`) + + expect(await exists(firstSkillDir)).toBe(false) + expect(await exists(join(home, '.skillhub', 'inventory.json'))).toBe(false) + }) + + test('rejects canonical target aliases before writing any installation', async () => { + globalThis.fetch = installFetch({ 'SKILL.md': '# Demo' }) + const home = await mkdtemp(join(tmpdir(), 'skillhub-install-home-')) + const targetParent = await mkdtemp(join(tmpdir(), 'skillhub-install-targets-')) + const genericRoot = join(targetParent, 'generic') + const codexRoot = join(targetParent, 'codex') + const skillDir = join(genericRoot, 'demo') + try { + await mkdir(genericRoot, { recursive: true }) + await symlink(genericRoot, codexRoot, process.platform === 'win32' ? 'junction' : 'dir') + + await expect(installSkill({ + registry: 'http://registry.test', + namespace: 'global', + slug: 'demo', + targets: [ + { agent: 'codex', rootDir: codexRoot, scope: 'user', source: 'detected' }, + { agent: 'generic', rootDir: genericRoot, scope: 'user', source: 'fallback' } + ], + force: false, + home + })).rejects.toThrow('multiple install targets resolve to') + + expect(await exists(skillDir)).toBe(false) + expect(await exists(join(home, '.skillhub', 'inventory.json'))).toBe(false) + } finally { + await rm(home, { recursive: true, force: true }) + await rm(targetParent, { recursive: true, force: true }) + } + }) + test('force replaces the old skill directory instead of overlaying files', async () => { globalThis.fetch = installFetch({ 'SKILL.md': '# New' }) const home = await mkdtemp(join(tmpdir(), 'skillhub-install-home-')) diff --git a/docs/skillhub/en/guide/cli.md b/docs/skillhub/en/guide/cli.md index 1a3069b9..6cbc779c 100644 --- a/docs/skillhub/en/guide/cli.md +++ b/docs/skillhub/en/guide/cli.md @@ -157,7 +157,7 @@ The CLI determines the installation location using the following logic: 1. If `--dir` is specified: Install to that directory, agent marked as `custom`. `--dir` is mutually exclusive with `--scope` and `--agent`. 2. If `--scope user|project` is specified: Limit detection to the chosen scope. - With `--agent `: Install to that profile's user or project skills directory directly. - - Without `--agent`: Detect existing skills directories within the chosen scope only. + - Without `--agent`: Detect existing skills directories within the chosen scope only. In interactive user scope, the `generic` target (`/.agents/skills/`) is always also offered and can be selected alone or together with detected targets. - No detected directory in the chosen scope → Fallback to `/.agents/skills/` for `--scope user` or `/.agents/skills/` for `--scope project`. 3. If `--agent` is specified (no `--scope`): Install to the corresponding Agent's skills directory (existing behaviour, unchanged). 4. If none of the above is specified: @@ -188,7 +188,7 @@ Each Agent has both project-level and user-level skills directories. Use `--scop | `kilo` | `/.kilo/skills/` | `~/.kilo/skills/` | | _fallback_ | `/.agents/skills/` | `~/.agents/skills/` | -For Agents not in the list, use `--dir` to specify the installation path. When `--scope user|project` finds no matching agent directory, the CLI falls back to the `_fallback_` row above. +For a custom path or an unsupported Agent directory, use `--dir` to specify the installation path. In interactive user scope, the `generic` target is offered alongside detected Agent targets. When `--scope user|project` finds no matching agent directory, the CLI falls back to the `_fallback_` row above. ### File Structure After Installation diff --git a/docs/skillhub/guide/cli.md b/docs/skillhub/guide/cli.md index 910d9cf5..22162d56 100644 --- a/docs/skillhub/guide/cli.md +++ b/docs/skillhub/guide/cli.md @@ -157,7 +157,7 @@ CLI 按以下逻辑确定安装位置: 1. 指定 `--dir`:安装到该目录,agent 标记为 `custom`。`--dir` 与 `--scope`、`--agent` 互斥。 2. 指定 `--scope user|project`:探测限定在该 scope 内。 - 同时指定 `--agent `:直接安装到该 profile 对应 scope 的 skills 目录。 - - 未指定 `--agent`:只探测该 scope 下已存在的 skills 目录。 + - 未指定 `--agent`:只探测该 scope 下已存在的 skills 目录。在交互式 user scope 下,始终额外提供 `generic` 目标(`/.agents/skills/`),可单独选择或与已探测目标同时选择。 - 该 scope 下未探测到 → fallback:`--scope user` 回退到 `/.agents/skills/`,`--scope project` 回退到 `/.agents/skills/`。 3. 指定 `--agent`(无 `--scope`):安装到对应 Agent 的 skills 目录(沿用现有行为,不变)。 4. 三者均未指定: @@ -188,7 +188,7 @@ CLI 按以下逻辑确定安装位置: | `kilo` | `/.kilo/skills/` | `~/.kilo/skills/` | | _fallback_ | `/.agents/skills/` | `~/.agents/skills/` | -对于不在列表中的 Agent,使用 `--dir` 指定安装路径。当 `--scope user|project` 找不到匹配的 agent 目录时,CLI 会回退到上表的 `_fallback_` 行。 +对于自定义路径或不在列表中的 Agent 目录,使用 `--dir` 显式指定安装路径。交互式 user scope 下会与已探测 Agent 目标一同提供 `generic` 目标;当 `--scope user|project` 找不到匹配的 agent 目录时,CLI 会回退到上表的 `_fallback_` 行。 ### 安装后的文件结构 From f3dbb57a806db620ae83cd76433b8efa69cb5a02 Mon Sep 17 00:00:00 2001 From: lhb6540 Date: Mon, 20 Jul 2026 11:35:44 +0800 Subject: [PATCH 41/81] =?UTF-8?q?fix(helm):=20=E4=BF=AE=E6=AD=A3=20CI=20?= =?UTF-8?q?=E6=B8=B2=E6=9F=93=E4=B8=8E=20PostgreSQL=20=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=91=98=E5=AF=86=E7=A0=81=E5=BC=95=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 同步 Helm CI matrix 与当前 values schema 和确定性凭据策略。 - 所有 CI 渲染加载测试凭据并迁移 Ingress TLS 数组配置 - PostgreSQL 使用 postgres 用户时引用管理员密码 key - 增加内置 Secret 和 existingSecret 的管理员用户契约测试 Signed-off-by: lhb6540 --- .github/workflows/pr-helm-chart.yml | 7 ++++--- charts/skillhub/templates/_helpers.tpl | 6 +++++- .../skillhub/tests/configuration-contracts.sh | 21 +++++++++++++++++++ 3 files changed, 30 insertions(+), 4 deletions(-) diff --git a/.github/workflows/pr-helm-chart.yml b/.github/workflows/pr-helm-chart.yml index bc5d8e03..5ec348d3 100644 --- a/.github/workflows/pr-helm-chart.yml +++ b/.github/workflows/pr-helm-chart.yml @@ -98,7 +98,8 @@ jobs: description: Ingress + TLS + cert-manager args: >- --set ingress.enabled=true - --set ingress.tls.enabled=true + --set-json 'ingress.hosts=[{"host":"skills.example.com","paths":[{"path":"/","pathType":"Prefix"}]}]' + --set-json 'ingress.tls=[{"hosts":["skills.example.com"],"secretName":"skills-tls"}]' --set ingress.certManager.enabled=true - name: s3-storage description: S3 存储 @@ -143,7 +144,7 @@ jobs: - name: Render template - ${{ matrix.scenario.name }} run: | echo "## ${{ matrix.scenario.description }}" - helm template test-release . ${{ matrix.scenario.args }} > rendered.yaml + helm template test-release . -f tests/test-values.yaml ${{ matrix.scenario.args }} > rendered.yaml echo "✅ Template rendered successfully" - name: Validate resources @@ -158,7 +159,7 @@ jobs: - name: Validate default dependency wiring if: ${{ matrix.scenario.name == 'bitnami-default' }} run: | - helm template test-release . --show-only templates/server-deployment.yaml > server.yaml + helm template test-release . -f tests/test-values.yaml --show-only templates/server-deployment.yaml > server.yaml grep -Fq 'value: "test-release-postgresql"' server.yaml grep -Fq 'value: "test-release-redis-master"' server.yaml grep -Fq 'name: test-release-postgresql' server.yaml diff --git a/charts/skillhub/templates/_helpers.tpl b/charts/skillhub/templates/_helpers.tpl index d287cfeb..2f4b9321 100644 --- a/charts/skillhub/templates/_helpers.tpl +++ b/charts/skillhub/templates/_helpers.tpl @@ -148,9 +148,13 @@ app.kubernetes.io/component: scanner {{- end -}} {{- end }} -{{- /* PostgreSQL 应用用户密码 Secret key */}} +{{- /* PostgreSQL 密码 Secret key;postgres 使用管理员密码,其他用户使用应用密码 */}} {{- define "skillhub.postgresql.passwordKey" -}} +{{- if eq .Values.postgresql.auth.username "postgres" -}} +{{- .Values.postgresql.auth.secretKeys.adminPasswordKey | default "postgres-password" -}} +{{- else -}} {{- .Values.postgresql.auth.secretKeys.userPasswordKey | default "password" -}} +{{- end -}} {{- end }} {{- /* PostgreSQL JDBC URL */}} diff --git a/charts/skillhub/tests/configuration-contracts.sh b/charts/skillhub/tests/configuration-contracts.sh index e3b33b7b..acf671a6 100755 --- a/charts/skillhub/tests/configuration-contracts.sh +++ b/charts/skillhub/tests/configuration-contracts.sh @@ -72,6 +72,27 @@ 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 \ From fef740b8105aab2eeea9924d744a9aa20cf68b0b Mon Sep 17 00:00:00 2001 From: jangrui Date: Tue, 21 Jul 2026 00:22:15 +0800 Subject: [PATCH 42/81] =?UTF-8?q?fix(ci):=20=E5=8E=BB=E6=8E=89=20kubeconfo?= =?UTF-8?q?rm=20schema-location=20=E7=9A=84=E5=86=85=E5=B1=82=E5=8D=95?= =?UTF-8?q?=E5=BC=95=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 单引号被当作字面字符传入,导致 cert-manager Certificate 校验报 "first path segment in URL cannot contain colon"。去掉后本地验证 Errors:1 → 0。 --- .github/workflows/pr-helm-chart.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr-helm-chart.yml b/.github/workflows/pr-helm-chart.yml index 5ec348d3..c980d3ec 100644 --- a/.github/workflows/pr-helm-chart.yml +++ b/.github/workflows/pr-helm-chart.yml @@ -179,4 +179,4 @@ jobs: uses: docker://ghcr.io/yannh/kubeconform:latest 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" + 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" From d8486cfb58e1c5d62ba24c7d04ac907954d6607a Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Tue, 21 Jul 2026 15:24:49 +0800 Subject: [PATCH 43/81] fix(scanner): pin LiteLLM for Alpine builds Signed-off-by: dongmucat <1127093059@qq.com> --- scanner/Dockerfile | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scanner/Dockerfile b/scanner/Dockerfile index 90eac126..f341893c 100644 --- a/scanner/Dockerfile +++ b/scanner/Dockerfile @@ -6,7 +6,9 @@ WORKDIR /app COPY backports/apply_1_0_2_llm_base_url_backport.py /tmp/apply_1_0_2_llm_base_url_backport.py -RUN pip install --no-cache-dir "cisco-ai-skill-scanner==${SKILL_SCANNER_VERSION}" && \ +RUN pip install --no-cache-dir \ + "cisco-ai-skill-scanner==${SKILL_SCANNER_VERSION}" \ + "litellm==1.90.2" && \ python /tmp/apply_1_0_2_llm_base_url_backport.py /usr/local/lib/python3.11/site-packages && \ rm /tmp/apply_1_0_2_llm_base_url_backport.py && \ addgroup -S app && \ From 9af4d391f342a447bffe4b750d10c38036db9d84 Mon Sep 17 00:00:00 2001 From: dongmucat <70678707+dongmucat@users.noreply.github.com> Date: Wed, 22 Jul 2026 15:01:50 +0800 Subject: [PATCH 44/81] docs(integrations): add Hermes Agent skill guide (#584) * docs(integrations): add Hermes skill guide Signed-off-by: dongmucat <1127093059@qq.com> * docs(integrations): clarify Hermes skill collision handling Signed-off-by: dongmucat <1127093059@qq.com> --------- Signed-off-by: dongmucat <1127093059@qq.com> --- README.md | 6 + README_zh.md | 6 + docs/hermes-integration-en.md | 278 ++++++++++++++++++++++++++++++++++ docs/hermes-integration.md | 278 ++++++++++++++++++++++++++++++++++ 4 files changed, 568 insertions(+) create mode 100644 docs/hermes-integration-en.md create mode 100644 docs/hermes-integration.md diff --git a/README.md b/README.md index d47ef5cc..2b068ab9 100644 --- a/README.md +++ b/README.md @@ -436,6 +436,12 @@ namespace `my-space` plus skill slug `my-skill`. 📖 **[Complete OpenClaw Integration Guide →](./docs/openclaw-integration.md)** +### [Hermes Agent](https://github.com/NousResearch/hermes-agent) + +[Hermes Agent](https://github.com/NousResearch/hermes-agent) uses the standard `SKILL.md` format and recursively discovers skills under `$HERMES_HOME/skills/`. Use SkillHub CLI's explicit `--dir` option to install a complete SkillHub package into Hermes without a registry adapter, then verify it with `hermes skills list`. + +📖 **[Complete Hermes Agent Integration Guide →](./docs/hermes-integration-en.md)** + ### [AstronClaw](https://agent.xfyun.cn/astron-claw) [AstronClaw](https://agent.xfyun.cn/astron-claw) is a cloud AI assistant built on OpenClaw's core capabilities, providing 24/7 online service through enterprise platforms like WeChat Work, DingTalk, and Feishu. It features a built-in skill system with over 130 official skills. You can connect it to a self-hosted SkillHub registry to enable one-click skill installation, search repository, dialogue-based automatic installation, and even custom skills management within your organization. diff --git a/README_zh.md b/README_zh.md index 80e65b75..42fb8225 100644 --- a/README_zh.md +++ b/README_zh.md @@ -370,6 +370,12 @@ namespace `my-space` 和 skill slug `my-skill`。 📖 **[完整 OpenClaw 集成指南 →](./docs/openclaw-integration.md)** +### [Hermes Agent](https://github.com/NousResearch/hermes-agent) + +[Hermes Agent](https://github.com/NousResearch/hermes-agent) 使用标准 `SKILL.md` 格式,并会递归发现 `$HERMES_HOME/skills/` 中的技能。通过 SkillHub CLI 的 `--dir` 参数即可把完整技能包安装到 Hermes,无需新增 registry 适配器;安装后可使用 `hermes skills list` 验证。 + +📖 **[完整 Hermes Agent 集成指南 →](./docs/hermes-integration.md)** + ### [AstronClaw](https://agent.xfyun.cn/astron-claw) [AstronClaw](https://agent.xfyun.cn/astron-claw) 是基于 OpenClaw 核心能力打造的云端 AI 助手,提供全天候在线服务,随时随地通过企业微信、钉钉、飞书等渠道提供服务。它内置了丰富的技能系统,您可以将其连接到自托管的 SkillHub 注册中心,支持技能市场一键安装、仓库搜索、对话自动安装,甚至管理和分发组织内部的自定义私有技能。 diff --git a/docs/hermes-integration-en.md b/docs/hermes-integration-en.md new file mode 100644 index 00000000..5c4031d8 --- /dev/null +++ b/docs/hermes-integration-en.md @@ -0,0 +1,278 @@ +# Hermes Agent Integration Guide + +This guide explains how to install skills from SkillHub into [NousResearch Hermes Agent](https://github.com/NousResearch/hermes-agent), then discover, load, update, and remove those skills in Hermes. + +“Hermes” in this guide means `NousResearch/hermes-agent`; it does not cover other projects with the same name. + +## Validated scope + +| Component | Validated version | Notes | +|-----------|-------------------|-------| +| SkillHub Server | `v0.2.13` | Public or self-hosted registry | +| SkillHub CLI | `0.1.8` | npm package `@astron-team/skillhub` | +| Hermes Agent | `0.18.2` | Upstream tag [`v2026.7.7.2`](https://github.com/NousResearch/hermes-agent/tree/v2026.7.7.2) | + +Validation date: 2026-07-17. + +Hermes 0.18.2 uses an [Agent Skills](https://agentskills.io/)-compatible `SKILL.md` format and recursively scans `$HERMES_HOME/skills/`. SkillHub CLI can extract a complete skill package into any explicit `--dir` target. The current integration therefore needs no format conversion, Hermes-specific CLI profile, or server adapter: + +```text +SkillHub registry + -> skillhub install --dir + -> //SKILL.md + -> Hermes discovers and loads the skill on demand +``` + +> Hermes 0.18.2 has no native SkillHub registry source. This guide uses SkillHub CLI for search, download, and local installation, while Hermes handles discovery and execution. + +## Prerequisites + +1. Install and initialize Hermes Agent. +2. Install SkillHub CLI: + +```bash +npm install -g @astron-team/skillhub + +skillhub version +hermes version +``` + +3. Ensure the skill package has a valid root `SKILL.md` with at least `name` and `description` frontmatter. + +The examples below use Bash/zsh. On Windows, use the same directory structure, replace the default Hermes home with `$HOME\.hermes`, and set variables using PowerShell syntax. + +## Quick start + +### 1. Configure the SkillHub registry + +Set the public or self-hosted SkillHub URL: + +```bash +export SKILLHUB_REGISTRY=https://skillhub.your-company.com +``` + +You can skip login for public skills that allow anonymous downloads. For team namespaces, restricted skills, or private deployments, save an API token first: + +```bash +skillhub login \ + --registry "$SKILLHUB_REGISTRY" \ + --token YOUR_API_TOKEN + +skillhub whoami --registry "$SKILLHUB_REGISTRY" +``` + +Use placeholder tokens in examples. Never write a real token into `SKILL.md`, scripts, or version control. + +### 2. Search for a skill + +```bash +skillhub search "pdf" --registry "$SKILLHUB_REGISTRY" +``` + +Record the namespace, slug, and required version. The following examples use `my-team/my-skill`: + +```bash +export SKILLHUB_NAMESPACE=my-team +export SKILLHUB_SKILL=my-skill +``` + +### 3. Install into the primary Hermes skills directory + +Set the home of the active Hermes profile. The default profile normally uses `~/.hermes`; if you use a custom `HERMES_HOME` or a named profile, point it at the actual profile directory: + +```bash +export HERMES_HOME="${HERMES_HOME:-$HOME/.hermes}" +export HERMES_SKILLHUB_DIR="$HERMES_HOME/skills/skillhub/$SKILLHUB_NAMESPACE" +``` + +Install the skill: + +```bash +skillhub install "$SKILLHUB_SKILL" \ + --namespace "$SKILLHUB_NAMESPACE" \ + --dir "$HERMES_SKILLHUB_DIR" \ + --registry "$SKILLHUB_REGISTRY" +``` + +SkillHub CLI preserves `SKILL.md`, `references/`, `scripts/`, `templates/`, `assets/`, and other package files. It also writes `.skillhub/metadata.json` to record the installation source. The resulting layout looks like this: + +```text +$HERMES_HOME/skills/ +└── skillhub/ + └── my-team/ + └── my-skill/ + ├── SKILL.md + ├── references/ # optional + ├── scripts/ # optional + └── .skillhub/ + └── metadata.json +``` + +Separating target directories by namespace reduces filesystem collisions between skills with the same slug. Hermes recursively scans these levels. + +### 4. Verify and load the skill in Hermes + +First, confirm that Hermes discovers the skill: + +```bash +hermes skills list --source local --enabled-only +``` + +Then start Hermes and invoke the slash command normalized from the skill `name`: + +```bash +hermes +``` + +```text +/my-skill +``` + +You can also ask Hermes in natural language to use the skill. Hermes lists the raw `SKILL.md` frontmatter `name`, but its slash command lowercases that name, replaces spaces and underscores with hyphens, removes other characters outside `a-z0-9-`, and collapses repeated hyphens. For example, `PDF_Tools` becomes `/pdf-tools`. The command may therefore differ from the SkillHub slug. + +If a running session does not immediately show a new skill, run `/reload-skills` or restart the session. + +## Update a skill + +SkillHub CLI 0.1.8 overwrites a local skill by repeating the install command with `--force`. Omitting `--version` resolves the latest published version; you can also pin one explicitly: + +```bash +skillhub install "$SKILLHUB_SKILL" \ + --namespace "$SKILLHUB_NAMESPACE" \ + --dir "$HERMES_SKILLHUB_DIR" \ + --registry "$SKILLHUB_REGISTRY" \ + --force + +# Pinned version example +skillhub install "$SKILLHUB_SKILL" \ + --namespace "$SKILLHUB_NAMESPACE" \ + --version 1.2.0 \ + --dir "$HERMES_SKILLHUB_DIR" \ + --registry "$SKILLHUB_REGISTRY" \ + --force +``` + +Review the new version before overwriting because `--force` replaces the existing skill directory. Afterward, run: + +```bash +skillhub list \ + --dir "$HERMES_SKILLHUB_DIR" \ + --registry "$SKILLHUB_REGISTRY" + +hermes skills list --source local --enabled-only +``` + +> `skillhub update` updates SkillHub CLI itself; it does not update installed skills. Refresh an installed skill with `skillhub install ... --force`. + +## Remove a skill + +First, list every installation from the same registry and confirm that there are no other same-slug skills you need to keep: + +```bash +skillhub list \ + --registry "$SKILLHUB_REGISTRY" +``` + +Then remove the local installation: + +```bash +skillhub remove "$SKILLHUB_SKILL" \ + --registry "$SKILLHUB_REGISTRY" +``` + +SkillHub CLI deletes both the skill directory and the local inventory record. Local `remove` in this version matches only registry and slug; it does not filter by namespace or directory. Every same-slug target from that registry, across all namespaces and installation directories, is removed. If the unfiltered `skillhub list` shows a match you need to keep, do not run the command; namespace- or directory-scoped removal requires a future CLI capability. + +After removal, run `/reload-skills`, restart the Hermes session, or confirm that the skill is gone with: + +```bash +hermes skills list --source local --enabled-only +``` + +## Optional: use a shared external skills directory + +When several agents share `~/.agents/skills`, install SkillHub skills into that shared tree instead of the primary Hermes directory: + +```bash +export SHARED_SKILLHUB_DIR="$HOME/.agents/skills/skillhub/$SKILLHUB_NAMESPACE" + +skillhub install "$SKILLHUB_SKILL" \ + --namespace "$SKILLHUB_NAMESPACE" \ + --dir "$SHARED_SKILLHUB_DIR" \ + --registry "$SKILLHUB_REGISTRY" +``` + +Merge the shared root into `$HERMES_HOME/config.yaml` without replacing existing `skills` settings: + +```yaml +skills: + external_dirs: + - ~/.agents/skills +``` + +Hermes lists and loads external skills alongside local skills. Do not rely on local shadowing: Hermes 0.18.2 refuses ambiguous `skill_view` matches across the local skills directory and `external_dirs`. Rename or remove a colliding copy instead. + +> `external_dirs` is not a read-only boundary. If the Hermes process can write to an external directory, Hermes skill-management tools can modify its files. Use filesystem permissions or an isolated Hermes profile when shared skills must remain read-only. + +## Compatibility and security boundaries + +- **Format compatibility is not complete runtime compatibility.** Hermes can read `SKILL.md` and supporting files, but agent-specific tools, MCP servers, commands, environment variables, and platform capabilities referenced by a skill still need individual verification. +- **Hermes treats this path as local.** A skill copied by SkillHub CLI does not run through the Hermes Skills Hub community-install scanner. Review the SkillHub security report and the skill contents before installation, and use Hermes terminal isolation where appropriate. +- **Keep multi-file packages intact.** Do not replace SkillHub CLI with the Hermes 0.18.2 direct-URL source for multi-file skills. That release guarantees a single `SKILL.md` for URL installs, whereas SkillHub CLI extracts the complete package. +- **Avoid name collisions.** Namespace-separated filesystem paths do not resolve slash-command collisions. Keep normalized command names unique within one Hermes profile. For example, `PDF Tools` and `pdf_tools` both become `/pdf-tools`. +- **Protect credentials.** A registry token is only for SkillHub access and does not belong in a skill package. Skills that need runtime secrets should use Hermes environment-variable and security settings. + +## Troubleshooting + +### The new skill is missing from the Hermes list + +Check these items in order: + +1. The current session uses the same `HERMES_HOME` used during installation. +2. The final path contains `/SKILL.md`. +3. `SKILL.md` contains valid `name` and `description` fields. +4. `platforms` or other frontmatter does not exclude the current operating system. +5. The skill appears after `/reload-skills` or in a new session. + +```bash +skillhub list --dir "$HERMES_SKILLHUB_DIR" --registry "$SKILLHUB_REGISTRY" +hermes skills list --source local +``` + +### Installation reports `skill already installed` + +Existing directories are not overwritten by default. Review the target version, then add `--force`: + +```bash +skillhub install "$SKILLHUB_SKILL" \ + --namespace "$SKILLHUB_NAMESPACE" \ + --dir "$HERMES_SKILLHUB_DIR" \ + --registry "$SKILLHUB_REGISTRY" \ + --force +``` + +### The CLI reports `registry unreachable` or a download failure + +- Confirm that `SKILLHUB_REGISTRY` is the SkillHub root URL. +- Run `skillhub search` against the same registry to distinguish registry reachability from a download failure. +- Check proxy, DNS, certificate, and self-hosted service status. +- Retry a transient network error only after confirming the service is healthy; do not bypass certificate failures by disabling TLS verification. + +### The skill is listed but fails during execution + +Check tool names, shell commands, script runtimes, packages, MCP servers, environment variables, and operating-system restrictions referenced by that skill. Those are skill-specific runtime compatibility concerns, not failures of `SKILL.md` discovery. + +### Can `hermes skills install` consume a SkillHub coordinate directly? + +Hermes 0.18.2 has no SkillHub registry source and cannot resolve a SkillHub namespace/slug directly. Use `skillhub install --dir ...` as shown in this guide. Native search, installation, updates, and security scanning inside Hermes would require a separately designed Hermes source adapter with its own protocol and acceptance scope. + +## Regression checks after upgrades + +After upgrading SkillHub CLI or Hermes, verify at least the following: + +1. `skillhub install --dir` still creates `/SKILL.md` and preserves support files. +2. `hermes skills list --source local --enabled-only` discovers the skill. +3. `/skill-name` loads `SKILL.md` and exposes support-file paths; then read one referenced file with `skill_view(name, file_path)` or exercise the script/asset the skill actually uses. +4. `skillhub install --force` overwrites the skill while keeping a healthy inventory. +5. Hermes no longer discovers the skill after `skillhub remove`. + +Upstream reference: [Hermes Skills System at v0.18.2](https://github.com/NousResearch/hermes-agent/blob/v2026.7.7.2/website/docs/user-guide/features/skills.md). diff --git a/docs/hermes-integration.md b/docs/hermes-integration.md new file mode 100644 index 00000000..25743bfd --- /dev/null +++ b/docs/hermes-integration.md @@ -0,0 +1,278 @@ +# Hermes Agent 集成指南 + +本文档说明如何把 SkillHub 中的技能安装到 [NousResearch Hermes Agent](https://github.com/NousResearch/hermes-agent),并在 Hermes 中发现、加载、更新和移除这些技能。 + +本文中的 “Hermes” 特指 `NousResearch/hermes-agent`,不适用于其他同名项目。 + +## 已验证范围 + +| 组件 | 已验证版本 | 说明 | +|------|------------|------| +| SkillHub Server | `v0.2.13` | 公开或自托管 registry | +| SkillHub CLI | `0.1.8` | npm 包 `@astron-team/skillhub` | +| Hermes Agent | `0.18.2` | 上游 tag [`v2026.7.7.2`](https://github.com/NousResearch/hermes-agent/tree/v2026.7.7.2) | + +验证日期:2026-07-17。 + +Hermes 0.18.2 使用兼容 [Agent Skills](https://agentskills.io/) 的 `SKILL.md` 格式,并递归扫描 `$HERMES_HOME/skills/`。SkillHub CLI 可以通过 `--dir` 把完整技能包解压到指定目录。因此,当前兼容链路不需要格式转换、Hermes 专用 CLI profile 或服务端适配: + +```text +SkillHub registry + -> skillhub install --dir + -> //SKILL.md + -> Hermes 发现并按需加载 +``` + +> Hermes 0.18.2 没有原生 SkillHub registry source。本指南使用 SkillHub CLI 负责搜索、下载和本地安装,Hermes 负责发现和执行技能。 + +## 前置条件 + +1. 已安装并初始化 Hermes Agent。 +2. 已安装 SkillHub CLI: + +```bash +npm install -g @astron-team/skillhub + +skillhub version +hermes version +``` + +3. 技能包根目录包含有效的 `SKILL.md`,其中至少有 `name` 和 `description` frontmatter。 + +以下示例使用 Bash/zsh。Windows 用户可使用同一目录结构,将默认 Hermes 主目录替换为 `$HOME\.hermes`,并按 PowerShell 语法设置变量。 + +## 快速开始 + +### 1. 配置 SkillHub registry + +设置公开或自托管 SkillHub 地址: + +```bash +export SKILLHUB_REGISTRY=https://skillhub.your-company.com +``` + +公开且允许匿名下载的技能可以跳过登录。访问团队命名空间、受限技能或私有部署时,先保存 API Token: + +```bash +skillhub login \ + --registry "$SKILLHUB_REGISTRY" \ + --token YOUR_API_TOKEN + +skillhub whoami --registry "$SKILLHUB_REGISTRY" +``` + +请使用占位 Token 演示,不要把真实 Token 写入 `SKILL.md`、脚本或版本库。 + +### 2. 搜索技能 + +```bash +skillhub search "pdf" --registry "$SKILLHUB_REGISTRY" +``` + +记录结果中的 namespace、slug 和所需版本。下面以 `my-team/my-skill` 为例: + +```bash +export SKILLHUB_NAMESPACE=my-team +export SKILLHUB_SKILL=my-skill +``` + +### 3. 安装到 Hermes 主技能目录 + +设置当前 Hermes profile 的主目录。默认 profile 通常是 `~/.hermes`;如果使用自定义 `HERMES_HOME` 或命名 profile,请指向实际 profile 目录: + +```bash +export HERMES_HOME="${HERMES_HOME:-$HOME/.hermes}" +export HERMES_SKILLHUB_DIR="$HERMES_HOME/skills/skillhub/$SKILLHUB_NAMESPACE" +``` + +安装技能: + +```bash +skillhub install "$SKILLHUB_SKILL" \ + --namespace "$SKILLHUB_NAMESPACE" \ + --dir "$HERMES_SKILLHUB_DIR" \ + --registry "$SKILLHUB_REGISTRY" +``` + +SkillHub CLI 会保留技能包中的 `SKILL.md`、`references/`、`scripts/`、`templates/`、`assets/` 等文件,并额外写入 `.skillhub/metadata.json` 记录安装来源。目录结构类似: + +```text +$HERMES_HOME/skills/ +└── skillhub/ + └── my-team/ + └── my-skill/ + ├── SKILL.md + ├── references/ # 可选 + ├── scripts/ # 可选 + └── .skillhub/ + └── metadata.json +``` + +按 namespace 分目录可以减少不同命名空间中同 slug 技能的文件路径冲突。Hermes 会递归扫描这些层级。 + +### 4. 在 Hermes 中验证和加载 + +先确认 Hermes 发现了技能: + +```bash +hermes skills list --source local --enabled-only +``` + +然后启动 Hermes,在会话中使用由技能 `name` 规范化得到的斜杠命令: + +```bash +hermes +``` + +```text +/my-skill +``` + +也可以在自然语言请求中明确要求 Hermes 使用该技能。Hermes 列表显示 `SKILL.md` frontmatter 中的原始 `name`,斜杠命令会把它转为小写、把空格和下划线替换为连字符、移除其他非 `a-z0-9-` 字符,并合并重复连字符。例如 `PDF_Tools` 对应 `/pdf-tools`。该命令不一定与 SkillHub slug 相同。 + +已经运行的会话未立即显示新技能时,执行 `/reload-skills` 或重新启动会话。 + +## 更新技能 + +SkillHub CLI 0.1.8 使用同一安装命令加 `--force` 覆盖本地技能。省略 `--version` 会解析最新已发布版本;也可以显式固定版本: + +```bash +skillhub install "$SKILLHUB_SKILL" \ + --namespace "$SKILLHUB_NAMESPACE" \ + --dir "$HERMES_SKILLHUB_DIR" \ + --registry "$SKILLHUB_REGISTRY" \ + --force + +# 固定版本示例 +skillhub install "$SKILLHUB_SKILL" \ + --namespace "$SKILLHUB_NAMESPACE" \ + --version 1.2.0 \ + --dir "$HERMES_SKILLHUB_DIR" \ + --registry "$SKILLHUB_REGISTRY" \ + --force +``` + +覆盖前请先审查新版本,因为 `--force` 会替换现有技能目录。更新后重新运行: + +```bash +skillhub list \ + --dir "$HERMES_SKILLHUB_DIR" \ + --registry "$SKILLHUB_REGISTRY" + +hermes skills list --source local --enabled-only +``` + +> `skillhub update` 更新的是 SkillHub CLI 自身,不会更新已安装技能。已安装技能使用 `skillhub install ... --force` 刷新。 + +## 移除技能 + +先列出同一 registry 中的全部安装,确认没有其他需要保留的同 slug 技能: + +```bash +skillhub list \ + --registry "$SKILLHUB_REGISTRY" +``` + +再移除本地安装: + +```bash +skillhub remove "$SKILLHUB_SKILL" \ + --registry "$SKILLHUB_REGISTRY" +``` + +SkillHub CLI 会同时删除技能目录和本地 inventory 记录。当前版本的本地 `remove` 仅按 registry 和 slug 匹配,不按 namespace 或目录过滤;同一 registry 下所有 namespace、所有安装目录中的相同 slug 都会被移除。如果未过滤的 `skillhub list` 中存在需要保留的匹配项,请不要执行该命令;按 namespace 或目录精确移除需要后续 CLI 能力支持。 + +移除后,使用 `/reload-skills`、重启 Hermes 会话,或运行以下命令确认技能已消失: + +```bash +hermes skills list --source local --enabled-only +``` + +## 可选:使用共享的 external skill 目录 + +如果多个 Agent 共用 `~/.agents/skills`,可以把 SkillHub 技能安装到共享目录,而不是 Hermes 主目录: + +```bash +export SHARED_SKILLHUB_DIR="$HOME/.agents/skills/skillhub/$SKILLHUB_NAMESPACE" + +skillhub install "$SKILLHUB_SKILL" \ + --namespace "$SKILLHUB_NAMESPACE" \ + --dir "$SHARED_SKILLHUB_DIR" \ + --registry "$SKILLHUB_REGISTRY" +``` + +然后把共享根目录合并到 `$HERMES_HOME/config.yaml`,不要覆盖已有的 `skills` 配置: + +```yaml +skills: + external_dirs: + - ~/.agents/skills +``` + +Hermes 会把 external skill 与本地技能一起列出和加载。不要依赖本地技能覆盖 external skill:Hermes 0.18.2 会拒绝加载本地技能目录与 `external_dirs` 之间存在歧义的 `skill_view` 匹配;请改名或移除其中一个冲突副本。 + +> `external_dirs` 不是只读边界。只要 Hermes 进程拥有写权限,Hermes 的技能管理工具就可能修改其中的文件。共享目录需要只读保护时,请使用文件系统权限或隔离的 Hermes profile。 + +## 兼容性与安全边界 + +- **格式兼容不等于运行时完全兼容。** Hermes 能读取 `SKILL.md` 和配套文件,但技能引用的 Agent 专用工具、MCP server、命令、环境变量或平台能力仍需逐项验证。 +- **Hermes 将此路径识别为 local skill。** 通过 SkillHub CLI 复制到本地的技能不会经过 Hermes Skills Hub 的 community 安装扫描。安装前应查看 SkillHub 安全报告并审查技能内容,必要时使用 Hermes 的终端隔离能力。 +- **保留多文件包。** 不要把多文件 SkillHub 技能改成 Hermes 0.18.2 的直接 URL 安装;该版本的 URL source 只保证单个 `SKILL.md`,而 SkillHub CLI 会解压完整包。 +- **避免名称冲突。** 文件路径按 namespace 隔离仍不能解决斜杠命令冲突;同一 Hermes profile 内应保持规范化后的命令名唯一。例如 `PDF Tools` 和 `pdf_tools` 都会变成 `/pdf-tools`。 +- **保护凭证。** Token 只用于 SkillHub registry 访问,不应写进技能包。需要运行时 secret 的技能应遵循 Hermes 的环境变量和安全设置方式。 + +## 常见问题 + +### Hermes 列表中没有新技能 + +依次检查: + +1. 当前会话的 `HERMES_HOME` 是否与安装时一致。 +2. 最终路径下是否存在 `/SKILL.md`。 +3. `SKILL.md` 是否包含有效的 `name` 和 `description`。 +4. `platforms` 等 frontmatter 是否排除了当前操作系统。 +5. 执行 `/reload-skills` 或启动新会话后是否出现。 + +```bash +skillhub list --dir "$HERMES_SKILLHUB_DIR" --registry "$SKILLHUB_REGISTRY" +hermes skills list --source local +``` + +### 安装提示 `skill already installed` + +已有目录默认不会被覆盖。先审查目标版本,再增加 `--force`: + +```bash +skillhub install "$SKILLHUB_SKILL" \ + --namespace "$SKILLHUB_NAMESPACE" \ + --dir "$HERMES_SKILLHUB_DIR" \ + --registry "$SKILLHUB_REGISTRY" \ + --force +``` + +### 提示 `registry unreachable` 或下载失败 + +- 核对 `SKILLHUB_REGISTRY` 是否是 SkillHub 根地址。 +- 先运行同一 registry 的 `skillhub search` 判断 registry 是否可达。 +- 检查代理、DNS、证书和自托管服务状态。 +- 短暂网络错误可以在确认服务正常后重试;不要通过关闭 TLS 校验绕过证书问题。 + +### 技能已列出但执行失败 + +检查技能引用的工具名称、shell 命令、脚本解释器、依赖包、MCP server、环境变量和操作系统限制。此类问题属于具体技能的运行时兼容性,不代表 `SKILL.md` 发现链路失败。 + +### 能否直接运行 `hermes skills install` 安装 SkillHub 坐标? + +Hermes 0.18.2 没有 SkillHub registry source,不能直接解析 SkillHub 的 namespace/slug。请使用本指南中的 `skillhub install --dir ...`。如果未来需要 Hermes 内原生搜索、安装、更新和安全扫描,应单独设计 Hermes source adapter,并重新定义协议和验收范围。 + +## 升级后的回归检查 + +升级 SkillHub CLI 或 Hermes 后,至少重新验证: + +1. `skillhub install --dir` 仍生成 `/SKILL.md` 并保留配套文件。 +2. `hermes skills list --source local --enabled-only` 能发现技能。 +3. `/skill-name` 能加载 `SKILL.md` 并暴露配套文件路径;再通过 `skill_view(name, file_path)` 读取一个实际引用文件,或执行技能使用的脚本/资产验证其运行时路径。 +4. `skillhub install --force` 能覆盖更新且 inventory 正常。 +5. `skillhub remove` 后 Hermes 不再发现该技能。 + +上游参考:[Hermes Skills System(v0.18.2)](https://github.com/NousResearch/hermes-agent/blob/v2026.7.7.2/website/docs/user-guide/features/skills.md)。 From 6ee746d371a82731c6e72f2117b47a2596bc9f92 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Wed, 22 Jul 2026 17:33:26 +0800 Subject: [PATCH 45/81] chore(cli): bump version to 0.1.9 Signed-off-by: dongmucat <1127093059@qq.com> --- cli/package.json | 2 +- cli/src/generated/pkg-info.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cli/package.json b/cli/package.json index 3845975b..a7b94f7d 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@astron-team/skillhub", - "version": "0.1.8", + "version": "0.1.9", "description": "Manage and install skills for AI coding agents", "keywords": [ "skillhub", diff --git a/cli/src/generated/pkg-info.ts b/cli/src/generated/pkg-info.ts index bc445c51..9445834b 100644 --- a/cli/src/generated/pkg-info.ts +++ b/cli/src/generated/pkg-info.ts @@ -1,3 +1,3 @@ // Generated by scripts/generate-pkg-info.ts - do not edit by hand. export const PKG_NAME = "@astron-team/skillhub" -export const PKG_VERSION = "0.1.8" +export const PKG_VERSION = "0.1.9" From 74bad000e3a2d0e069228e9f4a766d1e24433357 Mon Sep 17 00:00:00 2001 From: shychee Date: Tue, 21 Jul 2026 15:58:32 +0800 Subject: [PATCH 46/81] fix(search): rebuild search index asynchronously after label change Attaching or detaching a skill label triggers a search index rebuild via an afterCommit callback. Because LabelSearchSyncService.rebuildSkill ran synchronously on the request thread, the @Transactional index write executed inside the already-committed transaction-synchronization phase and was silently dropped -- the search document was never written, so label keywords never became searchable. Move rebuildSkill onto the skillhubEventExecutor with @Async (matching the existing rebuildSkills batch path) so the rebuild runs on a fresh thread and transaction. Add an integration test that fails on the old synchronous path and passes with the async fix. Signed-off-by: shychee --- .../service/LabelSearchSyncService.java | 7 +- .../LabelSearchSyncIntegrationTest.java | 137 ++++++++++++++++++ 2 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/service/LabelSearchSyncIntegrationTest.java diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/LabelSearchSyncService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/LabelSearchSyncService.java index 4920402d..0dc9ce41 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/LabelSearchSyncService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/LabelSearchSyncService.java @@ -20,8 +20,13 @@ public class LabelSearchSyncService { this.searchRebuildService = searchRebuildService; } + @Async("skillhubEventExecutor") public void rebuildSkill(Long skillId) { - searchRebuildService.rebuildBySkill(skillId); + try { + searchRebuildService.rebuildBySkill(skillId); + } catch (RuntimeException ex) { + log.error("Failed to rebuild search document for skill {}", skillId, ex); + } } @Async("skillhubEventExecutor") diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/LabelSearchSyncIntegrationTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/LabelSearchSyncIntegrationTest.java new file mode 100644 index 00000000..c7b9dd3a --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/LabelSearchSyncIntegrationTest.java @@ -0,0 +1,137 @@ +package com.iflytek.skillhub.service; + +import com.iflytek.skillhub.SkillhubApplication; +import com.iflytek.skillhub.TestRedisConfig; +import com.iflytek.skillhub.domain.label.LabelDefinition; +import com.iflytek.skillhub.domain.label.LabelDefinitionRepository; +import com.iflytek.skillhub.domain.label.LabelTranslation; +import com.iflytek.skillhub.domain.label.LabelTranslationRepository; +import com.iflytek.skillhub.domain.label.LabelType; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.namespace.NamespaceType; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.infra.jpa.SkillSearchDocumentEntity; +import com.iflytek.skillhub.infra.jpa.SkillSearchDocumentJpaRepository; +import com.iflytek.skillhub.search.SearchEmbeddingService; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; + +/** + * Reproduces the bug where attaching a skill label does not update the search + * index. The label keyword should appear in the rebuilt search document after + * {@code attachLabel} commits. + * + *

With the upstream (synchronous) {@code LabelSearchSyncService.rebuildSkill}, + * the rebuild runs inside the {@code afterCommit} callback on the request thread, + * where the {@code @Transactional index()} write does not persist — so the keyword + * never lands in the index and this test fails. Adding {@code @Async} moves the + * rebuild to a fresh thread/transaction and the keyword appears. + */ +@SpringBootTest(classes = SkillhubApplication.class) +@ActiveProfiles("test") +@Import(TestRedisConfig.class) +class LabelSearchSyncIntegrationTest { + + @Autowired + private SkillLabelAppService skillLabelAppService; + + @Autowired + private NamespaceRepository namespaceRepository; + + @Autowired + private SkillRepository skillRepository; + + @Autowired + private LabelDefinitionRepository labelDefinitionRepository; + + @Autowired + private LabelTranslationRepository labelTranslationRepository; + + @Autowired + private SkillSearchDocumentJpaRepository skillSearchDocumentJpaRepository; + + @MockBean + private SearchEmbeddingService searchEmbeddingService; + + @BeforeEach + void setUp() { + when(searchEmbeddingService.embed(anyString())).thenReturn(""); + when(searchEmbeddingService.similarity(anyString(), anyString())).thenReturn(0.0d); + } + + @Test + void attachingLabel_updatesSearchIndexWithLabelKeyword() throws Exception { + String suffix = UUID.randomUUID().toString().substring(0, 8); + String ownerId = "owner-" + suffix; + // ASCII display name so the tokenizer keeps it as a single searchable token. + String labelDisplayName = "MachineLearning" + suffix; + String labelSlug = "ml-" + suffix; + + Namespace namespace = new Namespace("ns-" + suffix, "NS " + suffix, ownerId); + namespace.setType(NamespaceType.GLOBAL); + namespace = namespaceRepository.save(namespace); + + Skill skill = new Skill(namespace.getId(), "skill-" + suffix, ownerId, SkillVisibility.PUBLIC); + skill.setDisplayName("Skill " + suffix); + skill.setSummary("A skill used to reproduce the label search sync bug."); + skill.setCreatedBy(ownerId); + skill.setUpdatedBy(ownerId); + skill = skillRepository.save(skill); + skillRepository.flush(); + + LabelDefinition label = labelDefinitionRepository.save( + new LabelDefinition(labelSlug, LabelType.RECOMMENDED, true, 0, ownerId)); + labelTranslationRepository.saveAll(List.of( + new LabelTranslation(label.getId(), "en", labelDisplayName))); + labelTranslationRepository.flush(); + + // Baseline: nothing indexed yet. + assertThat(skillSearchDocumentJpaRepository.findBySkillId(skill.getId())).isEmpty(); + + // Act: attach the label as the skill owner (passes resolve + permission checks). + Map ownerRoles = Map.of(namespace.getId(), NamespaceRole.OWNER); + skillLabelAppService.attachLabel( + namespace.getSlug(), + skill.getSlug(), + labelSlug, + ownerId, + ownerRoles, + new AuditRequestContext("127.0.0.1", "junit")); + + // Assert: the rebuilt search document must contain the label keyword. + SkillSearchDocumentEntity indexed = awaitIndexedDocument(skill.getId()); + assertThat(indexed.getKeywords()) + .as("label keyword should be indexed after attachLabel commits") + .contains(labelDisplayName); + } + + private SkillSearchDocumentEntity awaitIndexedDocument(Long skillId) throws InterruptedException { + Instant deadline = Instant.now().plus(Duration.ofSeconds(15)); + Optional indexed = skillSearchDocumentJpaRepository.findBySkillId(skillId); + while (indexed.isEmpty() && Instant.now().isBefore(deadline)) { + Thread.sleep(100L); + indexed = skillSearchDocumentJpaRepository.findBySkillId(skillId); + } + return indexed.orElseThrow( + () -> new AssertionError("Expected search document for skill " + skillId)); + } +} From de033da537c599d9aa65a5622a016d9718b91659 Mon Sep 17 00:00:00 2001 From: shychee Date: Wed, 22 Jul 2026 18:37:28 +0800 Subject: [PATCH 47/81] fix(search): make index writes REQUIRES_NEW to survive async caller-runs fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The @Async rebuildSkill fix relied on a fresh thread giving a clean transaction boundary. But skillhubEventExecutor uses CallerRunsPolicy: under saturation the rejected task runs on the caller (request) thread, back inside the afterCommit synchronization phase — the original failure context where the @Transactional index write is silently dropped. Mark SearchIndexService.index as REQUIRES_NEW so it always suspends any lingering post-commit synchronization and commits in its own transaction, independent of whether the async dispatch actually happened. Add regression tests: detach removes the label keyword, and a synchronous rebuild inside the afterCommit phase still persists the document (fails without REQUIRES_NEW). Signed-off-by: shychee --- .../LabelSearchSyncIntegrationTest.java | 132 ++++++++++++++++++ .../PostgresFullTextIndexService.java | 3 +- 2 files changed, 134 insertions(+), 1 deletion(-) diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/LabelSearchSyncIntegrationTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/LabelSearchSyncIntegrationTest.java index c7b9dd3a..257d33d2 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/LabelSearchSyncIntegrationTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/LabelSearchSyncIntegrationTest.java @@ -17,6 +17,7 @@ import com.iflytek.skillhub.domain.skill.SkillVisibility; import com.iflytek.skillhub.infra.jpa.SkillSearchDocumentEntity; import com.iflytek.skillhub.infra.jpa.SkillSearchDocumentJpaRepository; import com.iflytek.skillhub.search.SearchEmbeddingService; +import com.iflytek.skillhub.search.SearchRebuildService; import java.time.Duration; import java.time.Instant; import java.util.List; @@ -30,6 +31,9 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.context.annotation.Import; import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.transaction.support.TransactionTemplate; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.anyString; @@ -69,6 +73,12 @@ class LabelSearchSyncIntegrationTest { @Autowired private SkillSearchDocumentJpaRepository skillSearchDocumentJpaRepository; + @Autowired + private SearchRebuildService searchRebuildService; + + @Autowired + private TransactionTemplate transactionTemplate; + @MockBean private SearchEmbeddingService searchEmbeddingService; @@ -124,6 +134,65 @@ class LabelSearchSyncIntegrationTest { .contains(labelDisplayName); } + @Test + void detachingLabel_removesKeywordFromSearchIndex() throws Exception { + Fixture f = createFixture(); + + skillLabelAppService.attachLabel( + f.namespaceSlug, f.skillSlug, f.labelSlug, f.ownerId, f.ownerRoles, auditContext()); + SkillSearchDocumentEntity afterAttach = awaitIndexedDocument(f.skillId); + assertThat(afterAttach.getKeywords()) + .as("precondition: label keyword indexed after attach") + .contains(f.labelDisplayName); + + // Act: detach the same label. + skillLabelAppService.detachLabel( + f.namespaceSlug, f.skillSlug, f.labelSlug, f.ownerId, f.ownerRoles, auditContext()); + + // Assert: the rebuilt document must no longer contain the label keyword. + awaitKeywordAbsent(f.skillId, f.labelDisplayName); + } + + /** + * Guards against the {@code CallerRunsPolicy} regression: when the executor is + * saturated, {@code rebuildSkill} runs synchronously on the request thread inside + * the {@code afterCommit} phase — the exact context where the index write used to be + * dropped. This exercises that path directly (no async hop) and asserts the document + * is still persisted, proving the fix relies on {@code REQUIRES_NEW}, not on the + * executor having spare capacity. + */ + @Test + void syncRebuildInAfterCommitPhase_persistsIndex() throws Exception { + Fixture f = createFixture(); + + // Establish the skill-label association and a baseline index via the normal path. + skillLabelAppService.attachLabel( + f.namespaceSlug, f.skillSlug, f.labelSlug, f.ownerId, f.ownerRoles, auditContext()); + awaitIndexedDocument(f.skillId); + + // Clear the index so we can observe the synchronous rebuild in isolation. + transactionTemplate.executeWithoutResult( + status -> skillSearchDocumentJpaRepository.deleteBySkillId(f.skillId)); + assertThat(skillSearchDocumentJpaRepository.findBySkillId(f.skillId)).isEmpty(); + + // Rebuild synchronously on the caller thread, inside a post-commit synchronization + // (mirrors the CallerRuns fallback from afterCommit(() -> rebuildSkill(...))). + transactionTemplate.executeWithoutResult(status -> + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + searchRebuildService.rebuildBySkill(f.skillId); + } + })); + + SkillSearchDocumentEntity indexed = skillSearchDocumentJpaRepository.findBySkillId(f.skillId) + .orElseThrow(() -> new AssertionError( + "synchronous rebuild in afterCommit phase must persist the index document")); + assertThat(indexed.getKeywords()) + .as("label keyword must be indexed even on the synchronous caller-runs path") + .contains(f.labelDisplayName); + } + private SkillSearchDocumentEntity awaitIndexedDocument(Long skillId) throws InterruptedException { Instant deadline = Instant.now().plus(Duration.ofSeconds(15)); Optional indexed = skillSearchDocumentJpaRepository.findBySkillId(skillId); @@ -134,4 +203,67 @@ class LabelSearchSyncIntegrationTest { return indexed.orElseThrow( () -> new AssertionError("Expected search document for skill " + skillId)); } + + private void awaitKeywordAbsent(Long skillId, String keyword) throws InterruptedException { + Instant deadline = Instant.now().plus(Duration.ofSeconds(15)); + while (Instant.now().isBefore(deadline)) { + Optional indexed = + skillSearchDocumentJpaRepository.findBySkillId(skillId); + if (indexed.isPresent() && !indexed.get().getKeywords().contains(keyword)) { + return; + } + Thread.sleep(100L); + } + String keywords = skillSearchDocumentJpaRepository.findBySkillId(skillId) + .map(SkillSearchDocumentEntity::getKeywords) + .orElse(""); + throw new AssertionError( + "Expected keyword '" + keyword + "' to be removed from index for skill " + + skillId + " but keywords were: " + keywords); + } + + private AuditRequestContext auditContext() { + return new AuditRequestContext("127.0.0.1", "junit"); + } + + private Fixture createFixture() { + String suffix = UUID.randomUUID().toString().substring(0, 8); + String ownerId = "owner-" + suffix; + // ASCII display name so the tokenizer keeps it as a single searchable token. + String labelDisplayName = "MachineLearning" + suffix; + String labelSlug = "ml-" + suffix; + + Namespace namespace = new Namespace("ns-" + suffix, "NS " + suffix, ownerId); + namespace.setType(NamespaceType.GLOBAL); + namespace = namespaceRepository.save(namespace); + + Skill skill = new Skill(namespace.getId(), "skill-" + suffix, ownerId, SkillVisibility.PUBLIC); + skill.setDisplayName("Skill " + suffix); + skill.setSummary("A skill used to reproduce the label search sync bug."); + skill.setCreatedBy(ownerId); + skill.setUpdatedBy(ownerId); + skill = skillRepository.save(skill); + skillRepository.flush(); + + LabelDefinition label = labelDefinitionRepository.save( + new LabelDefinition(labelSlug, LabelType.RECOMMENDED, true, 0, ownerId)); + labelTranslationRepository.saveAll(List.of( + new LabelTranslation(label.getId(), "en", labelDisplayName))); + labelTranslationRepository.flush(); + + return new Fixture( + namespace.getSlug(), skill.getSlug(), skill.getId(), + labelSlug, labelDisplayName, ownerId, + Map.of(namespace.getId(), NamespaceRole.OWNER)); + } + + private record Fixture( + String namespaceSlug, + String skillSlug, + Long skillId, + String labelSlug, + String labelDisplayName, + String ownerId, + Map ownerRoles) { + } } diff --git a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextIndexService.java b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextIndexService.java index bac3cce1..304b5ac0 100644 --- a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextIndexService.java +++ b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextIndexService.java @@ -6,6 +6,7 @@ import com.iflytek.skillhub.search.SearchEmbeddingService; import com.iflytek.skillhub.search.SearchIndexService; import com.iflytek.skillhub.search.SkillSearchDocument; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import java.util.List; @@ -32,7 +33,7 @@ public class PostgresFullTextIndexService implements SearchIndexService { } @Override - @Transactional + @Transactional(propagation = Propagation.REQUIRES_NEW) public void index(SkillSearchDocument document) { SkillSearchDocument normalizedDocument = normalize(document); Optional existing = repository.findBySkillId(document.skillId()); From 55a739a1bf68f7cf38224eb6ad79e4a045e5982e Mon Sep 17 00:00:00 2001 From: FenjuFu <92919259+FenjuFu@users.noreply.github.com> Date: Wed, 22 Jul 2026 23:15:52 +0800 Subject: [PATCH 48/81] docs(integrations): add HarnessClaw Engine skill guide HarnessClaw Engine loads skills from SKILL.md files with YAML frontmatter and parameter substitution, so SkillHub packages install into it directly via the CLI --dir option, the same way Hermes Agent does. Signed-off-by: FenjuFu --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index 2b068ab9..fe0fb4b9 100644 --- a/README.md +++ b/README.md @@ -442,6 +442,13 @@ namespace `my-space` plus skill slug `my-skill`. 📖 **[Complete Hermes Agent Integration Guide →](./docs/hermes-integration-en.md)** +### [HarnessClaw Engine](https://github.com/harnessclaw/harnessclaw-engine) + +[HarnessClaw Engine](https://github.com/harnessclaw/harnessclaw-engine) is a Go LLM programming assistant engine that exposes its capabilities over WebSocket. It loads skills from `SKILL.md` files with YAML frontmatter and parameter substitution, scanning each configured directory for `skill-name/SKILL.md` (default `~/.harnessclaw/workspace/skills/`, with earlier directories taking priority on name conflicts). Install a SkillHub package straight into that directory with the CLI's `--dir` option, no registry adapter required: + +```bash +npx clawhub --dir ~/.harnessclaw/workspace/skills install my-skill +``` ### [AstronClaw](https://agent.xfyun.cn/astron-claw) [AstronClaw](https://agent.xfyun.cn/astron-claw) is a cloud AI assistant built on OpenClaw's core capabilities, providing 24/7 online service through enterprise platforms like WeChat Work, DingTalk, and Feishu. It features a built-in skill system with over 130 official skills. You can connect it to a self-hosted SkillHub registry to enable one-click skill installation, search repository, dialogue-based automatic installation, and even custom skills management within your organization. From 3d6c7db040e94c8eebcc275a37a60b0a6529a1bf Mon Sep 17 00:00:00 2001 From: FenjuFu <92919259+FenjuFu@users.noreply.github.com> Date: Wed, 22 Jul 2026 23:15:57 +0800 Subject: [PATCH 49/81] docs(integrations): add HarnessClaw Engine skill guide HarnessClaw Engine loads skills from SKILL.md files with YAML frontmatter and parameter substitution, so SkillHub packages install into it directly via the CLI --dir option, the same way Hermes Agent does. Signed-off-by: FenjuFu --- README_zh.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README_zh.md b/README_zh.md index 42fb8225..3cfc79d6 100644 --- a/README_zh.md +++ b/README_zh.md @@ -376,6 +376,13 @@ namespace `my-space` 和 skill slug `my-skill`。 📖 **[完整 Hermes Agent 集成指南 →](./docs/hermes-integration.md)** +### [HarnessClaw Engine](https://github.com/harnessclaw/harnessclaw-engine) + +[HarnessClaw Engine](https://github.com/harnessclaw/harnessclaw-engine) 是基于 Go 的 LLM 编程助手引擎,通过 WebSocket 协议对外提供能力。它从 `SKILL.md` 文件加载技能,支持 YAML frontmatter 与参数替换,并按配置顺序扫描各目录下的 `skill-name/SKILL.md`(默认 `~/.harnessclaw/workspace/skills/`,靠前的目录在重名时优先)。通过 SkillHub CLI 的 `--dir` 参数即可把技能包直接安装到该目录,无需新增 registry 适配器: + +```bash +npx clawhub --dir ~/.harnessclaw/workspace/skills install my-skill +``` ### [AstronClaw](https://agent.xfyun.cn/astron-claw) [AstronClaw](https://agent.xfyun.cn/astron-claw) 是基于 OpenClaw 核心能力打造的云端 AI 助手,提供全天候在线服务,随时随地通过企业微信、钉钉、飞书等渠道提供服务。它内置了丰富的技能系统,您可以将其连接到自托管的 SkillHub 注册中心,支持技能市场一键安装、仓库搜索、对话自动安装,甚至管理和分发组织内部的自定义私有技能。 From 5b9fc1627718f2981f3e18b3299357e2218f4f81 Mon Sep 17 00:00:00 2001 From: FenjuFu <92919259+FenjuFu@users.noreply.github.com> Date: Thu, 23 Jul 2026 10:07:52 +0800 Subject: [PATCH 50/81] docs(readme): add star/watch buttons and guidance to first screen The badge row had no star or watch affordance. Adds social-style badges and a one-line note under the intro explaining why starring matters and how to watch releases only, in both language versions. --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 2b068ab9..38611044 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,9 @@ [![Java](https://img.shields.io/badge/java-21-ED8B00?logo=openjdk&logoColor=white)](https://openjdk.org/projects/jdk/21/) [![React](https://img.shields.io/badge/react-19-61DAFB?logo=react&logoColor=black)](https://react.dev) +[![GitHub Stars](https://img.shields.io/github/stars/iflytek/skillhub?style=social)](https://github.com/iflytek/skillhub/stargazers) +[![GitHub Watchers](https://img.shields.io/github/watchers/iflytek/skillhub?style=social)](https://github.com/iflytek/skillhub/watchers) +

@@ -35,6 +38,8 @@ it to a namespace, and let others find it through search or install it via CLI. Built for on-premise deployment behind your firewall, with the same polish you'd expect from a public registry. +> ⭐ If SkillHub fits your team, **star** the repo to help other teams find it, and **Watch → Custom → Releases** to get notified when a new version ships. + ## Documentation - 📖 **[User Guide](https://iflytek.github.io/skillhub/)** — Skill publishing, search, CLI usage and other user guides From 8dd0598acb1cea007cb67085cefd0f911af6392b Mon Sep 17 00:00:00 2001 From: FenjuFu <92919259+FenjuFu@users.noreply.github.com> Date: Thu, 23 Jul 2026 10:07:57 +0800 Subject: [PATCH 51/81] docs(readme): add star/watch buttons and guidance to first screen The badge row had no star or watch affordance. Adds social-style badges and a one-line note under the intro explaining why starring matters and how to watch releases only, in both language versions. --- README_zh.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README_zh.md b/README_zh.md index 42fb8225..fa233133 100644 --- a/README_zh.md +++ b/README_zh.md @@ -14,6 +14,9 @@ [![Java](https://img.shields.io/badge/java-21-ED8B00?logo=openjdk&logoColor=white)](https://openjdk.org/projects/jdk/21/) [![React](https://img.shields.io/badge/react-19-61DAFB?logo=react&logoColor=black)](https://react.dev) +[![GitHub Stars](https://img.shields.io/github/stars/iflytek/skillhub?style=social)](https://github.com/iflytek/skillhub/stargazers) +[![GitHub Watchers](https://img.shields.io/github/watchers/iflytek/skillhub?style=social)](https://github.com/iflytek/skillhub/watchers) +
--- @@ -24,6 +27,8 @@ SkillHub 是一个自托管平台,为团队提供私有的、受治理的智能体技能共享空间。发布技能包,推送到命名空间,让其他人通过搜索发现或通过 CLI 安装。专为防火墙后的本地部署而构建,提供与公共注册中心相同的精致体验。 +> ⭐ 如果 SkillHub 适合你的团队,欢迎 **Star** 本仓库帮助更多团队发现它;点 **Watch → Custom → Releases** 可在新版本发布时收到通知。 + ## 文档 - 📖 **[用户指南](https://iflytek.github.io/skillhub/)** — 技能发布、搜索、CLI 使用等用户操作指南 From 03085f19b59096e0dfe5b93d10bcc97fe51c9b90 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Tue, 28 Jul 2026 11:08:37 +0800 Subject: [PATCH 52/81] docs(auth): define revoked token validation design (#605) Signed-off-by: dongmucat <1127093059@qq.com> --- ...6-07-28-revoked-token-validation-design.md | 150 ++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-28-revoked-token-validation-design.md diff --git a/docs/superpowers/specs/2026-07-28-revoked-token-validation-design.md b/docs/superpowers/specs/2026-07-28-revoked-token-validation-design.md new file mode 100644 index 00000000..a50ac5ce --- /dev/null +++ b/docs/superpowers/specs/2026-07-28-revoked-token-validation-design.md @@ -0,0 +1,150 @@ +# Revoked API Token Validation Design + +## Goal + +Prove and preserve fail-closed API-token behavior across the CLI API using a +real persisted token lifecycle. Invalid Bearer credentials must return HTTP +401 before endpoint business logic runs, while requests without an +`Authorization` header retain the existing anonymous-public-read contract and +valid credentials without sufficient authorization continue to return HTTP +403. + +## Scope + +This change covers the following CLI routes: + +- `GET /api/cli/v1/auth/whoami` +- `GET /api/cli/v1/skills/search` +- `GET /api/cli/v1/skills/{namespace}/{slug}/resolve` +- `GET /api/cli/v1/skills/{namespace}/{slug}/download` +- `GET /api/cli/v1/skills/{namespace}/{slug}/versions/{version}/download` + +It also covers the authenticated-versus-forbidden boundary on an existing +scope-protected CLI route. It does not add endpoints, change response fields, +change token storage, add a database migration, or change anonymous resource +visibility rules. + +## Current-State Finding + +The fail-closed implementation from closed PR #511 was later included in the +single replacement PR #523 and is present in both v0.2.14 and current `main`. +`ApiTokenAuthenticationFilter` already validates Bearer credentials before +business logic and rejects empty, malformed, unknown, expired, revoked, +missing-user, and disabled-user credentials through the configured +`AuthenticationEntryPoint`. + +The verified repository gap is regression coverage, not a demonstrated +production-code gap. Existing tests separately prove token lifecycle +validation and invalid-Bearer filtering, but they do not exercise persisted +token creation, revocation, and all affected CLI endpoints in one integrated +matrix. The CLI API table in `docs/03-authentication-design.md` also retains +legacy paths, and there is no dedicated OpenAPI 3.0 authentication contract in +`docs/api/`. + +## Architecture + +`ApiTokenAuthenticationFilter` remains the single Bearer-authentication entry +point. Controllers must not duplicate token parsing or lifecycle checks. + +The regression test will boot the Spring application with MockMvc, real +`ApiTokenService`, real `ApiTokenRepository`, and real user persistence. CLI +endpoint business services may be mocked only to make successful public-read +responses deterministic; authentication and token lifecycle components remain +real. This isolates the contract boundary under test: a rejected credential +must stop in the security chain before controller business logic executes. + +Production authentication code will be changed only when a new regression +test fails for the expected behavioral reason. Any fix must be the smallest +change at the shared authentication or token-validation source of the failure. +Endpoint-specific authentication patches and unrelated refactoring are out of +scope. + +## Persisted Token Lifecycle + +The test fixture creates an active user and issues a token through +`ApiTokenService`, retaining only the raw token returned at creation time. +Lifecycle transitions use production persistence paths: + +1. Call an affected endpoint with the valid raw token and confirm successful + authentication. +2. Revoke the token through `ApiTokenService.revokeToken`. +3. Call every affected endpoint with the same raw token. +4. Assert HTTP 401 and confirm protected endpoint business logic was not + reached. + +Expired-token coverage persists a token with an expiration timestamp earlier +than the service clock, then validates it through the same filter and +repository path. Unknown and malformed tokens exercise the same HTTP security +chain without creating a token row. + +## Behavioral Matrix + +| Credential state | `whoami` | Public `search` | Public `resolve` | Public `download` | Meaning | +|---|---:|---:|---:|---:|---| +| No `Authorization` header | 401 | Existing anonymous result | Existing anonymous result | Existing anonymous result | Anonymous access is preserved only where already public | +| Valid active token | 200 | Authenticated result | Authenticated result | Authenticated result | Principal and roles/scopes are projected | +| Revoked token | 401 | 401 | 401 | 401 | Credential cannot degrade to anonymous | +| Expired token | 401 | 401 | 401 | 401 | Credential cannot degrade to anonymous | +| Unknown token | 401 | 401 | 401 | 401 | Credential cannot degrade to anonymous | +| Malformed or empty Bearer | 401 | 401 | 401 | 401 | Authentication attempt is rejected before validation/business logic | +| Valid token lacking required authorization | N/A | N/A | 403 for a restricted resource or protected CLI action | 403 for a restricted resource or protected CLI action | Authenticated-but-forbidden remains distinct from invalid credentials | + +The test may use the existing scope-protected delete route to make the 403 +boundary deterministic without changing resource visibility or constructing a +private namespace scenario unrelated to token validation. + +## Error Handling and Security + +- Invalid Bearer credentials return the existing structured HTTP 401 response + through `ApiAuthenticationEntryPoint`. +- Valid credentials that fail scope or resource authorization return the + existing structured HTTP 403 response through the access-denied path. +- Responses must not reveal whether a token is unknown, expired, or revoked. +- Tests, logs, documentation, and commits must not contain real secrets. Test + credentials are generated locally and exist only in the in-memory test + database. +- Token material must never be logged. + +## Documentation + +Two documentation updates are required: + +1. Update `docs/03-authentication-design.md` so the CLI API section uses the + current `/api/cli/v1/...` routes and explicitly states the 401/403 and + anonymous-access boundary. +2. Add `docs/api/authentication.openapi.yaml` using OpenAPI 3.0. The document + must define Bearer authentication, all affected paths, query/path + parameters, success schemas, the common response envelope, HTTP 401 and 403 + responses, examples, and the rule that absent credentials are allowed only + on existing public-read routes. + +No controller signature or response schema changes are planned. Therefore the +generated `web/src/api/generated/schema.d.ts` should remain unchanged; if a +production fix unexpectedly changes a controller contract, `make generate-api` +becomes mandatory and the generated diff must be committed. + +## Verification + +Verification proceeds in this order: + +1. Run the new focused persisted-token matrix and record whether it fails or + passes on unmodified `main` behavior. +2. If it fails, preserve the failure output as reproduction evidence, apply one + minimal shared fix, and rerun the focused matrix. +3. Run auth-module and affected app integration tests. +4. Run `make test-backend-app`. +5. Run `make typecheck-web` and `make lint-web` as repository pre-PR gates. +6. Run `make staging` for containerized regression and smoke coverage. +7. Run `git diff --check` and confirm no generated OpenAPI type drift when no + controller contract changed. +8. Perform structured security and code review before opening the single final + pull request. + +## Delivery Constraints + +- Work only on `fix/auth-revoked-token-validation`. +- Keep PR #511 closed and use it only as historical reference. +- Create exactly one final pull request for GitHub issue #605. +- GitHub-facing text must not contain a Multica issue identifier. +- Do not merge `main`; merging remains the responsibility of an explicitly + authorized human owner. From 6567c19664f988a4fb7b218b9bbd33938134137a Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Tue, 28 Jul 2026 11:20:08 +0800 Subject: [PATCH 53/81] docs(auth): tighten runtime validation gates (#605) Signed-off-by: dongmucat <1127093059@qq.com> --- ...6-07-28-revoked-token-validation-design.md | 161 +++++++++++++++--- 1 file changed, 135 insertions(+), 26 deletions(-) diff --git a/docs/superpowers/specs/2026-07-28-revoked-token-validation-design.md b/docs/superpowers/specs/2026-07-28-revoked-token-validation-design.md index a50ac5ce..43fc316a 100644 --- a/docs/superpowers/specs/2026-07-28-revoked-token-validation-design.md +++ b/docs/superpowers/specs/2026-07-28-revoked-token-validation-design.md @@ -19,10 +19,11 @@ This change covers the following CLI routes: - `GET /api/cli/v1/skills/{namespace}/{slug}/download` - `GET /api/cli/v1/skills/{namespace}/{slug}/versions/{version}/download` -It also covers the authenticated-versus-forbidden boundary on an existing -scope-protected CLI route. It does not add endpoints, change response fields, -change token storage, add a database migration, or change anonymous resource -visibility rules. +It also covers the authenticated-versus-forbidden boundary on the affected +restricted read routes. An existing scope-protected CLI route may provide +supplementary scope-filter evidence only. This change does not add endpoints, +change response fields, change token storage, add a database migration, or +change anonymous resource visibility rules. ## Current-State Finding @@ -41,6 +42,61 @@ matrix. The CLI API table in `docs/03-authentication-design.md` also retains legacy paths, and there is no dedicated OpenAPI 3.0 authentication contract in `docs/api/`. +The reported v0.2.14 runtime behavior still contradicts the source and test +evidence. Source equality alone does not establish which artifact or replica +served the reported requests. The defect therefore remains open until the +release artifact and affected runtime are identified and the same token +lifecycle is replayed against that identified runtime. + +## Release Artifact and Runtime Identity Gate + +Runtime verification is a required investigation track, not an optional +deployment check. Before interpreting a runtime result, record all of the +following for every server replica that may receive the request: + +1. The configured deployment version and resolved image reference from the + runtime environment and `docker compose config --images`. +2. The running container's image ID and registry `RepoDigest` from + `docker inspect` / `docker image inspect`. +3. The OCI `org.opencontainers.image.revision` and + `org.opencontainers.image.version` labels. The publish workflow generates + these labels and also publishes a `sha-` tag, so the revision can + be mapped back to a repository commit. +4. The externally observed application URL, health result, deployment profile, + and request IDs for the authentication probes. + +If the revision label is absent, the image digest must be mapped to the +corresponding publish-images workflow output or registry manifest. A mutable +tag such as `latest` or `v0.2.14` is not sufficient identity evidence by +itself. If neither a revision nor a digest-to-build mapping can be obtained, +the source/runtime contradiction is unresolved and the defect cannot be +closed. + +Using a dedicated test user and non-production token, replay one lifecycle +against the identified running image: + +1. Issue the token and call every matrix endpoint while it is valid. +2. Revoke that same token through the normal product flow and verify its + persisted `revoked_at` value without exposing the raw token. +3. Reuse the same raw token against every matrix endpoint and capture status, + response envelope, request ID, timestamp, and serving replica when + available. +4. Repeat or pin requests per replica when a load balancer can route to mixed + versions, and compare the image digest/revision of each replica. + +If production mutation is not authorized, run the exact identified digest in +an approved isolated environment with equivalent auth/proxy configuration and +record that limitation. This does not by itself close the original field +report: an authorized runtime replay or owner-provided equivalent evidence is +still required. + +The contradiction is closed only when source commit, published image digest, +running instance identity, and replay result form one consistent chain. A +mismatched digest indicates deployment drift; identical application images +with divergent behavior require investigation of proxy header forwarding, +mixed replicas, session/cookie contamination, and request routing before any +source-code conclusion is accepted. + ## Architecture `ApiTokenAuthenticationFilter` remains the single Bearer-authentication entry @@ -53,6 +109,14 @@ responses deterministic; authentication and token lifecycle components remain real. This isolates the contract boundary under test: a rejected credential must stop in the security chain before controller business logic executes. +The restricted-read authorization test is separate and must not mock the +permission decision. It will persist a PRIVATE or NAMESPACE_ONLY skill owned by +another user, authenticate a valid outsider token with no qualifying namespace +role, and exercise the real `CliSkillAppService` plus domain query/download +authorization path. At least `resolve`, latest download, and versioned download +must return HTTP 403. A DELETE request with a missing token scope may supplement +this check, but cannot replace any affected read-path assertion. + Production authentication code will be changed only when a new regression test fails for the expected behavioral reason. Any fix must be the smallest change at the shared authentication or token-validation source of the failure. @@ -79,19 +143,31 @@ chain without creating a token row. ## Behavioral Matrix -| Credential state | `whoami` | Public `search` | Public `resolve` | Public `download` | Meaning | -|---|---:|---:|---:|---:|---| -| No `Authorization` header | 401 | Existing anonymous result | Existing anonymous result | Existing anonymous result | Anonymous access is preserved only where already public | -| Valid active token | 200 | Authenticated result | Authenticated result | Authenticated result | Principal and roles/scopes are projected | -| Revoked token | 401 | 401 | 401 | 401 | Credential cannot degrade to anonymous | -| Expired token | 401 | 401 | 401 | 401 | Credential cannot degrade to anonymous | -| Unknown token | 401 | 401 | 401 | 401 | Credential cannot degrade to anonymous | -| Malformed or empty Bearer | 401 | 401 | 401 | 401 | Authentication attempt is rejected before validation/business logic | -| Valid token lacking required authorization | N/A | N/A | 403 for a restricted resource or protected CLI action | 403 for a restricted resource or protected CLI action | Authenticated-but-forbidden remains distinct from invalid credentials | +The authentication rows use deterministic public fixtures. Latest and +versioned downloads are independent endpoints and must have independent test +arguments and assertions for every credential state. -The test may use the existing scope-protected delete route to make the 403 -boundary deterministic without changing resource visibility or constructing a -private namespace scenario unrelated to token validation. +| Credential state | `whoami` | Public `search` | Public `resolve` | Public latest download | Public versioned download | Meaning | +|---|---:|---:|---:|---:|---:|---| +| No `Authorization` header | 401 | 200 | 200 | Existing 200/302 success | Existing 200/302 success | Anonymous access is preserved only where already public | +| Valid active token | 200 | 200 | 200 | Existing 200/302 success | Existing 200/302 success | Principal and roles/scopes are projected | +| Revoked token | 401 | 401 | 401 | 401 | 401 | Credential cannot degrade to anonymous | +| Expired token | 401 | 401 | 401 | 401 | 401 | Credential cannot degrade to anonymous | +| Unknown token | 401 | 401 | 401 | 401 | 401 | Credential cannot degrade to anonymous | +| Empty Bearer credential | 401 | 401 | 401 | 401 | 401 | Empty authentication attempt is rejected before business logic | +| Malformed Bearer credential | 401 | 401 | 401 | 401 | 401 | Malformed authentication attempt is rejected before business logic | + +The authorization row uses a persisted PRIVATE or NAMESPACE_ONLY fixture and +the real read-authorization path: + +| Valid credential, insufficient resource permission | `whoami` | `search` | Restricted `resolve` | Restricted latest download | Restricted versioned download | +|---|---:|---:|---:|---:|---:| +| Outsider token with no qualifying namespace role | 200 | 200 with restricted skill omitted | 403 | 403 | 403 | + +The same fixture must also prove that an authorized owner or qualifying +namespace member can reach the restricted read path, so a 403 cannot be caused +by an invalid fixture. Missing-scope DELETE coverage is optional supplementary +evidence for the API-token scope filter only. ## Error Handling and Security @@ -123,22 +199,52 @@ generated `web/src/api/generated/schema.d.ts` should remain unchanged; if a production fix unexpectedly changes a controller contract, `make generate-api` becomes mandatory and the generated diff must be committed. +## Implementation Plan Requirements + +The detailed implementation plan must preserve the following independent +steps rather than collapsing them into one generic download case: + +1. Create the real persisted token/user fixture and public endpoint stubs used + by the authentication matrix. +2. Exercise `whoami`, `search`, and `resolve` for every credential state. +3. Exercise latest download for every credential state. +4. Exercise versioned download for every credential state. +5. Persist a restricted skill plus authorized and unauthorized users, then use + the real read-authorization path to prove 403 for restricted `resolve`, + latest download, and versioned download and success for an authorized user. +6. Update the authentication design and OpenAPI contract. +7. Identify the published/running image and replay the valid-to-revoked token + lifecycle against that exact digest, or record the external access blocker + without treating the field contradiction as resolved. + +Each endpoint/state step must state its own expected status and test command. +The plan may share fixture helpers, but it must not share one assertion in a +way that can skip either download route. + ## Verification Verification proceeds in this order: 1. Run the new focused persisted-token matrix and record whether it fails or - passes on unmodified `main` behavior. -2. If it fails, preserve the failure output as reproduction evidence, apply one - minimal shared fix, and rerun the focused matrix. -3. Run auth-module and affected app integration tests. -4. Run `make test-backend-app`. -5. Run `make typecheck-web` and `make lint-web` as repository pre-PR gates. -6. Run `make staging` for containerized regression and smoke coverage. -7. Run `git diff --check` and confirm no generated OpenAPI type drift when no + passes on unmodified `main` behavior, with separate results for latest and + versioned download. +2. Run the persisted restricted-resource checks through real query/download + authorization and record outsider 403 plus authorized-user success. +3. If an authentication row fails, preserve the failure output as reproduction + evidence, apply one minimal shared fix, and rerun the focused matrix. +4. Run auth-module and affected app integration tests. +5. Run `make test-backend-app`. +6. Run `make typecheck-web` and `make lint-web` as repository pre-PR gates. +7. Run `make staging` for containerized regression and smoke coverage. +8. Run `git diff --check` and confirm no generated OpenAPI type drift when no controller contract changed. -8. Perform structured security and code review before opening the single final - pull request. +9. Record the release tag, build revision, image reference, immutable digest, + and every serving replica's running image identity. +10. Replay the same valid-to-revoked token lifecycle against the identified + runtime and record endpoint-level status, request ID, and replica evidence, + keeping latest and versioned download results separate. +11. Perform structured security and code review before opening the single final + pull request. ## Delivery Constraints @@ -146,5 +252,8 @@ Verification proceeds in this order: - Keep PR #511 closed and use it only as historical reference. - Create exactly one final pull request for GitHub issue #605. - GitHub-facing text must not contain a Multica issue identifier. +- Do not mark the defect resolved or eligible for closure while the reported + runtime behavior and the identified artifact/runtime replay remain + contradictory or incomplete. - Do not merge `main`; merging remains the responsibility of an explicitly authorized human owner. From e5b843967804d1698f2635cc1d0aea86f5afbc1b Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Tue, 28 Jul 2026 11:37:07 +0800 Subject: [PATCH 54/81] docs(auth): plan revoked token regression coverage (#605) Signed-off-by: dongmucat <1127093059@qq.com> --- .../2026-07-28-revoked-token-validation.md | 1081 +++++++++++++++++ 1 file changed, 1081 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-28-revoked-token-validation.md diff --git a/docs/superpowers/plans/2026-07-28-revoked-token-validation.md b/docs/superpowers/plans/2026-07-28-revoked-token-validation.md new file mode 100644 index 00000000..d17b2d88 --- /dev/null +++ b/docs/superpowers/plans/2026-07-28-revoked-token-validation.md @@ -0,0 +1,1081 @@ +# Revoked API Token Validation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Lock the CLI API's fail-closed Bearer behavior with persisted token lifecycle tests, prove 401/403 semantics on every affected read endpoint, publish the authentication OpenAPI contract, and reconcile source behavior with the actual runtime artifact. + +**Architecture:** Keep `ApiTokenAuthenticationFilter` as the sole Bearer authentication entry point. Use one Spring Boot/MockMvc class with real token and user persistence plus deterministic controller-service stubs for the credential-state matrix, and a second Spring Boot/MockMvc class with real query/download authorization and a persisted PRIVATE skill for resource-level 403 checks. Production authentication code remains unchanged unless the unmodified-source matrix reproduces a failure; any such failure stops this plan for systematic root-cause analysis before a minimal fix is planned. + +**Tech Stack:** Java 21, Spring Boot 3.2, Spring Security, Spring Data JPA/H2, MockMvc, JUnit 5 parameterized tests, Mockito, OpenAPI 3.0 YAML, Docker/OCI image inspection. + +--- + +## File Map + +- Create `server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliTokenLifecycleSecurityIntegrationTest.java`: persisted valid/revoked/expired/unknown/empty/malformed credential matrix for each CLI endpoint. +- Create `server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliRestrictedReadAuthorizationIntegrationTest.java`: real PRIVATE-skill read authorization through resolve, latest download, and versioned download. +- Modify `docs/03-authentication-design.md`: current CLI route table and explicit anonymous/401/403 rules. +- Create `docs/api/authentication.openapi.yaml`: OpenAPI 3.0 contract for whoami, search, resolve, latest download, and versioned download. +- Do not modify `server/skillhub-auth/src/main/**` unless Task 5 records a failing unmodified-source assertion and a separate systematic-debugging plan amendment identifies the root cause. + +### Task 1: Persisted credential fixture and whoami/search/resolve matrix + +**Files:** +- Create: `server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliTokenLifecycleSecurityIntegrationTest.java` + +- [ ] **Step 1: Create the integration test fixture and endpoint tests** + +Create the class with real `ApiTokenService`, `ApiTokenRepository`, and `UserAccountRepository`; mock only `CliSkillAppService` so successful public reads are deterministic. Add independent anonymous, valid, and parameterized invalid-state methods for whoami, search, and resolve: + +```java +package com.iflytek.skillhub.controller.cli; + +import com.iflytek.skillhub.auth.entity.ApiToken; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.auth.repository.ApiTokenRepository; +import com.iflytek.skillhub.auth.token.ApiTokenService; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.dto.cli.CliResolveResponse; +import com.iflytek.skillhub.service.cli.CliSkillAppService; +import java.io.ByteArrayInputStream; +import java.time.Clock; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.core.io.InputStreamResource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.clearInvocations; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.BDDMockito.given; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class CliTokenLifecycleSecurityIntegrationTest { + + private enum InvalidCredentialState { + REVOKED, + EXPIRED, + UNKNOWN, + EMPTY, + MALFORMED + } + + @Autowired MockMvc mockMvc; + @Autowired ApiTokenService apiTokenService; + @Autowired ApiTokenRepository apiTokenRepository; + @Autowired UserAccountRepository userAccountRepository; + @Autowired Clock clock; + @MockBean CliSkillAppService cliSkillAppService; + + private String userId; + + @BeforeEach + void setUp() { + userId = "token-matrix-" + UUID.randomUUID(); + userAccountRepository.save(new UserAccount( + userId, "Token Matrix", userId + "@example.com", "")); + given(cliSkillAppService.search(any(), anyInt(), any(), any())) + .willReturn(new CliSkillAppService.CliSearchResult(List.of(), 0, 20)); + given(cliSkillAppService.resolve(anyString(), anyString(), any(), any(), any())) + .willReturn(new CliResolveResponse( + "global", "demo", "1.0.0", 1L, "sha256:empty", + "/api/v1/skills/global/demo/versions/1.0.0/download")); + given(cliSkillAppService.downloadLatest(anyString(), anyString(), any())) + .willAnswer(ignored -> downloadResponse()); + given(cliSkillAppService.downloadVersion(anyString(), anyString(), anyString(), any())) + .willAnswer(ignored -> downloadResponse()); + } + + @Test + void whoamiWithoutAuthorizationReturns401() throws Exception { + mockMvc.perform(get("/api/cli/v1/auth/whoami")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(401)); + } + + @Test + void whoamiWithValidPersistedTokenReturns200() throws Exception { + String token = createActiveToken(); + mockMvc.perform(withBearer(get("/api/cli/v1/auth/whoami"), token)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.handle").value(userId)); + } + + @ParameterizedTest(name = "whoami rejects {0}") + @EnumSource(InvalidCredentialState.class) + void whoamiRejectsInvalidBearer(InvalidCredentialState state) throws Exception { + clearInvocations(cliSkillAppService); + mockMvc.perform(withInvalidBearer(get("/api/cli/v1/auth/whoami"), state)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(401)); + verifyNoInteractions(cliSkillAppService); + } + + @Test + void searchWithoutAuthorizationReturns200() throws Exception { + mockMvc.perform(get("/api/cli/v1/skills/search").param("q", "demo").param("limit", "20")) + .andExpect(status().isOk()); + } + + @Test + void searchWithValidPersistedTokenReturns200() throws Exception { + String token = createActiveToken(); + mockMvc.perform(withBearer( + get("/api/cli/v1/skills/search").param("q", "demo").param("limit", "20"), token)) + .andExpect(status().isOk()); + } + + @ParameterizedTest(name = "search rejects {0}") + @EnumSource(InvalidCredentialState.class) + void searchRejectsInvalidBearer(InvalidCredentialState state) throws Exception { + clearInvocations(cliSkillAppService); + mockMvc.perform(withInvalidBearer( + get("/api/cli/v1/skills/search").param("q", "demo").param("limit", "20"), state)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(401)); + verifyNoInteractions(cliSkillAppService); + } + + @Test + void resolveWithoutAuthorizationReturns200() throws Exception { + mockMvc.perform(get("/api/cli/v1/skills/global/demo/resolve")) + .andExpect(status().isOk()); + } + + @Test + void resolveWithValidPersistedTokenReturns200() throws Exception { + String token = createActiveToken(); + mockMvc.perform(withBearer(get("/api/cli/v1/skills/global/demo/resolve"), token)) + .andExpect(status().isOk()); + } + + @ParameterizedTest(name = "resolve rejects {0}") + @EnumSource(InvalidCredentialState.class) + void resolveRejectsInvalidBearer(InvalidCredentialState state) throws Exception { + clearInvocations(cliSkillAppService); + mockMvc.perform(withInvalidBearer(get("/api/cli/v1/skills/global/demo/resolve"), state)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(401)); + verifyNoInteractions(cliSkillAppService); + } + + private MockHttpServletRequestBuilder withInvalidBearer( + MockHttpServletRequestBuilder request, + InvalidCredentialState state) { + return request + .header(HttpHeaders.AUTHORIZATION, authorizationHeader(state)) + .with(authentication(sessionAuthentication())); + } + + private MockHttpServletRequestBuilder withBearer( + MockHttpServletRequestBuilder request, + String rawToken) { + return request.header(HttpHeaders.AUTHORIZATION, "Bearer " + rawToken); + } + + private String authorizationHeader(InvalidCredentialState state) { + return switch (state) { + case REVOKED -> { + ApiTokenService.TokenCreateResult result = createToken(); + apiTokenService.revokeToken(result.entity().getId(), userId); + yield "Bearer " + result.rawToken(); + } + case EXPIRED -> { + ApiTokenService.TokenCreateResult result = createToken(); + ApiToken token = result.entity(); + token.setExpiresAt(Instant.now(clock).minusSeconds(1)); + apiTokenRepository.saveAndFlush(token); + yield "Bearer " + result.rawToken(); + } + case UNKNOWN -> "Bearer sk_unknown_" + UUID.randomUUID(); + case EMPTY -> "Bearer "; + case MALFORMED -> "Bearer"; + }; + } + + private String createActiveToken() { + return createToken().rawToken(); + } + + private ApiTokenService.TokenCreateResult createToken() { + return apiTokenService.createToken( + userId, "matrix-" + UUID.randomUUID(), "[\"skill:read\"]"); + } + + private UsernamePasswordAuthenticationToken sessionAuthentication() { + PlatformPrincipal principal = new PlatformPrincipal( + userId, "Session User", userId + "@example.com", "", "session", Set.of("USER")); + return new UsernamePasswordAuthenticationToken(principal, null, List.of()); + } + + private ResponseEntity downloadResponse() { + return ResponseEntity.ok(new InputStreamResource( + new ByteArrayInputStream("zip".getBytes(java.nio.charset.StandardCharsets.UTF_8)))); + } +} +``` + +- [ ] **Step 2: Apply a reversible fail-open mutation before the first test run** + +Temporarily change both rejection branches in `ApiTokenAuthenticationFilter.doFilterInternal` so malformed and invalid credentials continue down the chain. Do not stage or commit this mutation: + +```java +if (rawToken == null) { + filterChain.doFilter(request, response); + return; +} + +var token = apiTokenService.validateToken(rawToken); +if (token.isEmpty()) { + filterChain.doFilter(request, response); + return; +} +``` + +- [ ] **Step 3: Run whoami RED verification** + +Run: + +```bash +cd server && ./mvnw -pl skillhub-app -am \ + -Dtest=CliTokenLifecycleSecurityIntegrationTest#whoamiRejectsInvalidBearer \ + -Dsurefire.failIfNoSpecifiedTests=false test +``` + +Expected: FAIL for the invalid-state invocations because the pre-authenticated session reaches whoami and returns 200 instead of 401. + +- [ ] **Step 4: Run search RED verification** + +Run the same Maven command with `#searchRejectsInvalidBearer`. + +Expected: FAIL with expected 401 but actual 200 for revoked, expired, unknown, empty, and malformed Bearer credentials. + +- [ ] **Step 5: Run resolve RED verification** + +Run the same Maven command with `#resolveRejectsInvalidBearer`. + +Expected: FAIL with expected 401 but actual 200 for all invalid credential states. + +- [ ] **Step 6: Restore the two original reject branches** + +Restore exactly: + +```java +if (rawToken == null) { + rejectBearer(request, response); + return; +} + +var token = apiTokenService.validateToken(rawToken); +if (token.isEmpty()) { + rejectBearer(request, response); + return; +} +``` + +Confirm `git diff -- server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilter.java` is empty. + +- [ ] **Step 7: Run whoami/search/resolve GREEN commands independently** + +Run three Maven commands, one for each of: + +```text +CliTokenLifecycleSecurityIntegrationTest#whoamiRejectsInvalidBearer +CliTokenLifecycleSecurityIntegrationTest#searchRejectsInvalidBearer +CliTokenLifecycleSecurityIntegrationTest#resolveRejectsInvalidBearer +``` + +Expected: each command reports all parameterized invocations PASS, with no production authentication diff. + +### Task 2: Latest download matrix + +**Files:** +- Modify: `server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliTokenLifecycleSecurityIntegrationTest.java` + +- [ ] **Step 1: Add independent latest-download methods** + +Insert before the helper methods: + +```java +@Test +void latestDownloadWithoutAuthorizationReturns200() throws Exception { + mockMvc.perform(get("/api/cli/v1/skills/global/demo/download")) + .andExpect(status().isOk()); +} + +@Test +void latestDownloadWithValidPersistedTokenReturns200() throws Exception { + String token = createActiveToken(); + mockMvc.perform(withBearer(get("/api/cli/v1/skills/global/demo/download"), token)) + .andExpect(status().isOk()); +} + +@ParameterizedTest(name = "latest download rejects {0}") +@EnumSource(InvalidCredentialState.class) +void latestDownloadRejectsInvalidBearer(InvalidCredentialState state) throws Exception { + clearInvocations(cliSkillAppService); + mockMvc.perform(withInvalidBearer(get("/api/cli/v1/skills/global/demo/download"), state)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(401)); + verifyNoInteractions(cliSkillAppService); +} +``` + +- [ ] **Step 2: Reapply the reversible fail-open mutation and run latest-download RED** + +Run: + +```bash +cd server && ./mvnw -pl skillhub-app -am \ + -Dtest=CliTokenLifecycleSecurityIntegrationTest#latestDownloadRejectsInvalidBearer \ + -Dsurefire.failIfNoSpecifiedTests=false test +``` + +Expected: FAIL with expected 401 but actual 200 for every invalid state. + +- [ ] **Step 3: Restore the original reject branches and run latest-download GREEN** + +Run the same command after restoring the filter. + +Expected: all five invalid-state invocations PASS. Then run independent anonymous and valid methods with `#latestDownloadWithoutAuthorizationReturns200` and `#latestDownloadWithValidPersistedTokenReturns200`; both PASS. + +### Task 3: Versioned download matrix + +**Files:** +- Modify: `server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliTokenLifecycleSecurityIntegrationTest.java` + +- [ ] **Step 1: Add independent versioned-download methods** + +Insert before the helper methods: + +```java +@Test +void versionedDownloadWithoutAuthorizationReturns200() throws Exception { + mockMvc.perform(get("/api/cli/v1/skills/global/demo/versions/1.0.0/download")) + .andExpect(status().isOk()); +} + +@Test +void versionedDownloadWithValidPersistedTokenReturns200() throws Exception { + String token = createActiveToken(); + mockMvc.perform(withBearer( + get("/api/cli/v1/skills/global/demo/versions/1.0.0/download"), token)) + .andExpect(status().isOk()); +} + +@ParameterizedTest(name = "versioned download rejects {0}") +@EnumSource(InvalidCredentialState.class) +void versionedDownloadRejectsInvalidBearer(InvalidCredentialState state) throws Exception { + clearInvocations(cliSkillAppService); + mockMvc.perform(withInvalidBearer( + get("/api/cli/v1/skills/global/demo/versions/1.0.0/download"), state)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(401)); + verifyNoInteractions(cliSkillAppService); +} +``` + +- [ ] **Step 2: Reapply the reversible fail-open mutation and run versioned-download RED** + +Run the focused method command for `#versionedDownloadRejectsInvalidBearer`. + +Expected: FAIL with expected 401 but actual 200 for every invalid state. + +- [ ] **Step 3: Restore the filter and run versioned-download GREEN independently** + +Run focused commands for the invalid, anonymous, and valid versioned-download methods. + +Expected: all commands PASS and the filter source has no diff. + +- [ ] **Step 4: Run the complete persisted credential matrix** + +Run: + +```bash +cd server && ./mvnw -pl skillhub-app -am \ + -Dtest=CliTokenLifecycleSecurityIntegrationTest \ + -Dsurefire.failIfNoSpecifiedTests=false test +``` + +Expected: PASS for all five endpoints and all absent, valid, revoked, expired, unknown, empty, and malformed credential cases. + +- [ ] **Step 5: Commit the credential matrix** + +```bash +git add server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliTokenLifecycleSecurityIntegrationTest.java +git commit -s -m "test(auth): cover persisted CLI token states (#605)" +``` + +### Task 4: Real restricted-read 403 boundary + +**Files:** +- Create: `server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliRestrictedReadAuthorizationIntegrationTest.java` + +- [ ] **Step 1: Create a persisted PRIVATE skill fixture and real authorization tests** + +```java +package com.iflytek.skillhub.controller.cli; + +import com.iflytek.skillhub.auth.token.ApiTokenService; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.SkillVersion; +import com.iflytek.skillhub.domain.skill.SkillVersionRepository; +import com.iflytek.skillhub.domain.skill.SkillVersionStatus; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import java.time.Instant; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpHeaders; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class CliRestrictedReadAuthorizationIntegrationTest { + + @Autowired MockMvc mockMvc; + @Autowired ApiTokenService apiTokenService; + @Autowired UserAccountRepository userAccountRepository; + @Autowired NamespaceRepository namespaceRepository; + @Autowired SkillRepository skillRepository; + @Autowired SkillVersionRepository skillVersionRepository; + + private String namespaceSlug; + private String skillSlug; + private String version; + private String ownerToken; + private String outsiderToken; + + @BeforeEach + void setUp() { + String suffix = UUID.randomUUID().toString().replace("-", ""); + String ownerId = "private-owner-" + suffix; + String outsiderId = "private-outsider-" + suffix; + namespaceSlug = "private-ns-" + suffix; + skillSlug = "private-skill-" + suffix; + version = "1.0.0"; + + userAccountRepository.save(new UserAccount(ownerId, "Owner", ownerId + "@example.com", "")); + userAccountRepository.save(new UserAccount( + outsiderId, "Outsider", outsiderId + "@example.com", "")); + ownerToken = apiTokenService.createToken( + ownerId, "owner-token-" + suffix, "[\"skill:read\"]").rawToken(); + outsiderToken = apiTokenService.createToken( + outsiderId, "outsider-token-" + suffix, "[\"skill:read\"]").rawToken(); + + Namespace namespace = namespaceRepository.save(new Namespace(namespaceSlug, "Private NS", ownerId)); + Skill skill = skillRepository.save(new Skill( + namespace.getId(), skillSlug, ownerId, SkillVisibility.PRIVATE)); + SkillVersion published = new SkillVersion(skill.getId(), version, ownerId); + published.setStatus(SkillVersionStatus.PUBLISHED); + published.setPublishedAt(Instant.parse("2026-07-28T00:00:00Z")); + published.setDownloadReady(true); + published = skillVersionRepository.save(published); + skill.setLatestVersionId(published.getId()); + skillRepository.save(skill); + skillRepository.flush(); + skillVersionRepository.flush(); + } + + @Test + void outsiderCannotResolvePrivateSkill() throws Exception { + mockMvc.perform(withBearer( + get("/api/cli/v1/skills/{namespace}/{slug}/resolve", namespaceSlug, skillSlug), + outsiderToken)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(403)); + } + + @Test + void outsiderCannotDownloadLatestPrivateSkill() throws Exception { + mockMvc.perform(withBearer( + get("/api/cli/v1/skills/{namespace}/{slug}/download", namespaceSlug, skillSlug), + outsiderToken)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(403)); + } + + @Test + void outsiderCannotDownloadVersionedPrivateSkill() throws Exception { + mockMvc.perform(withBearer( + get("/api/cli/v1/skills/{namespace}/{slug}/versions/{version}/download", + namespaceSlug, skillSlug, version), + outsiderToken)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(403)); + } + + @Test + void ownerCanResolvePrivateSkill() throws Exception { + mockMvc.perform(withBearer( + get("/api/cli/v1/skills/{namespace}/{slug}/resolve", namespaceSlug, skillSlug), + ownerToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.slug").value(skillSlug)); + } + + private MockHttpServletRequestBuilder withBearer( + MockHttpServletRequestBuilder request, + String rawToken) { + return request.header(HttpHeaders.AUTHORIZATION, "Bearer " + rawToken); + } +} +``` + +- [ ] **Step 2: Apply a reversible authorization mutation before the first run** + +Temporarily change only the PRIVATE arm in `VisibilityChecker.canAccess`: + +```java +case PRIVATE -> true; +``` + +Do not stage or commit this mutation. + +- [ ] **Step 3: Run three independent restricted-read RED commands** + +Run the focused Maven command separately for: + +```text +CliRestrictedReadAuthorizationIntegrationTest#outsiderCannotResolvePrivateSkill +CliRestrictedReadAuthorizationIntegrationTest#outsiderCannotDownloadLatestPrivateSkill +CliRestrictedReadAuthorizationIntegrationTest#outsiderCannotDownloadVersionedPrivateSkill +``` + +Expected: each command FAILS because the outsider no longer receives 403. Resolve reaches 200; downloads proceed past authorization and return a non-403 response. + +- [ ] **Step 4: Restore PRIVATE authorization and run GREEN commands** + +Restore: + +```java +case PRIVATE -> isOwner(skill, currentUserId) || isAdminOrAbove(roles.get(skill.getNamespaceId())); +``` + +Confirm `git diff -- server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/VisibilityChecker.java` is empty. Run all four test methods independently. + +Expected: outsider resolve/latest/versioned each PASS with 403; owner resolve PASS with 200. + +- [ ] **Step 5: Run the existing search-visibility boundary tests** + +Run the search authorization checks independently as a supplementary 200-with-omission boundary: + +```bash +cd server +./mvnw -pl skillhub-app -am \ + -Dtest='PostgresFullTextQueryServiceTest#anonymousSearchSqlShouldOnlyReadPublicActiveVisibleNonArchivedSkills' \ + -Dsurefire.failIfNoSpecifiedTests=false test +./mvnw -pl skillhub-app -am \ + -Dtest='SkillSearchAppServiceTest#search_shouldIncludeMemberNamespacesInVisibilityScope' \ + -Dsurefire.failIfNoSpecifiedTests=false test +``` + +Expected: both commands PASS. Record search as a successful response whose result set omits inaccessible PRIVATE skills; it is not a substitute for the real resolve/download 403 assertions above. + +- [ ] **Step 6: Commit the restricted-read tests** + +```bash +git add server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliRestrictedReadAuthorizationIntegrationTest.java +git commit -s -m "test(auth): cover restricted CLI read authorization (#605)" +``` + +### Task 5: Production-code decision gate + +**Files:** +- Inspect only: `server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilter.java` +- Inspect only: `server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenService.java` + +- [ ] **Step 1: Confirm unmodified-source results and production diff** + +Run both new classes without any mutation, then run: + +```bash +git diff --exit-code origin/main -- \ + server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilter.java \ + server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenService.java +``` + +Expected: both classes PASS and the production authentication diff is empty. Record the outcome as “current source matrix passes; no production authentication change justified.” + +- [ ] **Step 2: Stop for systematic debugging if the expected result is false** + +If any unmodified-source assertion fails, stop execution before editing production code. Preserve the failing command and output, invoke `superpowers:systematic-debugging`, trace the request through token persistence, security chains, filters, and endpoint service boundaries, then amend this plan with the confirmed minimal change. Do not continue to documentation with a speculative fix. + +### Task 6: Authentication and OpenAPI documentation + +**Files:** +- Modify: `docs/03-authentication-design.md` +- Create: `docs/api/authentication.openapi.yaml` + +- [ ] **Step 1: Replace the CLI API table with current paths and semantics** + +Use this content in section 10.3: + +```markdown +### 10.3 CLI API + +| 接口 | 凭证规则 | 授权与错误语义 | +|------|---------|---------------| +| `GET /api/cli/v1/auth/whoami` | 必须提供有效 Bearer Token | 缺失、未知、过期、撤销或 malformed token 返回 401 | +| `GET /api/cli/v1/skills/search` | 可匿名;提供 Bearer 时必须有效 | 匿名仅返回公开可安装 skill;坏凭证返回 401,不得降级匿名 | +| `GET /api/cli/v1/skills/{namespace}/{slug}/resolve` | 可匿名读取公开资源;提供 Bearer 时必须有效 | 坏凭证返回 401;有效身份无资源权限返回 403 | +| `GET /api/cli/v1/skills/{namespace}/{slug}/download` | 可匿名下载公开资源;提供 Bearer 时必须有效 | 坏凭证返回 401;有效身份无资源权限返回 403 | +| `GET /api/cli/v1/skills/{namespace}/{slug}/versions/{version}/download` | 可匿名下载公开资源;提供 Bearer 时必须有效 | 坏凭证返回 401;有效身份无资源权限返回 403 | + +公共读接口仅在完全缺少 `Authorization` 头时允许匿名访问。请求一旦携带 Bearer 凭证,空值、格式错误、未知、过期、已撤销、用户缺失或用户禁用均由共享认证过滤器返回 401。身份已验证但 token scope 或资源可见性不足时返回 403;服务端不向客户端区分 token 不存在、过期或已撤销。 +``` + +- [ ] **Step 2: Create the complete OpenAPI 3.0 document** + +Create `docs/api/authentication.openapi.yaml` with `openapi: 3.0.3`, a `bearerAuth` HTTP bearer security scheme, all five paths, and these exact contract rules: + +```yaml +openapi: 3.0.3 +info: + title: SkillHub CLI Authentication API + version: 1.0.0 + description: >- + Authentication contract for CLI identity and public skill reads. Public read + operations permit a request with no Authorization header, but any supplied + Bearer credential must be valid; malformed, unknown, expired, or revoked + credentials return HTTP 401 and never fall back to anonymous access. +servers: + - url: / +tags: + - name: CLI Authentication + - name: CLI Skills +paths: + /api/cli/v1/auth/whoami: + get: + tags: [CLI Authentication] + summary: Return the current CLI identity + operationId: cliWhoAmI + security: + - bearerAuth: [] + responses: + '200': + description: Authenticated CLI identity + content: + application/json: + schema: + $ref: '#/components/schemas/CliWhoAmIEnvelope' + '401': + $ref: '#/components/responses/Unauthorized' + /api/cli/v1/skills/search: + get: + tags: [CLI Skills] + summary: Search CLI-installable skills + operationId: cliSearchSkills + description: No Authorization header uses anonymous public visibility. A supplied invalid Bearer credential returns 401. + security: + - {} + - bearerAuth: [] + parameters: + - name: q + in: query + required: false + schema: {type: string} + example: pdf + description: Optional search text. + - name: limit + in: query + required: false + schema: {type: integer, format: int32, default: 20} + example: 20 + description: Maximum number of results. + responses: + '200': + description: Search result + content: + application/json: + schema: + $ref: '#/components/schemas/CliSearchEnvelope' + '401': + $ref: '#/components/responses/Unauthorized' + /api/cli/v1/skills/{namespace}/{slug}/resolve: + get: + tags: [CLI Skills] + summary: Resolve a skill version + operationId: cliResolveSkill + security: + - {} + - bearerAuth: [] + parameters: + - $ref: '#/components/parameters/Namespace' + - $ref: '#/components/parameters/Slug' + - name: version + in: query + required: false + schema: {type: string} + example: 1.0.0 + description: Optional exact version; omitted resolves latest. + responses: + '200': + description: Resolved version + content: + application/json: + schema: + $ref: '#/components/schemas/CliResolveEnvelope' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + /api/cli/v1/skills/{namespace}/{slug}/download: + get: + tags: [CLI Skills] + summary: Download the latest installable skill version + operationId: cliDownloadLatestSkill + security: + - {} + - bearerAuth: [] + parameters: + - $ref: '#/components/parameters/Namespace' + - $ref: '#/components/parameters/Slug' + responses: + '200': + $ref: '#/components/responses/Download' + '302': + $ref: '#/components/responses/DownloadRedirect' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '503': + $ref: '#/components/responses/StorageUnavailable' + /api/cli/v1/skills/{namespace}/{slug}/versions/{version}/download: + get: + tags: [CLI Skills] + summary: Download an exact installable skill version + operationId: cliDownloadSkillVersion + security: + - {} + - bearerAuth: [] + parameters: + - $ref: '#/components/parameters/Namespace' + - $ref: '#/components/parameters/Slug' + - $ref: '#/components/parameters/Version' + responses: + '200': + $ref: '#/components/responses/Download' + '302': + $ref: '#/components/responses/DownloadRedirect' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '503': + $ref: '#/components/responses/StorageUnavailable' +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: SkillHub API token + description: API token issued by SkillHub. Invalid lifecycle states all return the same 401 response. + parameters: + Namespace: + name: namespace + in: path + required: true + schema: {type: string} + example: global + description: Namespace slug. + Slug: + name: slug + in: path + required: true + schema: {type: string} + example: pdf-parser + description: Skill slug. + Version: + name: version + in: path + required: true + schema: {type: string} + example: 1.0.0 + description: Exact semantic version. + responses: + Download: + description: ZIP package stream + headers: + Content-Disposition: + schema: {type: string} + description: Attachment filename. + content: + application/zip: + schema: {type: string, format: binary} + DownloadRedirect: + description: Redirect to a presigned object-storage URL + headers: + Location: + schema: {type: string, format: uri} + BadRequest: + description: Namespace, skill, or version cannot be resolved. + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorEnvelope'} + Unauthorized: + description: Bearer credential is missing where required, malformed, unknown, expired, revoked, or belongs to an unavailable user. + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorEnvelope'} + example: + code: 401 + msg: Authentication required + data: null + timestamp: '2026-07-28T00:00:00Z' + requestId: req-123 + Forbidden: + description: Credential is valid but token scope or resource permission is insufficient. + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorEnvelope'} + example: + code: 403 + msg: Forbidden + data: null + timestamp: '2026-07-28T00:00:00Z' + requestId: req-123 + StorageUnavailable: + description: Object storage is unavailable. + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorEnvelope'} + schemas: + Envelope: + type: object + required: [code, msg, timestamp] + properties: + code: {type: integer, format: int32} + msg: {type: string} + data: {type: object, nullable: true} + timestamp: {type: string, format: date-time} + requestId: {type: string, nullable: true} + ErrorEnvelope: + allOf: + - $ref: '#/components/schemas/Envelope' + - type: object + properties: + data: {type: object, nullable: true, example: null} + CliWhoAmIEnvelope: + allOf: + - $ref: '#/components/schemas/Envelope' + - type: object + properties: + data: + $ref: '#/components/schemas/CliWhoAmI' + CliWhoAmI: + type: object + required: [handle, displayName, email] + properties: + handle: {type: string, example: user-123} + displayName: {type: string, example: CLI User} + email: {type: string, format: email, example: cli@example.com} + CliSearchEnvelope: + allOf: + - $ref: '#/components/schemas/Envelope' + - type: object + properties: + data: + $ref: '#/components/schemas/CliSearchResult' + CliSearchResult: + type: object + required: [items, total, limit] + properties: + items: + type: array + items: {$ref: '#/components/schemas/CliSearchItem'} + total: {type: integer, format: int64, example: 1} + limit: {type: integer, format: int32, example: 20} + CliSearchItem: + type: object + required: [namespace, slug, latestVersion] + properties: + namespace: {type: string, example: global} + slug: {type: string, example: pdf-parser} + latestVersion: {type: string, example: 1.2.0} + summary: {type: string, nullable: true, example: Parse PDF files} + CliResolveEnvelope: + allOf: + - $ref: '#/components/schemas/Envelope' + - type: object + properties: + data: + $ref: '#/components/schemas/CliResolveResult' + CliResolveResult: + type: object + required: [namespace, slug, version, versionId, fingerprint, downloadUrl] + properties: + namespace: {type: string, example: global} + slug: {type: string, example: pdf-parser} + version: {type: string, example: 1.2.0} + versionId: {type: integer, format: int64, example: 42} + fingerprint: {type: string, example: 'sha256:abc123'} + downloadUrl: {type: string, example: /api/v1/skills/global/pdf-parser/versions/1.2.0/download} +``` + +- [ ] **Step 3: Validate documentation formatting and contract paths** + +Run: + +```bash +ruby -e 'require "yaml"; YAML.load_file("docs/api/authentication.openapi.yaml"); puts "OpenAPI YAML OK"' +rg -n '/api/cli/v1/(auth/whoami|skills)' docs/03-authentication-design.md docs/api/authentication.openapi.yaml +git diff --check +``` + +Expected: YAML parser prints `OpenAPI YAML OK`, all five current paths are found, and `git diff --check` exits 0. + +- [ ] **Step 4: Commit authentication documentation** + +```bash +git add docs/03-authentication-design.md docs/api/authentication.openapi.yaml +git commit -s -m "docs(auth): document CLI token failure semantics (#605)" +``` + +### Task 7: Release artifact and runtime identity evidence + +**Files:** +- No repository file changes; evidence belongs in the active issue comment because runtime URLs, replica identities, and operational details may not be suitable for the public repository. + +- [ ] **Step 1: Resolve the published v0.2.14 server digest and revision** + +Run: + +```bash +docker buildx imagetools inspect ghcr.io/iflytek/skillhub-server:v0.2.14 +docker buildx imagetools inspect ghcr.io/iflytek/skillhub-server:sha-982258d +``` + +Expected: record the immutable manifest digest and confirm whether the release tag and SHA tag resolve to the same manifest. If registry access is denied, capture the denial and escalate access to the human owner. + +- [ ] **Step 2: Inspect every affected runtime replica when access is provided** + +On the runtime host, from the release compose directory, run: + +```bash +docker compose -f compose.release.yml config --images +SERVER_CONTAINER_IDS="$(docker compose -f compose.release.yml ps -q server)" +docker inspect --format '{{.Name}} {{.Config.Image}} {{.Image}} {{index .Config.Labels "org.opencontainers.image.revision"}} {{index .Config.Labels "org.opencontainers.image.version"}}' ${SERVER_CONTAINER_IDS} +for container_id in ${SERVER_CONTAINER_IDS}; do + image_id="$(docker inspect --format '{{.Image}}' "${container_id}")" + docker image inspect --format '{{json .RepoDigests}}' "${image_id}" +done +``` + +Expected: record configured version, resolved image reference, image ID, OCI revision/version, and immutable RepoDigest for every replica. A mutable tag alone is not a pass. + +- [ ] **Step 3: Replay one token lifecycle against the identified runtime** + +Using an authorized dedicated test account, create one token through the normal product flow, verify all five endpoint results while valid, revoke the same token, verify its database `revoked_at` through an authorized operational read, then repeat all five requests with the same raw token. Record HTTP status, response `requestId`, timestamp, and serving replica separately for whoami, search, resolve, latest download, and versioned download. Never paste the raw token into comments or logs. + +Expected after revocation: 401 on every endpoint. If behavior differs, preserve the exact digest/replica/request evidence and continue systematic root-cause investigation; do not claim the defect is fixed or closable. + +- [ ] **Step 4: Escalate missing runtime authority explicitly** + +If no affected runtime URL, host/replica access, or authorization to create/revoke a test token is available, explicitly escalate to the human owner in the active issue. Name the missing authority and request the exact evidence still required: deployed version, immutable server digest or build SHA, all replica identities, and same-token valid-to-revoked replay. State that repository tests do not close the field contradiction and therefore cannot justify closing the defect. + +### Task 8: Quality gates and implementation review handoff + +**Files:** +- Verify all changed files; do not create a PR in this stage. + +- [ ] **Step 1: Run both focused integration classes** + +```bash +cd server && ./mvnw -pl skillhub-app -am \ + -Dtest=CliTokenLifecycleSecurityIntegrationTest,CliRestrictedReadAuthorizationIntegrationTest \ + -Dsurefire.failIfNoSpecifiedTests=false test +``` + +Expected: PASS, with latest and versioned download reported as distinct methods. + +- [ ] **Step 2: Run the complete backend gate** + +```bash +make test-backend-app +``` + +Expected: `BUILD SUCCESS`, zero failures, zero errors. + +- [ ] **Step 3: Run repository web gates required before delivery** + +```bash +make typecheck-web +make lint-web +``` + +Expected: zero TypeScript errors and zero ESLint errors/warnings. + +- [ ] **Step 4: Run containerized staging regression** + +```bash +make staging +``` + +Expected: backend/frontend images build, services become healthy, and smoke tests pass. Tear down with `make staging-down` after collecting evidence. + +- [ ] **Step 5: Verify scope, formatting, and commit hygiene** + +```bash +git diff --check origin/main...HEAD +git diff --name-only origin/main...HEAD +git status --short --branch +git log --format='%h %s%n%b' origin/main..HEAD +``` + +Expected: only the approved spec/plan, two test classes, authentication design, and OpenAPI document are changed; no production authentication source is changed when the matrix passes; all commits are signed off and reference GitHub issue #605 without any Multica identifier. + +- [ ] **Step 6: Route to tester and reviewer quality gates** + +Provide the branch, focused commands, complete matrix result, 403 fixture result, docs path, runtime identity/replay evidence or explicit external blocker, and full gate output to the project tester. After tester passes, request structured reviewer/security review. Address any findings on the same branch and rerun affected gates. + +- [ ] **Step 7: Report completion without creating a PR** + +Post the implementation result to the active issue thread. Include commit SHAs, endpoint-by-state matrix, RED mutation evidence, GREEN results, quality gates, OpenAPI path, production-code decision, and runtime identity/replay status. Do not create a PR, do not change issue status, and do not merge `main` during this stage. From 83b621880e3ee190f9776ec90420465671ab999a Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Tue, 28 Jul 2026 12:03:24 +0800 Subject: [PATCH 55/81] test(auth): cover persisted CLI token states (#605) Signed-off-by: dongmucat <1127093059@qq.com> --- ...TokenLifecycleSecurityIntegrationTest.java | 257 ++++++++++++++++++ 1 file changed, 257 insertions(+) create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliTokenLifecycleSecurityIntegrationTest.java diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliTokenLifecycleSecurityIntegrationTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliTokenLifecycleSecurityIntegrationTest.java new file mode 100644 index 00000000..c02ebb4e --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliTokenLifecycleSecurityIntegrationTest.java @@ -0,0 +1,257 @@ +package com.iflytek.skillhub.controller.cli; + +import com.iflytek.skillhub.auth.entity.ApiToken; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.auth.repository.ApiTokenRepository; +import com.iflytek.skillhub.auth.token.ApiTokenService; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.dto.cli.CliResolveResponse; +import com.iflytek.skillhub.service.cli.CliSkillAppService; +import java.io.ByteArrayInputStream; +import java.time.Clock; +import java.time.Instant; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.core.io.InputStreamResource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.clearInvocations; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class CliTokenLifecycleSecurityIntegrationTest { + + private enum InvalidCredentialState { + REVOKED, + EXPIRED, + UNKNOWN, + EMPTY, + MALFORMED + } + + @Autowired MockMvc mockMvc; + @Autowired ApiTokenService apiTokenService; + @Autowired ApiTokenRepository apiTokenRepository; + @Autowired UserAccountRepository userAccountRepository; + @Autowired Clock clock; + @MockBean CliSkillAppService cliSkillAppService; + + private String userId; + + @BeforeEach + void setUp() { + userId = "token-matrix-" + UUID.randomUUID(); + userAccountRepository.save(new UserAccount( + userId, "Token Matrix", userId + "@example.com", "")); + given(cliSkillAppService.search(any(), anyInt(), any(), any())) + .willReturn(new CliSkillAppService.CliSearchResult(List.of(), 0, 20)); + given(cliSkillAppService.resolve(anyString(), anyString(), any(), any(), any())) + .willReturn(new CliResolveResponse( + "global", "demo", "1.0.0", 1L, "sha256:empty", + "/api/v1/skills/global/demo/versions/1.0.0/download")); + given(cliSkillAppService.downloadLatest(anyString(), anyString(), any())) + .willAnswer(ignored -> downloadResponse()); + given(cliSkillAppService.downloadVersion(anyString(), anyString(), anyString(), any())) + .willAnswer(ignored -> downloadResponse()); + } + + @Test + void whoamiWithoutAuthorizationReturns401() throws Exception { + mockMvc.perform(get("/api/cli/v1/auth/whoami")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(401)); + } + + @Test + void whoamiWithValidPersistedTokenReturns200() throws Exception { + String token = createActiveToken(); + mockMvc.perform(withBearer(get("/api/cli/v1/auth/whoami"), token)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.handle").value(userId)); + } + + @ParameterizedTest(name = "whoami rejects {0}") + @EnumSource(InvalidCredentialState.class) + void whoamiRejectsInvalidBearer(InvalidCredentialState state) throws Exception { + clearInvocations(cliSkillAppService); + mockMvc.perform(withInvalidBearer(get("/api/cli/v1/auth/whoami"), state)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(401)); + verifyNoInteractions(cliSkillAppService); + } + + @Test + void searchWithoutAuthorizationReturns200() throws Exception { + mockMvc.perform(get("/api/cli/v1/skills/search").param("q", "demo").param("limit", "20")) + .andExpect(status().isOk()); + } + + @Test + void searchWithValidPersistedTokenReturns200() throws Exception { + String token = createActiveToken(); + mockMvc.perform(withBearer( + get("/api/cli/v1/skills/search").param("q", "demo").param("limit", "20"), token)) + .andExpect(status().isOk()); + } + + @ParameterizedTest(name = "search rejects {0}") + @EnumSource(InvalidCredentialState.class) + void searchRejectsInvalidBearer(InvalidCredentialState state) throws Exception { + clearInvocations(cliSkillAppService); + mockMvc.perform(withInvalidBearer( + get("/api/cli/v1/skills/search").param("q", "demo").param("limit", "20"), state)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(401)); + verifyNoInteractions(cliSkillAppService); + } + + @Test + void resolveWithoutAuthorizationReturns200() throws Exception { + mockMvc.perform(get("/api/cli/v1/skills/global/demo/resolve")) + .andExpect(status().isOk()); + } + + @Test + void resolveWithValidPersistedTokenReturns200() throws Exception { + String token = createActiveToken(); + mockMvc.perform(withBearer(get("/api/cli/v1/skills/global/demo/resolve"), token)) + .andExpect(status().isOk()); + } + + @ParameterizedTest(name = "resolve rejects {0}") + @EnumSource(InvalidCredentialState.class) + void resolveRejectsInvalidBearer(InvalidCredentialState state) throws Exception { + clearInvocations(cliSkillAppService); + mockMvc.perform(withInvalidBearer(get("/api/cli/v1/skills/global/demo/resolve"), state)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(401)); + verifyNoInteractions(cliSkillAppService); + } + + @Test + void latestDownloadWithoutAuthorizationReturns200() throws Exception { + mockMvc.perform(get("/api/cli/v1/skills/global/demo/download")) + .andExpect(status().isOk()); + } + + @Test + void latestDownloadWithValidPersistedTokenReturns200() throws Exception { + String token = createActiveToken(); + mockMvc.perform(withBearer(get("/api/cli/v1/skills/global/demo/download"), token)) + .andExpect(status().isOk()); + } + + @ParameterizedTest(name = "latest download rejects {0}") + @EnumSource(InvalidCredentialState.class) + void latestDownloadRejectsInvalidBearer(InvalidCredentialState state) throws Exception { + clearInvocations(cliSkillAppService); + mockMvc.perform(withInvalidBearer(get("/api/cli/v1/skills/global/demo/download"), state)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(401)); + verifyNoInteractions(cliSkillAppService); + } + + @Test + void versionedDownloadWithoutAuthorizationReturns200() throws Exception { + mockMvc.perform(get("/api/cli/v1/skills/global/demo/versions/1.0.0/download")) + .andExpect(status().isOk()); + } + + @Test + void versionedDownloadWithValidPersistedTokenReturns200() throws Exception { + String token = createActiveToken(); + mockMvc.perform(withBearer( + get("/api/cli/v1/skills/global/demo/versions/1.0.0/download"), token)) + .andExpect(status().isOk()); + } + + @ParameterizedTest(name = "versioned download rejects {0}") + @EnumSource(InvalidCredentialState.class) + void versionedDownloadRejectsInvalidBearer(InvalidCredentialState state) throws Exception { + clearInvocations(cliSkillAppService); + mockMvc.perform(withInvalidBearer( + get("/api/cli/v1/skills/global/demo/versions/1.0.0/download"), state)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(401)); + verifyNoInteractions(cliSkillAppService); + } + + private MockHttpServletRequestBuilder withInvalidBearer( + MockHttpServletRequestBuilder request, + InvalidCredentialState state) { + return request + .header(HttpHeaders.AUTHORIZATION, authorizationHeader(state)) + .with(authentication(sessionAuthentication())); + } + + private MockHttpServletRequestBuilder withBearer( + MockHttpServletRequestBuilder request, + String rawToken) { + return request.header(HttpHeaders.AUTHORIZATION, "Bearer " + rawToken); + } + + private String authorizationHeader(InvalidCredentialState state) { + return switch (state) { + case REVOKED -> { + ApiTokenService.TokenCreateResult result = createToken(); + apiTokenService.revokeToken(result.entity().getId(), userId); + yield "Bearer " + result.rawToken(); + } + case EXPIRED -> { + ApiTokenService.TokenCreateResult result = createToken(); + ApiToken token = result.entity(); + token.setExpiresAt(Instant.now(clock).minusSeconds(1)); + apiTokenRepository.saveAndFlush(token); + yield "Bearer " + result.rawToken(); + } + case UNKNOWN -> "Bearer sk_unknown_" + UUID.randomUUID(); + case EMPTY -> "Bearer "; + case MALFORMED -> "Bearer"; + }; + } + + private String createActiveToken() { + return createToken().rawToken(); + } + + private ApiTokenService.TokenCreateResult createToken() { + return apiTokenService.createToken( + userId, "matrix-" + UUID.randomUUID(), "[\"skill:read\"]"); + } + + private UsernamePasswordAuthenticationToken sessionAuthentication() { + PlatformPrincipal principal = new PlatformPrincipal( + userId, "Session User", userId + "@example.com", "", "session", Set.of("USER")); + return new UsernamePasswordAuthenticationToken(principal, null, List.of()); + } + + private ResponseEntity downloadResponse() { + return ResponseEntity.ok(new InputStreamResource( + new ByteArrayInputStream("zip".getBytes(java.nio.charset.StandardCharsets.UTF_8)))); + } +} From 52843c8020da52be4fc9f20464f1c9a3296f69cc Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Tue, 28 Jul 2026 12:16:28 +0800 Subject: [PATCH 56/81] fix(test): assert CLI download media type (#605) Signed-off-by: dongmucat <1127093059@qq.com> --- .../CliTokenLifecycleSecurityIntegrationTest.java | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliTokenLifecycleSecurityIntegrationTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliTokenLifecycleSecurityIntegrationTest.java index c02ebb4e..04b19de5 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliTokenLifecycleSecurityIntegrationTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliTokenLifecycleSecurityIntegrationTest.java @@ -24,6 +24,7 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.core.io.InputStreamResource; import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.test.context.ActiveProfiles; @@ -38,6 +39,7 @@ import static org.mockito.Mockito.clearInvocations; import static org.mockito.Mockito.verifyNoInteractions; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -163,7 +165,8 @@ class CliTokenLifecycleSecurityIntegrationTest { void latestDownloadWithValidPersistedTokenReturns200() throws Exception { String token = createActiveToken(); mockMvc.perform(withBearer(get("/api/cli/v1/skills/global/demo/download"), token)) - .andExpect(status().isOk()); + .andExpect(status().isOk()) + .andExpect(content().contentType("application/zip")); } @ParameterizedTest(name = "latest download rejects {0}") @@ -187,7 +190,8 @@ class CliTokenLifecycleSecurityIntegrationTest { String token = createActiveToken(); mockMvc.perform(withBearer( get("/api/cli/v1/skills/global/demo/versions/1.0.0/download"), token)) - .andExpect(status().isOk()); + .andExpect(status().isOk()) + .andExpect(content().contentType("application/zip")); } @ParameterizedTest(name = "versioned download rejects {0}") @@ -251,7 +255,9 @@ class CliTokenLifecycleSecurityIntegrationTest { } private ResponseEntity downloadResponse() { - return ResponseEntity.ok(new InputStreamResource( - new ByteArrayInputStream("zip".getBytes(java.nio.charset.StandardCharsets.UTF_8)))); + return ResponseEntity.ok() + .contentType(MediaType.parseMediaType("application/zip")) + .body(new InputStreamResource( + new ByteArrayInputStream("zip".getBytes(java.nio.charset.StandardCharsets.UTF_8)))); } } From 06cecd4237c57e9738b16db37ac25ab1d46a2a40 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Tue, 28 Jul 2026 12:51:09 +0800 Subject: [PATCH 57/81] test(auth): cover restricted CLI read authorization (#605) Signed-off-by: dongmucat <1127093059@qq.com> --- ...ictedReadAuthorizationIntegrationTest.java | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliRestrictedReadAuthorizationIntegrationTest.java diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliRestrictedReadAuthorizationIntegrationTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliRestrictedReadAuthorizationIntegrationTest.java new file mode 100644 index 00000000..0424e373 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliRestrictedReadAuthorizationIntegrationTest.java @@ -0,0 +1,123 @@ +package com.iflytek.skillhub.controller.cli; + +import com.iflytek.skillhub.auth.token.ApiTokenService; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.SkillVersion; +import com.iflytek.skillhub.domain.skill.SkillVersionRepository; +import com.iflytek.skillhub.domain.skill.SkillVersionStatus; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import java.time.Instant; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpHeaders; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class CliRestrictedReadAuthorizationIntegrationTest { + + @Autowired MockMvc mockMvc; + @Autowired ApiTokenService apiTokenService; + @Autowired UserAccountRepository userAccountRepository; + @Autowired NamespaceRepository namespaceRepository; + @Autowired SkillRepository skillRepository; + @Autowired SkillVersionRepository skillVersionRepository; + + private String namespaceSlug; + private String skillSlug; + private String version; + private String ownerToken; + private String outsiderToken; + + @BeforeEach + void setUp() { + String suffix = UUID.randomUUID().toString().replace("-", ""); + String ownerId = "private-owner-" + suffix; + String outsiderId = "private-outsider-" + suffix; + namespaceSlug = "private-ns-" + suffix; + skillSlug = "private-skill-" + suffix; + version = "1.0.0"; + + userAccountRepository.save(new UserAccount( + ownerId, "Private Skill Owner", ownerId + "@example.com", "")); + userAccountRepository.save(new UserAccount( + outsiderId, "Private Skill Outsider", outsiderId + "@example.com", "")); + ownerToken = apiTokenService.createToken( + ownerId, "owner-token-" + suffix, "[\"skill:read\"]").rawToken(); + outsiderToken = apiTokenService.createToken( + outsiderId, "outsider-token-" + suffix, "[\"skill:read\"]").rawToken(); + + Namespace namespace = namespaceRepository.save( + new Namespace(namespaceSlug, "Private Namespace", ownerId)); + Skill skill = skillRepository.save(new Skill( + namespace.getId(), skillSlug, ownerId, SkillVisibility.PRIVATE)); + SkillVersion published = new SkillVersion(skill.getId(), version, ownerId); + published.setStatus(SkillVersionStatus.PUBLISHED); + published.setPublishedAt(Instant.parse("2026-07-28T00:00:00Z")); + published.setDownloadReady(true); + published = skillVersionRepository.save(published); + skill.setLatestVersionId(published.getId()); + skillRepository.save(skill); + skillRepository.flush(); + skillVersionRepository.flush(); + } + + @Test + void outsiderCannotResolvePrivateSkill() throws Exception { + mockMvc.perform(withBearer( + get("/api/cli/v1/skills/{namespace}/{slug}/resolve", namespaceSlug, skillSlug), + outsiderToken)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(403)); + } + + @Test + void outsiderCannotDownloadLatestPrivateSkill() throws Exception { + mockMvc.perform(withBearer( + get("/api/cli/v1/skills/{namespace}/{slug}/download", namespaceSlug, skillSlug), + outsiderToken)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(403)); + } + + @Test + void outsiderCannotDownloadVersionedPrivateSkill() throws Exception { + mockMvc.perform(withBearer( + get("/api/cli/v1/skills/{namespace}/{slug}/versions/{version}/download", + namespaceSlug, skillSlug, version), + outsiderToken)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(403)); + } + + @Test + void ownerCanResolvePrivateSkill() throws Exception { + mockMvc.perform(withBearer( + get("/api/cli/v1/skills/{namespace}/{slug}/resolve", namespaceSlug, skillSlug), + ownerToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.slug").value(skillSlug)); + } + + private MockHttpServletRequestBuilder withBearer( + MockHttpServletRequestBuilder request, + String rawToken) { + return request.header(HttpHeaders.AUTHORIZATION, "Bearer " + rawToken); + } +} From 5805e0f1d3431b671ffb9a9240499d59df2359fc Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Tue, 28 Jul 2026 12:55:35 +0800 Subject: [PATCH 58/81] docs(auth): document CLI token failure semantics (#605) Signed-off-by: dongmucat <1127093059@qq.com> --- docs/03-authentication-design.md | 13 +- docs/api/authentication.openapi.yaml | 288 +++++++++++++++++++++++++++ 2 files changed, 297 insertions(+), 4 deletions(-) create mode 100644 docs/api/authentication.openapi.yaml diff --git a/docs/03-authentication-design.md b/docs/03-authentication-design.md index b4fb0cd1..5c70b51f 100644 --- a/docs/03-authentication-design.md +++ b/docs/03-authentication-design.md @@ -621,10 +621,15 @@ window.location.href = '/oauth2/authorization/github' ### 10.3 CLI API -| 接口 | 所需凭证 | 额外判定 | -|------|---------|---------| -| `GET /api/v1/whoami` | 任意有效 Bearer Token | 无 | -| `POST /api/v1/publish` | Bearer Token + `skill:publish` | 普通用户要求目标 namespace 成员;`SUPER_ADMIN` 可绕过 | +| 接口 | 凭证规则 | 授权与错误语义 | +|------|---------|---------------| +| `GET /api/cli/v1/auth/whoami` | 必须提供有效 Bearer Token | 缺失、未知、过期、撤销或 malformed token 返回 401 | +| `GET /api/cli/v1/skills/search` | 可匿名;提供 Bearer 时必须有效 | 匿名仅返回公开可安装 skill;坏凭证返回 401,不得降级匿名 | +| `GET /api/cli/v1/skills/{namespace}/{slug}/resolve` | 可匿名读取公开资源;提供 Bearer 时必须有效 | 坏凭证返回 401;有效身份无资源权限返回 403 | +| `GET /api/cli/v1/skills/{namespace}/{slug}/download` | 可匿名下载公开资源;提供 Bearer 时必须有效 | 坏凭证返回 401;有效身份无资源权限返回 403 | +| `GET /api/cli/v1/skills/{namespace}/{slug}/versions/{version}/download` | 可匿名下载公开资源;提供 Bearer 时必须有效 | 坏凭证返回 401;有效身份无资源权限返回 403 | + +公共读接口仅在完全缺少 `Authorization` 头时允许匿名访问。请求一旦携带 Bearer 凭证,空值、格式错误、未知、过期、已撤销、用户缺失或用户禁用均由共享认证过滤器返回 401。身份已验证但 token scope 或资源可见性不足时返回 403;服务端不向客户端区分 token 不存在、过期或已撤销。 ### 10.4 Admin API diff --git a/docs/api/authentication.openapi.yaml b/docs/api/authentication.openapi.yaml new file mode 100644 index 00000000..97f170c5 --- /dev/null +++ b/docs/api/authentication.openapi.yaml @@ -0,0 +1,288 @@ +openapi: 3.0.3 +info: + title: SkillHub CLI Authentication API + version: 1.0.0 + description: >- + Authentication contract for CLI identity and public skill reads. Public read + operations permit a request with no Authorization header, but any supplied + Bearer credential must be valid; malformed, unknown, expired, or revoked + credentials return HTTP 401 and never fall back to anonymous access. +servers: + - url: / +tags: + - name: CLI Authentication + - name: CLI Skills +paths: + /api/cli/v1/auth/whoami: + get: + tags: [CLI Authentication] + summary: Return the current CLI identity + operationId: cliWhoAmI + security: + - bearerAuth: [] + responses: + '200': + description: Authenticated CLI identity + content: + application/json: + schema: + $ref: '#/components/schemas/CliWhoAmIEnvelope' + '401': + $ref: '#/components/responses/Unauthorized' + /api/cli/v1/skills/search: + get: + tags: [CLI Skills] + summary: Search CLI-installable skills + operationId: cliSearchSkills + description: No Authorization header uses anonymous public visibility. A supplied invalid Bearer credential returns 401. + security: + - {} + - bearerAuth: [] + parameters: + - name: q + in: query + required: false + schema: {type: string} + example: pdf + description: Optional search text. + - name: limit + in: query + required: false + schema: {type: integer, format: int32, default: 20} + example: 20 + description: Maximum number of results. + responses: + '200': + description: Search result + content: + application/json: + schema: + $ref: '#/components/schemas/CliSearchEnvelope' + '401': + $ref: '#/components/responses/Unauthorized' + /api/cli/v1/skills/{namespace}/{slug}/resolve: + get: + tags: [CLI Skills] + summary: Resolve a skill version + operationId: cliResolveSkill + security: + - {} + - bearerAuth: [] + parameters: + - $ref: '#/components/parameters/Namespace' + - $ref: '#/components/parameters/Slug' + - name: version + in: query + required: false + schema: {type: string} + example: 1.0.0 + description: Optional exact version; omitted resolves latest. + responses: + '200': + description: Resolved version + content: + application/json: + schema: + $ref: '#/components/schemas/CliResolveEnvelope' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + /api/cli/v1/skills/{namespace}/{slug}/download: + get: + tags: [CLI Skills] + summary: Download the latest installable skill version + operationId: cliDownloadLatestSkill + security: + - {} + - bearerAuth: [] + parameters: + - $ref: '#/components/parameters/Namespace' + - $ref: '#/components/parameters/Slug' + responses: + '200': + $ref: '#/components/responses/Download' + '302': + $ref: '#/components/responses/DownloadRedirect' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '503': + $ref: '#/components/responses/StorageUnavailable' + /api/cli/v1/skills/{namespace}/{slug}/versions/{version}/download: + get: + tags: [CLI Skills] + summary: Download an exact installable skill version + operationId: cliDownloadSkillVersion + security: + - {} + - bearerAuth: [] + parameters: + - $ref: '#/components/parameters/Namespace' + - $ref: '#/components/parameters/Slug' + - $ref: '#/components/parameters/Version' + responses: + '200': + $ref: '#/components/responses/Download' + '302': + $ref: '#/components/responses/DownloadRedirect' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '503': + $ref: '#/components/responses/StorageUnavailable' +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: SkillHub API token + description: API token issued by SkillHub. Invalid lifecycle states all return the same 401 response. + parameters: + Namespace: + name: namespace + in: path + required: true + schema: {type: string} + example: global + description: Namespace slug. + Slug: + name: slug + in: path + required: true + schema: {type: string} + example: pdf-parser + description: Skill slug. + Version: + name: version + in: path + required: true + schema: {type: string} + example: 1.0.0 + description: Exact semantic version. + responses: + Download: + description: ZIP package stream + headers: + Content-Disposition: + schema: {type: string} + description: Attachment filename. + content: + application/zip: + schema: {type: string, format: binary} + DownloadRedirect: + description: Redirect to a presigned object-storage URL + headers: + Location: + schema: {type: string, format: uri} + BadRequest: + description: Namespace, skill, or version cannot be resolved. + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorEnvelope'} + Unauthorized: + description: Bearer credential is missing where required, malformed, unknown, expired, revoked, or belongs to an unavailable user. + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorEnvelope'} + example: + code: 401 + msg: Authentication required + data: null + timestamp: '2026-07-28T00:00:00Z' + requestId: req-123 + Forbidden: + description: Credential is valid but token scope or resource permission is insufficient. + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorEnvelope'} + example: + code: 403 + msg: Forbidden + data: null + timestamp: '2026-07-28T00:00:00Z' + requestId: req-123 + StorageUnavailable: + description: Object storage is unavailable. + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorEnvelope'} + schemas: + Envelope: + type: object + required: [code, msg, timestamp] + properties: + code: {type: integer, format: int32} + msg: {type: string} + data: {type: object, nullable: true} + timestamp: {type: string, format: date-time} + requestId: {type: string, nullable: true} + ErrorEnvelope: + allOf: + - $ref: '#/components/schemas/Envelope' + - type: object + properties: + data: {type: object, nullable: true, example: null} + CliWhoAmIEnvelope: + allOf: + - $ref: '#/components/schemas/Envelope' + - type: object + properties: + data: + $ref: '#/components/schemas/CliWhoAmI' + CliWhoAmI: + type: object + required: [handle, displayName, email] + properties: + handle: {type: string, example: user-123} + displayName: {type: string, example: CLI User} + email: {type: string, format: email, example: cli@example.com} + CliSearchEnvelope: + allOf: + - $ref: '#/components/schemas/Envelope' + - type: object + properties: + data: + $ref: '#/components/schemas/CliSearchResult' + CliSearchResult: + type: object + required: [items, total, limit] + properties: + items: + type: array + items: {$ref: '#/components/schemas/CliSearchItem'} + total: {type: integer, format: int64, example: 1} + limit: {type: integer, format: int32, example: 20} + CliSearchItem: + type: object + required: [namespace, slug, latestVersion] + properties: + namespace: {type: string, example: global} + slug: {type: string, example: pdf-parser} + latestVersion: {type: string, example: 1.2.0} + summary: {type: string, nullable: true, example: Parse PDF files} + CliResolveEnvelope: + allOf: + - $ref: '#/components/schemas/Envelope' + - type: object + properties: + data: + $ref: '#/components/schemas/CliResolveResult' + CliResolveResult: + type: object + required: [namespace, slug, version, versionId, fingerprint, downloadUrl] + properties: + namespace: {type: string, example: global} + slug: {type: string, example: pdf-parser} + version: {type: string, example: 1.2.0} + versionId: {type: integer, format: int64, example: 42} + fingerprint: {type: string, example: 'sha256:abc123'} + downloadUrl: {type: string, example: /api/v1/skills/global/pdf-parser/versions/1.2.0/download} From e5f0cc140a94484ee07ecd7eb728a1a84a81af35 Mon Sep 17 00:00:00 2001 From: FenjuFu <92919259+FenjuFu@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:49:38 +0800 Subject: [PATCH 59/81] docs(faq): add community-sourced deployment and operations Q&A (#593) * docs(faq): add community-sourced deployment and operations Q&A Adds entries collected from real user-support threads to the reference FAQ (both zh and en): - 502 on auth APIs while the page loads, traced to server startup failure on the SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET placeholder guard - config changes not taking effect (.env.release.example vs .env.release, restart vs recreate) - built-in skill sync failure in offline environments - upgrade path with Flyway auto-migration and volume retention - external dependencies and the lack of MySQL support - granting SUPER_ADMIN to an OAuth account via the bootstrap admin - telling CLI and server versions apart - installing skills into a target directory on an intranet Signed-off-by: FenjuFu * docs(faq): move entries to the published docs source and fix inaccuracies Move the new FAQ entries from document/ (a generated tree that the docs build does not read) to docs/skillhub/, which is what make docs-build and the Pages deploy actually publish. Also address review feedback: - drop the SKILLHUB_BUILTIN_SKILLS_ENABLED tip; compose.release.yml does not pass that variable through, so setting it has no effect - correct the dependency list: object storage defaults to local, S3 is recommended for production - soften the 502 wording, since upstream/DNS/network can also cause it - state the 32-character minimum for the cookie secret - give a real bulk-install example and qualify v0.2.12 as a server version - drop entries already covered by existing upgrade/MySQL/version questions Signed-off-by: FenjuFu <92919259+FenjuFu@users.noreply.github.com> * docs(faq): correct deployment and admin guidance Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> * docs(faq): fix remaining recreate guidance Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> * docs(faq): clarify bulk install paths Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --------- Signed-off-by: FenjuFu Signed-off-by: FenjuFu <92919259+FenjuFu@users.noreply.github.com> Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Co-authored-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- docs/skillhub/en/faq.md | 83 +++++++++++++++++++++++++++++++++++++++-- docs/skillhub/faq.md | 83 +++++++++++++++++++++++++++++++++++++++-- 2 files changed, 160 insertions(+), 6 deletions(-) diff --git a/docs/skillhub/en/faq.md b/docs/skillhub/en/faq.md index 9cf6d556..7cc4500f 100644 --- a/docs/skillhub/en/faq.md +++ b/docs/skillhub/en/faq.md @@ -156,10 +156,18 @@ A: This is most commonly seen with **manual deployment** (caused by API errors o ## Q: How do I change the admin password? Why don't my config changes take effect? -A: Environment variables are read at container startup, so you must restart the containers after changing them. +A: Environment variables are injected when a container is created, so you must recreate the containers after changing them; `restart` alone does not re-inject environment variables. 1. Edit `/tmp/skillhub-runtime/.env.release` in the runtime directory (refer to [.env.release.example](https://github.com/iflytek/skillhub/blob/main/.env.release.example)). -2. Restart the relevant containers. +2. Recreate the relevant containers: + + ```bash + docker compose \ + --env-file /tmp/skillhub-runtime/.env.release \ + -f /tmp/skillhub-runtime/compose.release.yml \ + up -d --force-recreate + ``` + 3. If the password was already persisted to the database and the change still doesn't take effect, you may need to clear the corresponding data and re-initialize. ## Q: Is an email verification code required to change / reset a password? @@ -219,7 +227,7 @@ A: The default limit is **100 files** (this is separate from the 100MB size limi SKILLHUB_PUBLISH_MAX_FILE_COUNT=500 ``` -Restart the containers for the change to take effect. Note that `compose.release.yml` must also reference this variable; older versions (e.g. v0.2.6) may hard-code the value, so upgrading to the latest version is recommended. +Recreate the containers for the change to take effect; `restart` alone does not re-inject environment variables. Note that `compose.release.yml` must also reference this variable; older versions (e.g. v0.2.6) may hard-code the value, so upgrading to the latest version is recommended. ## Q: Is there a server version requirement for using the CLI (publish / download, etc.)? @@ -246,6 +254,75 @@ docker image inspect ghcr.io/iflytek/skillhub-server:latest --format '{{index .C - Check the CLI version: `skillhub version`. - For customization (e.g. changing the logo), it is recommended to fork the latest code, modify it, and build your own Docker image. +## Q: The page loads, but the login / register APIs return 502? + +A: The page is served by the `web` container, while login, register and other APIs are proxied by `web` to `server` (default `SKILLHUB_API_UPSTREAM=http://server:8080`). When the page works but the API returns 502, check whether `server` started correctly first; a wrong upstream, DNS, or container-network problem can also produce a 502. + +Troubleshooting order: + +```bash +# 1. Check whether server is running +docker compose --env-file .env.release -f compose.release.yml ps + +# 2. Look at the first error in the server startup log +docker compose --env-file .env.release -f compose.release.yml logs server | head -50 +``` + +One common startup failure is: + +``` +SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET must not use the default placeholder +``` + +This means `server` still reads the placeholder from the template. Replace it in `.env.release` with your own random string (**at least 32 characters**) and recreate the containers: + +```bash +SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET= +``` + +Running `make validate-release-config` before startup validates `.env.release` and surfaces placeholders and missing values early. + +## Q: Why doesn't my configuration change take effect? + +A: Two common causes: + +1. **Edited the wrong file**: `.env.release.example` is only a template; Compose reads the file passed via `--env-file`, i.e. `.env.release`. Run `cp .env.release.example .env.release` first, then edit `.env.release`. +2. **Restarted instead of recreated**: environment variables are injected when the container is created, and `restart` does not re-inject them. Recreate the containers after a config change: + +```bash +docker compose --env-file .env.release -f compose.release.yml up -d --force-recreate +``` + +## Q: What external dependencies does SkillHub require at runtime? + +A: PostgreSQL and Redis are required. Object storage supports both `local` and S3, controlled by `SKILLHUB_STORAGE_PROVIDER`. `.env.release.example` explicitly selects `local`, but if the variable is completely unset when using `compose.release.yml`, the Compose fallback is `s3`. Set it explicitly; S3 is recommended for production (configured via `SKILLHUB_STORAGE_S3_*`). Only PostgreSQL is supported as the database — MySQL is not. + +The release Compose file already bundles PostgreSQL and Redis, bound to `127.0.0.1` by default. + +## Q: How does an account created through OAuth (GitHub / GitLab, etc.) get admin rights? + +A: The first OAuth login creates a regular user. An existing `SUPER_ADMIN` (for example the bootstrap admin created during initialization) has to promote it from the admin console. + +A `USER_ADMIN` can manage user status and assign platform roles other than `SUPER_ADMIN`, but cannot grant `SUPER_ADMIN` to any account or change the role of an existing `SUPER_ADMIN`. Only a `SUPER_ADMIN` can perform those two operations. + +## Q: How do I install multiple skills in bulk? + +A: The CLI `install` command handles one skill at a time. Both examples below use `--dir` to install the skills under the same target root; each skill is placed in `$target_dir//`: + +```bash +target_dir=/opt/skillhub-skills + +# install one by one +for skill in skill-a skill-b skill-c; do + skillhub install "$skill" --dir "$target_dir" +done + +# or read from a manifest file (one skill name per line) +xargs -a skills.txt -I {} skillhub install "{}" --dir "$target_dir" +``` + +Since **SkillHub Server v0.2.12**, public skills support anonymous search and install. Note that an invalid bearer token now fails the command instead of falling back to anonymous access — update or remove the stale credential in that case. + ## Q: What should I do if I encounter issues? A: You can get help through the following channels: diff --git a/docs/skillhub/faq.md b/docs/skillhub/faq.md index 2aac0a54..b16ecd81 100644 --- a/docs/skillhub/faq.md +++ b/docs/skillhub/faq.md @@ -156,10 +156,18 @@ A: 该现象多见于「手动部署」场景(接口异常或初始化未完 ## Q: 如何修改 admin 密码?修改配置后不生效? -A: 环境变量在容器启动时读取,修改后必须重启容器才会生效。 +A: 环境变量在容器创建时注入,修改后必须重新创建容器才会生效;仅执行 `restart` 不会重新注入环境变量。 1. 修改运行时目录下的 `/tmp/skillhub-runtime/.env.release`(参考仓库 [.env.release.example](https://github.com/iflytek/skillhub/blob/main/.env.release.example))。 -2. 重启相关容器。 +2. 重新创建相关容器: + + ```bash + docker compose \ + --env-file /tmp/skillhub-runtime/.env.release \ + -f /tmp/skillhub-runtime/compose.release.yml \ + up -d --force-recreate + ``` + 3. 若此前密码已写入数据库导致仍不生效,可能需要清理对应数据后重新初始化。 ## Q: 修改 / 找回密码必须使用邮箱验证码吗? @@ -219,7 +227,7 @@ A: 默认上限为 **100 个文件**(这与 100MB 的大小限制是两回事 SKILLHUB_PUBLISH_MAX_FILE_COUNT=500 ``` -修改后需重启容器生效。注意 `compose.release.yml` 中也需引用该变量;较旧版本(如 v0.2.6)可能将该值写死,建议升级到最新版本。 +修改后需重新创建容器才会生效;仅执行 `restart` 不会重新注入环境变量。注意 `compose.release.yml` 中也需引用该变量;较旧版本(如 v0.2.6)可能将该值写死,建议升级到最新版本。 ## Q: 使用 CLI(发布 / 下载等)对服务端版本有要求吗? @@ -246,6 +254,75 @@ docker image inspect ghcr.io/iflytek/skillhub-server:latest --format '{{index .C - 查看 CLI 版本:`skillhub version`。 - 如需定制(如修改 logo 等),建议基于最新代码进行二次开发并自行构建 docker 镜像。 +## Q: 页面能打开,但登录 / 注册接口返回 502? + +A: 页面由 `web` 容器提供,登录、注册等接口由 `web` 转发给 `server`(默认 `SKILLHUB_API_UPSTREAM=http://server:8080`)。出现「页面正常但 API 502」时,通常先检查 `server` 是否正常启动;upstream 配置、DNS 或容器网络异常也可能返回 502。 + +排查顺序: + +```bash +# 1. 看 server 是否处于运行状态 +docker compose --env-file .env.release -f compose.release.yml ps + +# 2. 看 server 启动日志中的第一条错误 +docker compose --env-file .env.release -f compose.release.yml logs server | head -50 +``` + +一条常见的启动失败日志是: + +``` +SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET must not use the default placeholder +``` + +说明 `server` 读到的仍是模板里的占位值。在 `.env.release` 中改成自己的随机字符串(**至少 32 个字符**)后重建容器即可: + +```bash +SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET=<替换成你自己的随机字符串,至少 32 个字符> +``` + +启动前可以先执行 `make validate-release-config`,它会校验 `.env.release`,提前暴露这类占位值和缺失项。 + +## Q: 改了配置为什么不生效? + +A: 两个高频原因: + +1. **改错了文件**:`.env.release.example` 只是模板,Compose 实际读取的是 `--env-file` 指定的 `.env.release`。请先 `cp .env.release.example .env.release`,然后修改 `.env.release`。 +2. **只重启没重建**:环境变量在容器创建时注入,`restart` 不会重新注入。改完配置需要重建容器: + +```bash +docker compose --env-file .env.release -f compose.release.yml up -d --force-recreate +``` + +## Q: SkillHub 运行时需要哪些外部依赖? + +A: 必需 PostgreSQL 和 Redis;对象存储支持 `local` 与 S3 两种模式,由 `SKILLHUB_STORAGE_PROVIDER` 控制。`.env.release.example` 显式配置为 `local`,但如果使用 `compose.release.yml` 时完全没有设置该变量,Compose 的回退值是 `s3`。建议始终显式设置;生产环境推荐使用 S3(通过 `SKILLHUB_STORAGE_S3_*` 配置)。数据库仅支持 PostgreSQL,暂不支持 MySQL。 + +发布版 Compose 已内置 PostgreSQL 与 Redis,默认只绑定在 `127.0.0.1`。 + +## Q: 通过 OAuth(GitHub / GitLab 等)登录的账号,如何取得管理员权限? + +A: OAuth 首次登录创建的是普通用户。需要由已有的 `SUPER_ADMIN`(例如初始化时的 bootstrap admin)在后台将其提升为管理员。 + +`USER_ADMIN` 可以管理用户状态,并分配除 `SUPER_ADMIN` 之外的平台角色;但不能向任何账号授予 `SUPER_ADMIN`,也不能修改已有 `SUPER_ADMIN` 账号的角色。这两类操作只有 `SUPER_ADMIN` 可以执行。 + +## Q: 如何批量安装多个技能包? + +A: CLI 的 `install` 一次处理一个技能包。下面两个示例都通过 `--dir` 将技能批量安装到同一个目标根目录;每个技能实际位于 `$target_dir//`: + +```bash +target_dir=/opt/skillhub-skills + +# 逐个安装 +for skill in skill-a skill-b skill-c; do + skillhub install "$skill" --dir "$target_dir" +done + +# 或从清单文件读取(每行一个技能名) +xargs -a skills.txt -I {} skillhub install "{}" --dir "$target_dir" +``` + +自 **SkillHub Server v0.2.12** 起,公开技能支持匿名搜索与安装;如果配置了无效的 Bearer Token,命令会直接失败而不再回退匿名访问,遇到这种情况请更新凭据或先移除无效 Token。 + ## Q: 遇到问题怎么办? A: 可以通过以下方式获取帮助: From 726eeac8b24dc85a6e98276738d211ef92551d58 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Tue, 28 Jul 2026 13:52:26 +0800 Subject: [PATCH 60/81] test(auth): cover token replay and private search (#605) Signed-off-by: dongmucat <1127093059@qq.com> --- ...ictedReadAuthorizationIntegrationTest.java | 29 +++++++++ ...TokenLifecycleSecurityIntegrationTest.java | 62 +++++++++++++++++++ 2 files changed, 91 insertions(+) diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliRestrictedReadAuthorizationIntegrationTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliRestrictedReadAuthorizationIntegrationTest.java index 0424e373..5a29cffa 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliRestrictedReadAuthorizationIntegrationTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliRestrictedReadAuthorizationIntegrationTest.java @@ -11,6 +11,8 @@ import com.iflytek.skillhub.domain.skill.SkillVersionStatus; import com.iflytek.skillhub.domain.skill.SkillVisibility; import com.iflytek.skillhub.domain.user.UserAccount; import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.infra.jpa.SkillSearchDocumentEntity; +import com.iflytek.skillhub.infra.jpa.SkillSearchDocumentJpaRepository; import java.time.Instant; import java.util.UUID; import org.junit.jupiter.api.BeforeEach; @@ -23,6 +25,9 @@ import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; +import static org.hamcrest.Matchers.aMapWithSize; +import static org.hamcrest.Matchers.hasItem; +import static org.hamcrest.Matchers.not; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -38,6 +43,7 @@ class CliRestrictedReadAuthorizationIntegrationTest { @Autowired NamespaceRepository namespaceRepository; @Autowired SkillRepository skillRepository; @Autowired SkillVersionRepository skillVersionRepository; + @Autowired SkillSearchDocumentJpaRepository skillSearchDocumentRepository; private String namespaceSlug; private String skillSlug; @@ -76,6 +82,29 @@ class CliRestrictedReadAuthorizationIntegrationTest { skillRepository.save(skill); skillRepository.flush(); skillVersionRepository.flush(); + skillSearchDocumentRepository.saveAndFlush(new SkillSearchDocumentEntity( + skill.getId(), + namespace.getId(), + namespaceSlug, + ownerId, + skillSlug, + "Private skill search fixture", + "private", + skillSlug, + "", + SkillVisibility.PRIVATE.name(), + skill.getStatus().name())); + } + + @Test + void outsiderSearchOmitsPersistedPrivateSkill() throws Exception { + mockMvc.perform(withBearer( + get("/api/cli/v1/skills/search").param("limit", "20"), + outsiderToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", aMapWithSize(5))) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.items[*].slug", not(hasItem(skillSlug)))); } @Test diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliTokenLifecycleSecurityIntegrationTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliTokenLifecycleSecurityIntegrationTest.java index 04b19de5..e351a7e5 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliTokenLifecycleSecurityIntegrationTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliTokenLifecycleSecurityIntegrationTest.java @@ -29,8 +29,11 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; +import static org.hamcrest.Matchers.aMapWithSize; +import static org.hamcrest.Matchers.nullValue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyString; @@ -205,6 +208,65 @@ class CliTokenLifecycleSecurityIntegrationTest { verifyNoInteractions(cliSkillAppService); } + @Test + void sameRawTokenIsRejectedByAllEndpointsAfterValidUseAndRevocation() throws Exception { + ApiTokenService.TokenCreateResult token = createToken(); + String rawToken = token.rawToken(); + + assertSuccessEnvelope(withBearer(get("/api/cli/v1/auth/whoami"), rawToken)) + .andExpect(jsonPath("$.data.handle").value(userId)); + assertSuccessEnvelope(withBearer( + get("/api/cli/v1/skills/search").param("q", "demo").param("limit", "20"), + rawToken)); + assertSuccessEnvelope(withBearer( + get("/api/cli/v1/skills/global/demo/resolve"), rawToken)); + mockMvc.perform(withBearer( + get("/api/cli/v1/skills/global/demo/download"), rawToken)) + .andExpect(status().isOk()) + .andExpect(content().contentType("application/zip")); + mockMvc.perform(withBearer( + get("/api/cli/v1/skills/global/demo/versions/1.0.0/download"), rawToken)) + .andExpect(status().isOk()) + .andExpect(content().contentType("application/zip")); + + apiTokenService.revokeToken(token.entity().getId(), userId); + clearInvocations(cliSkillAppService); + + assertUnauthorizedEnvelope(withBearer(get("/api/cli/v1/auth/whoami"), rawToken)); + assertUnauthorizedEnvelope(withBearer( + get("/api/cli/v1/skills/search").param("q", "demo").param("limit", "20"), + rawToken)); + assertUnauthorizedEnvelope(withBearer( + get("/api/cli/v1/skills/global/demo/resolve"), rawToken)); + assertUnauthorizedEnvelope(withBearer( + get("/api/cli/v1/skills/global/demo/download"), rawToken)); + assertUnauthorizedEnvelope(withBearer( + get("/api/cli/v1/skills/global/demo/versions/1.0.0/download"), rawToken)); + verifyNoInteractions(cliSkillAppService); + } + + private ResultActions assertSuccessEnvelope(MockHttpServletRequestBuilder request) throws Exception { + return mockMvc.perform(request) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", aMapWithSize(5))) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.msg").isString()) + .andExpect(jsonPath("$.data").exists()) + .andExpect(jsonPath("$.timestamp").isString()) + .andExpect(jsonPath("$.requestId").isString()); + } + + private void assertUnauthorizedEnvelope(MockHttpServletRequestBuilder request) throws Exception { + mockMvc.perform(request) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$", aMapWithSize(5))) + .andExpect(jsonPath("$.code").value(401)) + .andExpect(jsonPath("$.msg").isString()) + .andExpect(jsonPath("$.data").value(nullValue())) + .andExpect(jsonPath("$.timestamp").isString()) + .andExpect(jsonPath("$.requestId").isString()); + } + private MockHttpServletRequestBuilder withInvalidBearer( MockHttpServletRequestBuilder request, InvalidCredentialState state) { From 8163a48e9e489f1c6f7c3e854274bb8c7f50997e Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Tue, 28 Jul 2026 13:52:42 +0800 Subject: [PATCH 61/81] docs(auth): align Bearer-only response contract (#605) Signed-off-by: dongmucat <1127093059@qq.com> --- docs/03-authentication-design.md | 4 +- docs/api/authentication.openapi.yaml | 17 +++-- .../2026-07-28-revoked-token-validation.md | 71 ++++++++++++++----- ...6-07-28-revoked-token-validation-design.md | 14 ++-- 4 files changed, 75 insertions(+), 31 deletions(-) diff --git a/docs/03-authentication-design.md b/docs/03-authentication-design.md index 5c70b51f..e28f10af 100644 --- a/docs/03-authentication-design.md +++ b/docs/03-authentication-design.md @@ -377,7 +377,7 @@ API Token 仍保留,但定位从“CLI 唯一认证方式”调整为“平台 - 用途:自动化脚本、兼容层调用、手工 Token 管理、后续系统集成 - 存储:只存 SHA-256 哈希,明文只展示一次 - 校验:从 `Authorization: Bearer ` 提取 → 哈希比对 → 加载关联用户 → 检查用户状态 -- 失败闭合:公共读接口只有在缺少 `Authorization` 头时才按匿名访问处理;只要出现 Bearer 凭证,空值、格式错误、未知、过期、已吊销、用户缺失或用户禁用均返回 401,不能回退为匿名访问 +- 失败闭合:共享认证过滤器只识别 Bearer scheme;公共读接口在未提供可识别的 Bearer 凭证时按匿名访问处理(包括缺少 `Authorization` 头,以及 Basic 或其他非 Bearer scheme)。请求一旦使用 Bearer scheme,空值、格式错误、未知、过期、已吊销、用户缺失或用户禁用均返回 401,不能回退为匿名访问 - 作用域:`skill:read`, `skill:publish`, `skill:delete`, `token:manage` > **一期作用域说明(非最小权限)**:一期 Token 作用域为粗粒度动作级别,不与 namespace 绑定。Token 继承用户的全部权限——如果用户是某个 namespace 的 MEMBER,则该用户的任何 Token(只要包含 `skill:publish` scope)都可以向该 namespace 发布技能。这是有意的一期简化,不满足最小权限原则。后续版本计划引入 namespace 级别的 Token 作用域限定(如 `namespace:ai-team:skill:publish`),或通过 `api_token_scope` 子表实现 Token 与 namespace 的绑定。 @@ -629,7 +629,7 @@ window.location.href = '/oauth2/authorization/github' | `GET /api/cli/v1/skills/{namespace}/{slug}/download` | 可匿名下载公开资源;提供 Bearer 时必须有效 | 坏凭证返回 401;有效身份无资源权限返回 403 | | `GET /api/cli/v1/skills/{namespace}/{slug}/versions/{version}/download` | 可匿名下载公开资源;提供 Bearer 时必须有效 | 坏凭证返回 401;有效身份无资源权限返回 403 | -公共读接口仅在完全缺少 `Authorization` 头时允许匿名访问。请求一旦携带 Bearer 凭证,空值、格式错误、未知、过期、已撤销、用户缺失或用户禁用均由共享认证过滤器返回 401。身份已验证但 token scope 或资源可见性不足时返回 403;服务端不向客户端区分 token 不存在、过期或已撤销。 +共享 API token 过滤器只识别 Bearer scheme。公共读接口在未提供可识别的 Bearer 凭证时允许匿名访问:这既包括完全缺少 `Authorization` 头,也包括 Basic 或其他非 Bearer scheme;`whoami` 因自身要求认证,在这些情况下仍返回 401。请求一旦使用 Bearer scheme,空值、格式错误、未知、过期、已撤销、用户缺失或用户禁用均由共享认证过滤器返回 401,不能降级为匿名身份。身份已验证但 token scope 或资源可见性不足时返回 403;服务端不向客户端区分 token 不存在、过期或已撤销。 ### 10.4 Admin API diff --git a/docs/api/authentication.openapi.yaml b/docs/api/authentication.openapi.yaml index 97f170c5..4fafac22 100644 --- a/docs/api/authentication.openapi.yaml +++ b/docs/api/authentication.openapi.yaml @@ -4,9 +4,11 @@ info: version: 1.0.0 description: >- Authentication contract for CLI identity and public skill reads. Public read - operations permit a request with no Authorization header, but any supplied - Bearer credential must be valid; malformed, unknown, expired, or revoked - credentials return HTTP 401 and never fall back to anonymous access. + operations treat a request with no recognized Bearer credential as anonymous, + including an absent Authorization header or an unsupported scheme such as + Basic. Once the Bearer scheme is used, the credential must be valid; + malformed, unknown, expired, or revoked Bearer credentials return HTTP 401 + and never fall back to anonymous access. servers: - url: / tags: @@ -34,7 +36,7 @@ paths: tags: [CLI Skills] summary: Search CLI-installable skills operationId: cliSearchSkills - description: No Authorization header uses anonymous public visibility. A supplied invalid Bearer credential returns 401. + description: An absent Bearer credential, including an unsupported non-Bearer Authorization scheme, uses anonymous public visibility. A supplied invalid Bearer credential returns 401. security: - {} - bearerAuth: [] @@ -65,6 +67,7 @@ paths: tags: [CLI Skills] summary: Resolve a skill version operationId: cliResolveSkill + description: An absent Bearer credential, including an unsupported non-Bearer Authorization scheme, uses anonymous public visibility. A supplied invalid Bearer credential returns 401. security: - {} - bearerAuth: [] @@ -95,6 +98,7 @@ paths: tags: [CLI Skills] summary: Download the latest installable skill version operationId: cliDownloadLatestSkill + description: An absent Bearer credential, including an unsupported non-Bearer Authorization scheme, uses anonymous public visibility. A supplied invalid Bearer credential returns 401. security: - {} - bearerAuth: [] @@ -119,6 +123,7 @@ paths: tags: [CLI Skills] summary: Download an exact installable skill version operationId: cliDownloadSkillVersion + description: An absent Bearer credential, including an unsupported non-Bearer Authorization scheme, uses anonymous public visibility. A supplied invalid Bearer credential returns 401. security: - {} - bearerAuth: [] @@ -218,13 +223,13 @@ components: schemas: Envelope: type: object - required: [code, msg, timestamp] + required: [code, msg, data, timestamp, requestId] properties: code: {type: integer, format: int32} msg: {type: string} data: {type: object, nullable: true} timestamp: {type: string, format: date-time} - requestId: {type: string, nullable: true} + requestId: {type: string, example: req-123} ErrorEnvelope: allOf: - $ref: '#/components/schemas/Envelope' diff --git a/docs/superpowers/plans/2026-07-28-revoked-token-validation.md b/docs/superpowers/plans/2026-07-28-revoked-token-validation.md index d17b2d88..8deb0553 100644 --- a/docs/superpowers/plans/2026-07-28-revoked-token-validation.md +++ b/docs/superpowers/plans/2026-07-28-revoked-token-validation.md @@ -13,7 +13,7 @@ ## File Map - Create `server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliTokenLifecycleSecurityIntegrationTest.java`: persisted valid/revoked/expired/unknown/empty/malformed credential matrix for each CLI endpoint. -- Create `server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliRestrictedReadAuthorizationIntegrationTest.java`: real PRIVATE-skill read authorization through resolve, latest download, and versioned download. +- Create `server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliRestrictedReadAuthorizationIntegrationTest.java`: real PRIVATE-skill search omission and read authorization through resolve, latest download, and versioned download. - Modify `docs/03-authentication-design.md`: current CLI route table and explicit anonymous/401/403 rules. - Create `docs/api/authentication.openapi.yaml`: OpenAPI 3.0 contract for whoami, search, resolve, latest download, and versioned download. - Do not modify `server/skillhub-auth/src/main/**` unless Task 5 records a failing unmodified-source assertion and a separate systematic-debugging plan amendment identifies the root cause. @@ -412,7 +412,34 @@ Run focused commands for the invalid, anonymous, and valid versioned-download me Expected: all commands PASS and the filter source has no diff. -- [ ] **Step 4: Run the complete persisted credential matrix** +- [ ] **Step 4: Add and prove the same-token valid-to-revoked replay** + +Create one token through `ApiTokenService`, retain its raw value, and use that +same value successfully against whoami, search, resolve, latest download, and +versioned download. Revoke the persisted token through +`ApiTokenService.revokeToken`, clear prior business-service invocations, then +replay the exact same raw value against all five endpoints. Each replay must +return 401 and the mocked business service must receive no post-revocation +interaction. + +For the three valid JSON responses and all five revoked error responses, assert +that the outer JSON object contains exactly `code`, `msg`, `data`, `timestamp`, +and `requestId`; successful downloads remain binary-stream exceptions. + +Apply the reversible invalid-token fail-open mutation and run: + +```bash +cd server && ./mvnw -pl skillhub-app -am \ + -Dtest=CliTokenLifecycleSecurityIntegrationTest#sameRawTokenIsRejectedByAllEndpointsAfterValidUseAndRevocation \ + -Dsurefire.failIfNoSpecifiedTests=false test +``` + +Expected RED: at least one public read replay returns 200 instead of 401. +Restore the filter, confirm its production diff is empty, and rerun the same +command. Expected GREEN: one test passes with all five valid calls and all five +revoked replays exercised. + +- [ ] **Step 5: Run the complete persisted credential matrix** Run: @@ -422,9 +449,11 @@ cd server && ./mvnw -pl skillhub-app -am \ -Dsurefire.failIfNoSpecifiedTests=false test ``` -Expected: PASS for all five endpoints and all absent, valid, revoked, expired, unknown, empty, and malformed credential cases. +Expected: PASS for all five endpoints and all absent, valid, revoked, expired, +unknown, empty, and malformed credential cases, plus the same-token lifecycle +replay. -- [ ] **Step 5: Commit the credential matrix** +- [ ] **Step 6: Commit the credential matrix** ```bash git add server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliTokenLifecycleSecurityIntegrationTest.java @@ -596,21 +625,25 @@ Confirm `git diff -- server/skillhub-domain/src/main/java/com/iflytek/skillhub/d Expected: outsider resolve/latest/versioned each PASS with 403; owner resolve PASS with 200. -- [ ] **Step 5: Run the existing search-visibility boundary tests** +- [ ] **Step 5: Persist and verify the PRIVATE search-visibility boundary** -Run the search authorization checks independently as a supplementary 200-with-omission boundary: +Persist a `SkillSearchDocumentEntity` for the same PRIVATE fixture, call the CLI +search endpoint with the valid outsider token through the real +`CliSkillAppService` and `SearchQueryService`, and assert HTTP 200 with the +fixture slug omitted. Run it independently: ```bash cd server ./mvnw -pl skillhub-app -am \ - -Dtest='PostgresFullTextQueryServiceTest#anonymousSearchSqlShouldOnlyReadPublicActiveVisibleNonArchivedSkills' \ - -Dsurefire.failIfNoSpecifiedTests=false test -./mvnw -pl skillhub-app -am \ - -Dtest='SkillSearchAppServiceTest#search_shouldIncludeMemberNamespacesInVisibilityScope' \ + -Dtest='CliRestrictedReadAuthorizationIntegrationTest#outsiderSearchOmitsPersistedPrivateSkill' \ -Dsurefire.failIfNoSpecifiedTests=false test ``` -Expected: both commands PASS. Record search as a successful response whose result set omits inaccessible PRIVATE skills; it is not a substitute for the real resolve/download 403 assertions above. +Before the GREEN run, temporarily include PRIVATE documents in the search +adapter's visibility predicate and confirm the test fails because the fixture +slug appears. Restore the production predicate and confirm the command passes. +The search omission is not a substitute for the real resolve/download 403 +assertions above. - [ ] **Step 6: Commit the restricted-read tests** @@ -662,7 +695,7 @@ Use this content in section 10.3: | `GET /api/cli/v1/skills/{namespace}/{slug}/download` | 可匿名下载公开资源;提供 Bearer 时必须有效 | 坏凭证返回 401;有效身份无资源权限返回 403 | | `GET /api/cli/v1/skills/{namespace}/{slug}/versions/{version}/download` | 可匿名下载公开资源;提供 Bearer 时必须有效 | 坏凭证返回 401;有效身份无资源权限返回 403 | -公共读接口仅在完全缺少 `Authorization` 头时允许匿名访问。请求一旦携带 Bearer 凭证,空值、格式错误、未知、过期、已撤销、用户缺失或用户禁用均由共享认证过滤器返回 401。身份已验证但 token scope 或资源可见性不足时返回 403;服务端不向客户端区分 token 不存在、过期或已撤销。 +共享 API token 过滤器只识别 Bearer scheme。公共读接口在未提供可识别的 Bearer 凭证时允许匿名访问:这既包括完全缺少 `Authorization` 头,也包括 Basic 或其他非 Bearer scheme;`whoami` 因自身要求认证,在这些情况下仍返回 401。请求一旦使用 Bearer scheme,空值、格式错误、未知、过期、已撤销、用户缺失或用户禁用均由共享认证过滤器返回 401,不能降级为匿名身份。身份已验证但 token scope 或资源可见性不足时返回 403;服务端不向客户端区分 token 不存在、过期或已撤销。 ``` - [ ] **Step 2: Create the complete OpenAPI 3.0 document** @@ -676,9 +709,11 @@ info: version: 1.0.0 description: >- Authentication contract for CLI identity and public skill reads. Public read - operations permit a request with no Authorization header, but any supplied - Bearer credential must be valid; malformed, unknown, expired, or revoked - credentials return HTTP 401 and never fall back to anonymous access. + operations treat a request with no recognized Bearer credential as anonymous, + including an absent Authorization header or an unsupported scheme such as + Basic. Once the Bearer scheme is used, the credential must be valid; + malformed, unknown, expired, or revoked Bearer credentials return HTTP 401 + and never fall back to anonymous access. servers: - url: / tags: @@ -706,7 +741,7 @@ paths: tags: [CLI Skills] summary: Search CLI-installable skills operationId: cliSearchSkills - description: No Authorization header uses anonymous public visibility. A supplied invalid Bearer credential returns 401. + description: An absent Bearer credential, including an unsupported non-Bearer Authorization scheme, uses anonymous public visibility. A supplied invalid Bearer credential returns 401. security: - {} - bearerAuth: [] @@ -890,13 +925,13 @@ components: schemas: Envelope: type: object - required: [code, msg, timestamp] + required: [code, msg, data, timestamp, requestId] properties: code: {type: integer, format: int32} msg: {type: string} data: {type: object, nullable: true} timestamp: {type: string, format: date-time} - requestId: {type: string, nullable: true} + requestId: {type: string, example: req-123} ErrorEnvelope: allOf: - $ref: '#/components/schemas/Envelope' diff --git a/docs/superpowers/specs/2026-07-28-revoked-token-validation-design.md b/docs/superpowers/specs/2026-07-28-revoked-token-validation-design.md index 43fc316a..e0116d9a 100644 --- a/docs/superpowers/specs/2026-07-28-revoked-token-validation-design.md +++ b/docs/superpowers/specs/2026-07-28-revoked-token-validation-design.md @@ -4,10 +4,11 @@ Prove and preserve fail-closed API-token behavior across the CLI API using a real persisted token lifecycle. Invalid Bearer credentials must return HTTP -401 before endpoint business logic runs, while requests without an -`Authorization` header retain the existing anonymous-public-read contract and -valid credentials without sufficient authorization continue to return HTTP -403. +401 before endpoint business logic runs, while requests without a recognized +Bearer credential retain the existing anonymous-public-read contract. This +includes an absent `Authorization` header and unsupported schemes such as +Basic. Valid credentials without sufficient authorization continue to return +HTTP 403. ## Scope @@ -100,7 +101,9 @@ source-code conclusion is accepted. ## Architecture `ApiTokenAuthenticationFilter` remains the single Bearer-authentication entry -point. Controllers must not duplicate token parsing or lifecycle checks. +point. It ignores Basic and other non-Bearer schemes, which therefore reach +public read routes as anonymous requests; controllers must not duplicate token +parsing or lifecycle checks. The regression test will boot the Spring application with MockMvc, real `ApiTokenService`, real `ApiTokenRepository`, and real user persistence. CLI @@ -150,6 +153,7 @@ arguments and assertions for every credential state. | Credential state | `whoami` | Public `search` | Public `resolve` | Public latest download | Public versioned download | Meaning | |---|---:|---:|---:|---:|---:|---| | No `Authorization` header | 401 | 200 | 200 | Existing 200/302 success | Existing 200/302 success | Anonymous access is preserved only where already public | +| Basic or another non-Bearer scheme | 401 | 200 | 200 | Existing 200/302 success | Existing 200/302 success | Unsupported schemes are not treated as API-token attempts | | Valid active token | 200 | 200 | 200 | Existing 200/302 success | Existing 200/302 success | Principal and roles/scopes are projected | | Revoked token | 401 | 401 | 401 | 401 | 401 | Credential cannot degrade to anonymous | | Expired token | 401 | 401 | 401 | 401 | 401 | Credential cannot degrade to anonymous | From 4fdc7e3dc5c63b786d45ed1004b79c0450c5add1 Mon Sep 17 00:00:00 2001 From: FenjuFu <92919259+FenjuFu@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:57:35 +0800 Subject: [PATCH 62/81] fix(publish): delete review tasks of any status when replacing a version (#601) * fix(publish): delete review tasks of any status when replacing a version Re-uploading a rejected version under the same version number returned HTTP 500. deleteReplaceableVersionArtifacts only removed a PENDING review task, but a rejected version owns a REJECTED one; that row kept a foreign key on the skill_version, so the subsequent delete hit a constraint violation that surfaced as a 500. Delete every review task attached to the version instead. Signed-off-by: FenjuFu <92919259+FenjuFu@users.noreply.github.com> * test(publish): drop the spring-test dependency from the new test skillhub-domain has no spring-test on its test classpath, so ReflectionTestUtils does not resolve there. Use plain JDK reflection for setting the generated id and invoking the private method. Signed-off-by: FenjuFu <92919259+FenjuFu@users.noreply.github.com> * fix(publish): constrain rejected version replacement Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> * test(publish): verify replaced review is deleted Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> * test(e2e): use generated API response types Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --------- Signed-off-by: FenjuFu <92919259+FenjuFu@users.noreply.github.com> Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Co-authored-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- .../skill/service/SkillPublishService.java | 14 ++- .../service/SkillPublishServiceTest.java | 62 ++++++++++++-- web/e2e/helpers/test-data-builder.ts | 61 ++++++++++--- web/e2e/rejected-version-republish.spec.ts | 85 +++++++++++++++++++ 4 files changed, 200 insertions(+), 22 deletions(-) create mode 100644 web/e2e/rejected-version-republish.spec.ts diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java index c204306a..610c8203 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java @@ -62,6 +62,12 @@ public class SkillPublishService { private static final DateTimeFormatter AUTO_VERSION_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd.HHmmss").withZone(ZoneId.systemDefault()); + private static final Set REPLACEABLE_VERSION_STATUSES = Set.of( + SkillVersionStatus.DRAFT, + SkillVersionStatus.SCAN_FAILED, + SkillVersionStatus.UPLOADED, + SkillVersionStatus.REJECTED + ); private static final Logger log = LoggerFactory.getLogger(SkillPublishService.class); public record PublishResult( @@ -566,7 +572,7 @@ public class SkillPublishService { } private void deleteReplaceableVersionArtifacts(Skill skill, SkillVersion version, String namespaceSlug) { - if (version.getStatus() == SkillVersionStatus.PUBLISHED) { + if (!REPLACEABLE_VERSION_STATUSES.contains(version.getStatus())) { throw new DomainBadRequestException("error.skill.version.exists", version.getVersion()); } @@ -577,8 +583,10 @@ public class SkillPublishService { skillRepository.flush(); } - reviewTaskRepository.findBySkillVersionIdAndStatus(version.getId(), ReviewTaskStatus.PENDING) - .ifPresent(reviewTaskRepository::delete); + // Every review task referencing this version has to go, not just a PENDING one: + // a rejected version still owns a REJECTED task whose foreign key blocks the + // skill_version delete below, which surfaces to the caller as an HTTP 500. + reviewTaskRepository.deleteBySkillVersionIdIn(List.of(version.getId())); List files = skillFileRepository.findByVersionId(version.getId()); List storageKeys = new ArrayList<>(); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java index 73f118fd..a75f971f 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java @@ -260,7 +260,7 @@ class SkillPublishServiceTest { } @Test - void testPublishFromEntries_ShouldReplaceDraftVersionWithSameVersion() throws Exception { + void testPublishFromEntries_ShouldReplaceRejectedVersionWithSameVersion() throws Exception { String namespaceSlug = "test-ns"; String publisherId = "user-100"; String skillMdContent = "---\nname: test-skill\ndescription: Test\nversion: 1.0.0\n---\nBody"; @@ -275,9 +275,9 @@ class SkillPublishServiceTest { Skill skill = new Skill(1L, "test-skill", publisherId, SkillVisibility.PUBLIC); setId(skill, 1L); - SkillVersion draftVersion = new SkillVersion(1L, "1.0.0", publisherId); - draftVersion.setStatus(SkillVersionStatus.DRAFT); - setId(draftVersion, 8L); + SkillVersion rejectedVersion = new SkillVersion(1L, "1.0.0", publisherId); + rejectedVersion.setStatus(SkillVersionStatus.REJECTED); + setId(rejectedVersion, 8L); SkillFile oldFile = new SkillFile(8L, "SKILL.md", (long) skillMdContent.length(), "text/markdown", "abc", "skills/1/8/SKILL.md"); when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); @@ -288,7 +288,7 @@ class SkillPublishServiceTest { when(skillRepository.findByNamespaceIdAndSlug(any(), eq("test-skill"))).thenReturn(List.of(skill)); when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(any(), eq("test-skill"), eq(publisherId))).thenReturn(Optional.of(skill)); when(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PENDING_REVIEW)).thenReturn(List.of()); - when(skillVersionRepository.findBySkillIdAndVersion(1L, "1.0.0")).thenReturn(Optional.of(draftVersion)); + when(skillVersionRepository.findBySkillIdAndVersion(1L, "1.0.0")).thenReturn(Optional.of(rejectedVersion)); when(skillFileRepository.findByVersionId(8L)).thenReturn(List.of(oldFile)); when(skillVersionRepository.save(any(SkillVersion.class))).thenAnswer(invocation -> { SkillVersion saved = invocation.getArgument(0); @@ -309,10 +309,60 @@ class SkillPublishServiceTest { assertEquals("1.0.0", result.version().getVersion()); assertEquals(SkillVersionStatus.PENDING_REVIEW, result.version().getStatus()); + verify(reviewTaskRepository).deleteBySkillVersionIdIn(List.of(8L)); verify(skillFileRepository).deleteByVersionId(8L); - verify(skillVersionRepository).delete(draftVersion); + verify(skillVersionRepository).delete(rejectedVersion); verify(skillVersionRepository).flush(); verify(objectStorageService).deleteObjects(List.of("skills/1/8/SKILL.md", "packages/1/8/bundle.zip")); + + ArgumentCaptor reviewTaskCaptor = ArgumentCaptor.forClass(ReviewTask.class); + verify(reviewTaskRepository).save(reviewTaskCaptor.capture()); + assertEquals(result.version().getId(), reviewTaskCaptor.getValue().getSkillVersionId()); + assertEquals(publisherId, reviewTaskCaptor.getValue().getSubmittedBy()); + } + + @Test + void testPublishFromEntries_ShouldRejectReplacementOfYankedVersion() throws Exception { + String namespaceSlug = "test-ns"; + String publisherId = "user-100"; + String skillMdContent = "---\nname: test-skill\ndescription: Test\nversion: 1.0.0\n---\nBody"; + + PackageEntry skillMd = new PackageEntry("SKILL.md", skillMdContent.getBytes(), skillMdContent.length(), "text/markdown"); + List entries = List.of(skillMd); + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1"); + setId(namespace, 1L); + NamespaceMember member = mock(NamespaceMember.class); + SkillMetadata metadata = new SkillMetadata("test-skill", "Test", "1.0.0", "Body", Map.of()); + + Skill skill = new Skill(1L, "test-skill", publisherId, SkillVisibility.PUBLIC); + setId(skill, 1L); + SkillVersion yankedVersion = new SkillVersion(1L, "1.0.0", publisherId); + yankedVersion.setStatus(SkillVersionStatus.YANKED); + setId(yankedVersion, 8L); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(any(), eq(publisherId))).thenReturn(Optional.of(member)); + when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.pass()); + when(skillMetadataParser.parse(skillMdContent)).thenReturn(metadata); + when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass()); + when(skillRepository.findByNamespaceIdAndSlug(any(), eq("test-skill"))).thenReturn(List.of(skill)); + when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(any(), eq("test-skill"), eq(publisherId))).thenReturn(Optional.of(skill)); + when(skillVersionRepository.findBySkillIdAndVersion(1L, "1.0.0")).thenReturn(Optional.of(yankedVersion)); + + DomainBadRequestException exception = assertThrows(DomainBadRequestException.class, () -> + service.publishFromEntries( + namespaceSlug, + entries, + publisherId, + SkillVisibility.PUBLIC, + Set.of() + )); + + assertEquals("error.skill.version.exists", exception.messageCode()); + verify(reviewTaskRepository, never()).deleteBySkillVersionIdIn(anyList()); + verify(skillVersionRepository, never()).delete(any()); + verify(skillFileRepository, never()).deleteByVersionId(any()); } @Test diff --git a/web/e2e/helpers/test-data-builder.ts b/web/e2e/helpers/test-data-builder.ts index f255ea4a..71cd9ebd 100644 --- a/web/e2e/helpers/test-data-builder.ts +++ b/web/e2e/helpers/test-data-builder.ts @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os' import { execFileSync } from 'node:child_process' import path from 'node:path' import type { APIRequestContext, Page, TestInfo } from '@playwright/test' +import type { components } from '../../src/api/generated/schema' import { csrfHeaders } from './csrf' type CleanupTask = () => Promise @@ -32,14 +33,9 @@ export interface SeededReviewData { skill: SeededSkill } -interface ReviewTaskSummary { - id: number - namespace: string - skillSlug: string - status: string - submittedBy: string - version: string -} +type ReviewTaskResponse = components['schemas']['ReviewTaskResponse'] +type SkillVersionResponse = components['schemas']['SkillVersionResponse'] +type SkillVersionStatus = NonNullable interface NamespaceCandidate { userId: string @@ -463,19 +459,17 @@ export class E2eTestDataBuilder { async waitForPendingReview(namespaceSlug: string, skillSlug: string, version: string): Promise { for (let attempt = 0; attempt < 20; attempt += 1) { try { - const page = await parseEnvelope<{ - items: ReviewTaskSummary[] - }>( + const page = await parseEnvelope( await this.request.get('/api/web/reviews?status=PENDING&page=0&size=100&sortDirection=DESC'), ) - const matched = page.items.find((item) => + const matched = page.items?.find((item) => item.namespace === namespaceSlug && item.skillSlug === skillSlug && item.version === version && item.status === 'PENDING', ) - if (matched) { + if (matched?.id != null) { return matched.id } } catch { @@ -488,6 +482,38 @@ export class E2eTestDataBuilder { throw new Error(`Timed out waiting for pending review ${namespaceSlug}/${skillSlug}@${version}`) } + async waitForVersionStatus( + namespaceSlug: string, + skillSlug: string, + version: string, + expectedStatus: SkillVersionStatus, + ): Promise { + for (let attempt = 0; attempt < 60; attempt += 1) { + try { + const page = await parseEnvelope( + await this.request.get( + `/api/web/skills/${encodeURIComponent(namespaceSlug)}/${encodeURIComponent(skillSlug)}/versions?page=0&size=100`, + ), + ) + + const matched = page.items?.find((item) => + item.version === version && item.status === expectedStatus, + ) + if (matched?.id != null) { + return matched.id + } + } catch { + // Security scanning and version projection can complete asynchronously. + } + + await new Promise((resolve) => setTimeout(resolve, 1_000)) + } + + throw new Error( + `Timed out waiting for ${namespaceSlug}/${skillSlug}@${version} to reach ${expectedStatus}`, + ) + } + async approveReview(reviewTaskId: number, comment = 'Approved by Playwright E2E'): Promise { let lastError: unknown for (let attempt = 0; attempt < 60; attempt += 1) { @@ -512,6 +538,15 @@ export class E2eTestDataBuilder { throw lastError instanceof Error ? lastError : new Error('approveReview timed out') } + async rejectReview(reviewTaskId: number, comment = 'Rejected by Playwright E2E'): Promise { + await parseEnvelope( + await this.request.post(`/api/web/reviews/${reviewTaskId}/reject`, { + data: { comment }, + headers: await csrfHeaders(this.page), + }), + ) + } + async searchNamespaceMemberCandidates(slug: string, search: string): Promise { const query = new URLSearchParams({ search }) return parseEnvelope( diff --git a/web/e2e/rejected-version-republish.spec.ts b/web/e2e/rejected-version-republish.spec.ts new file mode 100644 index 00000000..cbbcada4 --- /dev/null +++ b/web/e2e/rejected-version-republish.spec.ts @@ -0,0 +1,85 @@ +import { expect, test } from '@playwright/test' +import { setEnglishLocale } from './helpers/auth-fixtures' +import { loginWithCredentials, registerSession } from './helpers/session' +import { E2eTestDataBuilder } from './helpers/test-data-builder' + +function getOptionalEnv(name: string): string | undefined { + const value = process.env[name]?.trim() + return value ? value : undefined +} + +function adminCredentials() { + return { + username: getOptionalEnv('E2E_ADMIN_USERNAME') ?? getOptionalEnv('BOOTSTRAP_ADMIN_USERNAME') ?? 'admin', + password: getOptionalEnv('E2E_ADMIN_PASSWORD') ?? getOptionalEnv('BOOTSTRAP_ADMIN_PASSWORD') ?? 'ChangeMe!2026', + } +} + +test.describe('Rejected version replacement (Real API)', () => { + test.describe.configure({ timeout: 150_000 }) + + test.beforeEach(async ({ page }, testInfo) => { + await setEnglishLocale(page) + await registerSession(page, testInfo) + }) + + test('re-publishes the same version after rejection', async ({ page, browser }, testInfo) => { + const publisherBuilder = new E2eTestDataBuilder(page, testInfo) + await publisherBuilder.init() + + const adminContext = await browser.newContext() + const adminPage = await adminContext.newPage() + const adminBuilder = new E2eTestDataBuilder(adminPage, testInfo) + await loginWithCredentials(adminPage, adminCredentials(), testInfo) + await adminBuilder.init() + + try { + const namespace = await publisherBuilder.ensureWritableNamespace() + const skillName = `replace-rejected-${Date.now().toString(36)}` + const firstPublish = await publisherBuilder.publishSkill(namespace.slug, { + name: skillName, + version: '1.0.0', + }) + const rejectedReviewId = await adminBuilder.waitForPendingReview( + namespace.slug, + firstPublish.slug, + firstPublish.version, + ) + await publisherBuilder.waitForVersionStatus( + namespace.slug, + firstPublish.slug, + firstPublish.version, + 'PENDING_REVIEW', + ) + await adminBuilder.rejectReview(rejectedReviewId) + + const replacement = await publisherBuilder.publishSkill(namespace.slug, { + name: skillName, + description: 'Replacement after review rejection', + version: '1.0.0', + }) + const replacementReviewId = await adminBuilder.waitForPendingReview( + namespace.slug, + replacement.slug, + replacement.version, + ) + await publisherBuilder.waitForVersionStatus( + namespace.slug, + replacement.slug, + replacement.version, + 'PENDING_REVIEW', + ) + + expect(replacement.skillId).toBe(firstPublish.skillId) + expect(replacement.version).toBe(firstPublish.version) + expect(replacementReviewId).not.toBe(rejectedReviewId) + + const replacedReviewResponse = await adminPage.request.get(`/api/web/reviews/${rejectedReviewId}`) + expect(replacedReviewResponse.status()).toBe(404) + } finally { + await adminBuilder.cleanup() + await adminContext.close() + await publisherBuilder.cleanup() + } + }) +}) From 5012b31af2d042ca18df2e3ec128f706ecd43e26 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Tue, 28 Jul 2026 15:22:11 +0800 Subject: [PATCH 63/81] test(auth): cover CLI session fallback (#605) Signed-off-by: dongmucat <1127093059@qq.com> --- docs/03-authentication-design.md | 14 +- docs/api/authentication.openapi.yaml | 38 ++-- .../2026-07-28-revoked-token-validation.md | 166 +++++++++++++--- ...6-07-28-revoked-token-validation-design.md | 64 +++--- ...ictedReadAuthorizationIntegrationTest.java | 71 +++++-- ...TokenLifecycleSecurityIntegrationTest.java | 183 ++++++++++++++++-- 6 files changed, 432 insertions(+), 104 deletions(-) diff --git a/docs/03-authentication-design.md b/docs/03-authentication-design.md index e28f10af..7cac3b82 100644 --- a/docs/03-authentication-design.md +++ b/docs/03-authentication-design.md @@ -377,7 +377,7 @@ API Token 仍保留,但定位从“CLI 唯一认证方式”调整为“平台 - 用途:自动化脚本、兼容层调用、手工 Token 管理、后续系统集成 - 存储:只存 SHA-256 哈希,明文只展示一次 - 校验:从 `Authorization: Bearer ` 提取 → 哈希比对 → 加载关联用户 → 检查用户状态 -- 失败闭合:共享认证过滤器只识别 Bearer scheme;公共读接口在未提供可识别的 Bearer 凭证时按匿名访问处理(包括缺少 `Authorization` 头,以及 Basic 或其他非 Bearer scheme)。请求一旦使用 Bearer scheme,空值、格式错误、未知、过期、已吊销、用户缺失或用户禁用均返回 401,不能回退为匿名访问 +- 失败闭合与身份优先级:共享认证过滤器只识别 Bearer scheme。有效 Bearer 覆盖已加载的 Web Session 身份;Bearer 为空、格式错误、未知、过期、已吊销、用户缺失或用户禁用时立即返回 401,即使存在有效 Session 也不得回退。缺少 `Authorization` 头或使用 Basic/其他非 Bearer scheme 时保留有效 Session;若无 Session,公共读接口按匿名访问,`whoami` 返回 401 - 作用域:`skill:read`, `skill:publish`, `skill:delete`, `token:manage` > **一期作用域说明(非最小权限)**:一期 Token 作用域为粗粒度动作级别,不与 namespace 绑定。Token 继承用户的全部权限——如果用户是某个 namespace 的 MEMBER,则该用户的任何 Token(只要包含 `skill:publish` scope)都可以向该 namespace 发布技能。这是有意的一期简化,不满足最小权限原则。后续版本计划引入 namespace 级别的 Token 作用域限定(如 `namespace:ai-team:skill:publish`),或通过 `api_token_scope` 子表实现 Token 与 namespace 的绑定。 @@ -623,13 +623,13 @@ window.location.href = '/oauth2/authorization/github' | 接口 | 凭证规则 | 授权与错误语义 | |------|---------|---------------| -| `GET /api/cli/v1/auth/whoami` | 必须提供有效 Bearer Token | 缺失、未知、过期、撤销或 malformed token 返回 401 | -| `GET /api/cli/v1/skills/search` | 可匿名;提供 Bearer 时必须有效 | 匿名仅返回公开可安装 skill;坏凭证返回 401,不得降级匿名 | -| `GET /api/cli/v1/skills/{namespace}/{slug}/resolve` | 可匿名读取公开资源;提供 Bearer 时必须有效 | 坏凭证返回 401;有效身份无资源权限返回 403 | -| `GET /api/cli/v1/skills/{namespace}/{slug}/download` | 可匿名下载公开资源;提供 Bearer 时必须有效 | 坏凭证返回 401;有效身份无资源权限返回 403 | -| `GET /api/cli/v1/skills/{namespace}/{slug}/versions/{version}/download` | 可匿名下载公开资源;提供 Bearer 时必须有效 | 坏凭证返回 401;有效身份无资源权限返回 403 | +| `GET /api/cli/v1/auth/whoami` | 有效 Web Session 或有效 Bearer Token | 无有效身份返回 401;坏 Bearer 即使存在 Session 也返回 401 | +| `GET /api/cli/v1/skills/search` | Session 可用;无 Session 时可匿名;提供 Bearer 时必须有效 | 匿名仅返回公开可安装 skill;有效 Bearer 覆盖 Session;坏 Bearer 返回 401,不得降级 | +| `GET /api/cli/v1/skills/{namespace}/{slug}/resolve` | Session 可用;无 Session 时可匿名读取公开资源;提供 Bearer 时必须有效 | 有效 Bearer 覆盖 Session;坏 Bearer 返回 401;有效身份无资源权限返回 403 | +| `GET /api/cli/v1/skills/{namespace}/{slug}/download` | Session 可用;无 Session 时可匿名下载公开资源;提供 Bearer 时必须有效 | 有效 Bearer 覆盖 Session;坏 Bearer 返回 401;有效身份无资源权限返回 403 | +| `GET /api/cli/v1/skills/{namespace}/{slug}/versions/{version}/download` | Session 可用;无 Session 时可匿名下载公开资源;提供 Bearer 时必须有效 | 有效 Bearer 覆盖 Session;坏 Bearer 返回 401;有效身份无资源权限返回 403 | -共享 API token 过滤器只识别 Bearer scheme。公共读接口在未提供可识别的 Bearer 凭证时允许匿名访问:这既包括完全缺少 `Authorization` 头,也包括 Basic 或其他非 Bearer scheme;`whoami` 因自身要求认证,在这些情况下仍返回 401。请求一旦使用 Bearer scheme,空值、格式错误、未知、过期、已撤销、用户缺失或用户禁用均由共享认证过滤器返回 401,不能降级为匿名身份。身份已验证但 token scope 或资源可见性不足时返回 403;服务端不向客户端区分 token 不存在、过期或已撤销。 +Spring Security 先加载 Web Session 身份,共享 API token 过滤器随后只处理 Bearer scheme。有效 Bearer 会覆盖 Session,确保请求使用 token 的用户、角色与 scope;Bearer 为空、格式错误、未知、过期、已撤销、用户缺失或用户禁用时,过滤器清除当前身份并立即返回 401,不能回退到 Session 或匿名身份。完全缺少 `Authorization` 头或使用 Basic/其他非 Bearer scheme 时,过滤器不改变已有 Session;如果 Session 也不存在,公共读接口按匿名身份执行,而 `whoami` 返回 401。身份已验证但 token scope 或资源可见性不足时返回 403;服务端不向客户端区分 token 不存在、过期或已撤销。`whoami.email` 字段始终存在,但没有可用邮箱时值为 `null`。 ### 10.4 Admin API diff --git a/docs/api/authentication.openapi.yaml b/docs/api/authentication.openapi.yaml index 4fafac22..e50788ff 100644 --- a/docs/api/authentication.openapi.yaml +++ b/docs/api/authentication.openapi.yaml @@ -3,12 +3,13 @@ info: title: SkillHub CLI Authentication API version: 1.0.0 description: >- - Authentication contract for CLI identity and public skill reads. Public read - operations treat a request with no recognized Bearer credential as anonymous, - including an absent Authorization header or an unsupported scheme such as - Basic. Once the Bearer scheme is used, the credential must be valid; - malformed, unknown, expired, or revoked Bearer credentials return HTTP 401 - and never fall back to anonymous access. + Authentication contract for CLI identity and public skill reads. A valid + Bearer credential overrides a Web Session identity. Once the Bearer scheme + is used, the credential must be valid: empty, malformed, unknown, expired, + or revoked Bearer credentials return HTTP 401 and never fall back to the + Session or anonymous access. An absent Authorization header or an + unsupported scheme such as Basic preserves a valid Web Session. Without a + Session, public reads use anonymous visibility and whoami returns HTTP 401. servers: - url: / tags: @@ -20,8 +21,10 @@ paths: tags: [CLI Authentication] summary: Return the current CLI identity operationId: cliWhoAmI + description: Requires a valid Bearer credential or Web Session. Bearer takes priority over Session; invalid Bearer returns 401 without Session fallback. An absent or non-Bearer Authorization header preserves Session, but returns 401 when no Session exists. security: - bearerAuth: [] + - sessionAuth: [] responses: '200': description: Authenticated CLI identity @@ -36,9 +39,10 @@ paths: tags: [CLI Skills] summary: Search CLI-installable skills operationId: cliSearchSkills - description: An absent Bearer credential, including an unsupported non-Bearer Authorization scheme, uses anonymous public visibility. A supplied invalid Bearer credential returns 401. + description: Valid Bearer overrides Web Session. Invalid Bearer returns 401 without Session fallback. An absent or non-Bearer Authorization header preserves Session; without Session, the request uses anonymous public visibility. security: - {} + - sessionAuth: [] - bearerAuth: [] parameters: - name: q @@ -67,9 +71,10 @@ paths: tags: [CLI Skills] summary: Resolve a skill version operationId: cliResolveSkill - description: An absent Bearer credential, including an unsupported non-Bearer Authorization scheme, uses anonymous public visibility. A supplied invalid Bearer credential returns 401. + description: Valid Bearer overrides Web Session. Invalid Bearer returns 401 without Session fallback. An absent or non-Bearer Authorization header preserves Session; without Session, the request uses anonymous public visibility. security: - {} + - sessionAuth: [] - bearerAuth: [] parameters: - $ref: '#/components/parameters/Namespace' @@ -98,9 +103,10 @@ paths: tags: [CLI Skills] summary: Download the latest installable skill version operationId: cliDownloadLatestSkill - description: An absent Bearer credential, including an unsupported non-Bearer Authorization scheme, uses anonymous public visibility. A supplied invalid Bearer credential returns 401. + description: Valid Bearer overrides Web Session. Invalid Bearer returns 401 without Session fallback. An absent or non-Bearer Authorization header preserves Session; without Session, the request uses anonymous public visibility. security: - {} + - sessionAuth: [] - bearerAuth: [] parameters: - $ref: '#/components/parameters/Namespace' @@ -123,9 +129,10 @@ paths: tags: [CLI Skills] summary: Download an exact installable skill version operationId: cliDownloadSkillVersion - description: An absent Bearer credential, including an unsupported non-Bearer Authorization scheme, uses anonymous public visibility. A supplied invalid Bearer credential returns 401. + description: Valid Bearer overrides Web Session. Invalid Bearer returns 401 without Session fallback. An absent or non-Bearer Authorization header preserves Session; without Session, the request uses anonymous public visibility. security: - {} + - sessionAuth: [] - bearerAuth: [] parameters: - $ref: '#/components/parameters/Namespace' @@ -150,7 +157,12 @@ components: type: http scheme: bearer bearerFormat: SkillHub API token - description: API token issued by SkillHub. Invalid lifecycle states all return the same 401 response. + description: API token issued by SkillHub. A valid token overrides Web Session; invalid lifecycle states all return the same 401 response without Session fallback. + sessionAuth: + type: apiKey + in: cookie + name: SESSION + description: Spring Session browser identity. It is preserved when Authorization is absent or uses a non-Bearer scheme, and is overridden by a valid Bearer token. parameters: Namespace: name: namespace @@ -194,7 +206,7 @@ components: application/json: schema: {$ref: '#/components/schemas/ErrorEnvelope'} Unauthorized: - description: Bearer credential is missing where required, malformed, unknown, expired, revoked, or belongs to an unavailable user. + description: No valid supported identity is present where required, or the Bearer credential is empty, malformed, unknown, expired, revoked, or belongs to an unavailable user. Invalid Bearer never falls back to Web Session. content: application/json: schema: {$ref: '#/components/schemas/ErrorEnvelope'} @@ -249,7 +261,7 @@ components: properties: handle: {type: string, example: user-123} displayName: {type: string, example: CLI User} - email: {type: string, format: email, example: cli@example.com} + email: {type: string, format: email, nullable: true, example: cli@example.com, description: Email address when available; the required field is null when the account has no email.} CliSearchEnvelope: allOf: - $ref: '#/components/schemas/Envelope' diff --git a/docs/superpowers/plans/2026-07-28-revoked-token-validation.md b/docs/superpowers/plans/2026-07-28-revoked-token-validation.md index 8deb0553..2888470d 100644 --- a/docs/superpowers/plans/2026-07-28-revoked-token-validation.md +++ b/docs/superpowers/plans/2026-07-28-revoked-token-validation.md @@ -2,9 +2,9 @@ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. -**Goal:** Lock the CLI API's fail-closed Bearer behavior with persisted token lifecycle tests, prove 401/403 semantics on every affected read endpoint, publish the authentication OpenAPI contract, and reconcile source behavior with the actual runtime artifact. +**Goal:** Lock the CLI API's fail-closed Bearer behavior and Web Session fallback with persisted lifecycle and mixed-credential tests, prove 401/403 semantics on every affected read endpoint, publish the authentication OpenAPI contract, and reconcile source behavior with the actual runtime artifact. -**Architecture:** Keep `ApiTokenAuthenticationFilter` as the sole Bearer authentication entry point. Use one Spring Boot/MockMvc class with real token and user persistence plus deterministic controller-service stubs for the credential-state matrix, and a second Spring Boot/MockMvc class with real query/download authorization and a persisted PRIVATE skill for resource-level 403 checks. Production authentication code remains unchanged unless the unmodified-source matrix reproduces a failure; any such failure stops this plan for systematic root-cause analysis before a minimal fix is planned. +**Architecture:** Keep `ApiTokenAuthenticationFilter` as the sole Bearer authentication entry point while preserving Spring Security's existing Web Session identity. Valid Bearer replaces Session; invalid Bearer fails closed without Session fallback; absent or non-Bearer Authorization preserves Session and otherwise leaves public reads anonymous. Use one Spring Boot/MockMvc class with real token and user persistence plus deterministic controller-service stubs for the credential-state matrix, and a second Spring Boot/MockMvc class with real query/download authorization plus persisted PRIVATE and matching PUBLIC skills for authorization checks. Production authentication code remains unchanged unless the unmodified-source matrix reproduces a failure; any such failure stops this plan for systematic root-cause analysis before a minimal fix is planned. **Tech Stack:** Java 21, Spring Boot 3.2, Spring Security, Spring Data JPA/H2, MockMvc, JUnit 5 parameterized tests, Mockito, OpenAPI 3.0 YAML, Docker/OCI image inspection. @@ -14,7 +14,7 @@ - Create `server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliTokenLifecycleSecurityIntegrationTest.java`: persisted valid/revoked/expired/unknown/empty/malformed credential matrix for each CLI endpoint. - Create `server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliRestrictedReadAuthorizationIntegrationTest.java`: real PRIVATE-skill search omission and read authorization through resolve, latest download, and versioned download. -- Modify `docs/03-authentication-design.md`: current CLI route table and explicit anonymous/401/403 rules. +- Modify `docs/03-authentication-design.md`: current CLI route table, Web Session/Bearer priority, and explicit anonymous/401/403 rules. - Create `docs/api/authentication.openapi.yaml`: OpenAPI 3.0 contract for whoami, search, resolve, latest download, and versioned download. - Do not modify `server/skillhub-auth/src/main/**` unless Task 5 records a failing unmodified-source assertion and a separate systematic-debugging plan amendment identifies the root cause. @@ -689,13 +689,13 @@ Use this content in section 10.3: | 接口 | 凭证规则 | 授权与错误语义 | |------|---------|---------------| -| `GET /api/cli/v1/auth/whoami` | 必须提供有效 Bearer Token | 缺失、未知、过期、撤销或 malformed token 返回 401 | -| `GET /api/cli/v1/skills/search` | 可匿名;提供 Bearer 时必须有效 | 匿名仅返回公开可安装 skill;坏凭证返回 401,不得降级匿名 | -| `GET /api/cli/v1/skills/{namespace}/{slug}/resolve` | 可匿名读取公开资源;提供 Bearer 时必须有效 | 坏凭证返回 401;有效身份无资源权限返回 403 | -| `GET /api/cli/v1/skills/{namespace}/{slug}/download` | 可匿名下载公开资源;提供 Bearer 时必须有效 | 坏凭证返回 401;有效身份无资源权限返回 403 | -| `GET /api/cli/v1/skills/{namespace}/{slug}/versions/{version}/download` | 可匿名下载公开资源;提供 Bearer 时必须有效 | 坏凭证返回 401;有效身份无资源权限返回 403 | +| `GET /api/cli/v1/auth/whoami` | 有效 Web Session 或有效 Bearer Token | 无有效身份返回 401;坏 Bearer 即使存在 Session 也返回 401 | +| `GET /api/cli/v1/skills/search` | Session 可用;无 Session 时可匿名;提供 Bearer 时必须有效 | 匿名仅返回公开可安装 skill;有效 Bearer 覆盖 Session;坏 Bearer 返回 401,不得降级 | +| `GET /api/cli/v1/skills/{namespace}/{slug}/resolve` | Session 可用;无 Session 时可匿名读取公开资源;提供 Bearer 时必须有效 | 有效 Bearer 覆盖 Session;坏 Bearer 返回 401;有效身份无资源权限返回 403 | +| `GET /api/cli/v1/skills/{namespace}/{slug}/download` | Session 可用;无 Session 时可匿名下载公开资源;提供 Bearer 时必须有效 | 有效 Bearer 覆盖 Session;坏 Bearer 返回 401;有效身份无资源权限返回 403 | +| `GET /api/cli/v1/skills/{namespace}/{slug}/versions/{version}/download` | Session 可用;无 Session 时可匿名下载公开资源;提供 Bearer 时必须有效 | 有效 Bearer 覆盖 Session;坏 Bearer 返回 401;有效身份无资源权限返回 403 | -共享 API token 过滤器只识别 Bearer scheme。公共读接口在未提供可识别的 Bearer 凭证时允许匿名访问:这既包括完全缺少 `Authorization` 头,也包括 Basic 或其他非 Bearer scheme;`whoami` 因自身要求认证,在这些情况下仍返回 401。请求一旦使用 Bearer scheme,空值、格式错误、未知、过期、已撤销、用户缺失或用户禁用均由共享认证过滤器返回 401,不能降级为匿名身份。身份已验证但 token scope 或资源可见性不足时返回 403;服务端不向客户端区分 token 不存在、过期或已撤销。 +Spring Security 先加载 Web Session 身份,共享 API token 过滤器随后只处理 Bearer scheme。有效 Bearer 覆盖 Session;坏 Bearer 清除当前身份并立即返回 401,不回退 Session 或匿名。没有 Authorization 或使用 Basic/其他非 Bearer scheme 时保留 Session;如果 Session 也不存在,公共读匿名而 `whoami` 返回 401。身份已验证但 token scope 或资源权限不足时返回 403。`whoami.email` 字段始终存在,没有邮箱时为 `null`。 ``` - [ ] **Step 2: Create the complete OpenAPI 3.0 document** @@ -708,12 +708,11 @@ info: title: SkillHub CLI Authentication API version: 1.0.0 description: >- - Authentication contract for CLI identity and public skill reads. Public read - operations treat a request with no recognized Bearer credential as anonymous, - including an absent Authorization header or an unsupported scheme such as - Basic. Once the Bearer scheme is used, the credential must be valid; - malformed, unknown, expired, or revoked Bearer credentials return HTTP 401 - and never fall back to anonymous access. + Authentication contract for CLI identity and public skill reads. Valid + Bearer overrides Web Session. Invalid Bearer returns HTTP 401 without + Session fallback. An absent Authorization header or unsupported scheme such + as Basic preserves Session; without Session, public reads are anonymous and + whoami returns HTTP 401. servers: - url: / tags: @@ -725,8 +724,10 @@ paths: tags: [CLI Authentication] summary: Return the current CLI identity operationId: cliWhoAmI + description: Valid Bearer overrides Session; invalid Bearer returns 401 without Session fallback. An absent or non-Bearer Authorization header preserves Session. security: - bearerAuth: [] + - sessionAuth: [] responses: '200': description: Authenticated CLI identity @@ -741,9 +742,10 @@ paths: tags: [CLI Skills] summary: Search CLI-installable skills operationId: cliSearchSkills - description: An absent Bearer credential, including an unsupported non-Bearer Authorization scheme, uses anonymous public visibility. A supplied invalid Bearer credential returns 401. + description: Valid Bearer overrides Session; invalid Bearer returns 401 without Session fallback. An absent or non-Bearer header preserves Session, otherwise this route uses anonymous public visibility. security: - {} + - sessionAuth: [] - bearerAuth: [] parameters: - name: q @@ -772,8 +774,10 @@ paths: tags: [CLI Skills] summary: Resolve a skill version operationId: cliResolveSkill + description: Valid Bearer overrides Session; invalid Bearer returns 401 without Session fallback. An absent or non-Bearer header preserves Session, otherwise this route uses anonymous public visibility. security: - {} + - sessionAuth: [] - bearerAuth: [] parameters: - $ref: '#/components/parameters/Namespace' @@ -802,8 +806,10 @@ paths: tags: [CLI Skills] summary: Download the latest installable skill version operationId: cliDownloadLatestSkill + description: Valid Bearer overrides Session; invalid Bearer returns 401 without Session fallback. An absent or non-Bearer header preserves Session, otherwise this route uses anonymous public visibility. security: - {} + - sessionAuth: [] - bearerAuth: [] parameters: - $ref: '#/components/parameters/Namespace' @@ -826,8 +832,10 @@ paths: tags: [CLI Skills] summary: Download an exact installable skill version operationId: cliDownloadSkillVersion + description: Valid Bearer overrides Session; invalid Bearer returns 401 without Session fallback. An absent or non-Bearer header preserves Session, otherwise this route uses anonymous public visibility. security: - {} + - sessionAuth: [] - bearerAuth: [] parameters: - $ref: '#/components/parameters/Namespace' @@ -852,7 +860,12 @@ components: type: http scheme: bearer bearerFormat: SkillHub API token - description: API token issued by SkillHub. Invalid lifecycle states all return the same 401 response. + description: API token issued by SkillHub. Valid Bearer overrides Session; invalid lifecycle states return the same 401 response without Session fallback. + sessionAuth: + type: apiKey + in: cookie + name: SESSION + description: Spring Session browser identity, preserved when Authorization is absent or uses a non-Bearer scheme. parameters: Namespace: name: namespace @@ -896,7 +909,7 @@ components: application/json: schema: {$ref: '#/components/schemas/ErrorEnvelope'} Unauthorized: - description: Bearer credential is missing where required, malformed, unknown, expired, revoked, or belongs to an unavailable user. + description: No valid supported identity is present where required, or the Bearer credential is invalid. Invalid Bearer never falls back to Web Session. content: application/json: schema: {$ref: '#/components/schemas/ErrorEnvelope'} @@ -951,7 +964,7 @@ components: properties: handle: {type: string, example: user-123} displayName: {type: string, example: CLI User} - email: {type: string, format: email, example: cli@example.com} + email: {type: string, format: email, nullable: true, example: cli@example.com} CliSearchEnvelope: allOf: - $ref: '#/components/schemas/Envelope' @@ -1056,7 +1069,111 @@ Expected after revocation: 401 on every endpoint. If behavior differs, preserve If no affected runtime URL, host/replica access, or authorization to create/revoke a test token is available, explicitly escalate to the human owner in the active issue. Name the missing authority and request the exact evidence still required: deployed version, immutable server digest or build SHA, all replica identities, and same-token valid-to-revoked replay. State that repository tests do not close the field contradiction and therefore cannot justify closing the defect. -### Task 8: Quality gates and implementation review handoff +### Task 8: Preserve Web Session fallback and harden the reviewed contracts + +**Files:** +- Modify: `server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliTokenLifecycleSecurityIntegrationTest.java` +- Modify: `server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliRestrictedReadAuthorizationIntegrationTest.java` +- Modify: `docs/03-authentication-design.md` +- Modify: `docs/api/authentication.openapi.yaml` +- Modify: `docs/superpowers/specs/2026-07-28-revoked-token-validation-design.md` + +- [ ] **Step 1: Add the five-endpoint Web Session and mixed-credential matrix** + +Add independent arguments for `whoami`, search, resolve, latest download, and +versioned download. For each endpoint exercise Session-only, Session + Basic, +Basic-only, and Session + valid Bearer. Persist distinct Session and token +users, assert Session identity is retained when Bearer is absent or the scheme +is Basic, assert public reads are anonymous for Basic-only, and assert valid +Bearer identity replaces Session identity. Existing revoked, expired, unknown, +empty, and malformed Bearer cases must attach a real mock HTTP Session and +continue to return the fixed five-field 401 envelope before controller service +logic runs. + +Run a reversible filter mutation that prevents valid Bearer replacement of an +existing Session principal, then run: + +```bash +cd server && ./mvnw -pl skillhub-app -am \ + -Dtest=CliTokenLifecycleSecurityIntegrationTest#sessionAndAuthorizationSchemeMatrix \ + -Dsurefire.failIfNoSpecifiedTests=false test +``` + +Expected RED: Session + valid Bearer exposes the Session user instead of the +token user. Restore production source immediately and rerun the same command. +Expected GREEN: all 20 endpoint/credential arguments pass without a production +source diff. + +- [ ] **Step 2: Lock the nullable whoami email contract** + +Persist an active user whose email is `null`, issue its token through +`ApiTokenService`, call `GET /api/cli/v1/auth/whoami`, and assert the `email` +key is present with a JSON null value inside the standard five-field envelope. + +```bash +cd server && ./mvnw -pl skillhub-app -am \ + -Dtest=CliTokenLifecycleSecurityIntegrationTest#whoamiReturnsNullEmailForPersistedUserWithoutEmail \ + -Dsurefire.failIfNoSpecifiedTests=false test +``` + +Expected: PASS against existing production behavior; this is a response-shape +characterization test. Update `CliWhoAmI.email` in OpenAPI to remain required +while becoming `nullable: true`. + +- [ ] **Step 3: Make PRIVATE search omission a positive and negative proof** + +Use a unique numeric `skillSlug` as `q`, persist an installable PUBLIC skill +whose search document contains the same keyword, and keep the existing +installable PRIVATE skill. Assert the PUBLIC slug is returned and the PRIVATE +slug is omitted for the outsider token. + +```bash +cd server && ./mvnw -pl skillhub-app -am \ + -Dtest=CliRestrictedReadAuthorizationIntegrationTest#outsiderSearchReturnsMatchingPublicSkillAndOmitsPrivateSkill \ + -Dsurefire.failIfNoSpecifiedTests=false test +``` + +Expected RED before the PUBLIC fixture is persisted: the expected PUBLIC slug +is absent. Expected GREEN after the fixture is added: the same non-empty result +contains PUBLIC and omits PRIVATE. + +- [ ] **Step 4: Assert the fixed five-field 403 envelope on every restricted read** + +Replace status/code-only assertions for restricted resolve, latest download, +and versioned download with a shared assertion for exactly `code`, `msg`, +`data`, `timestamp`, and `requestId`; require `code=403`, `data=null`, and +string timestamps/request IDs. Keep the three routes as separate test methods. + +```bash +cd server && ./mvnw -pl skillhub-app -am \ + -Dtest=CliRestrictedReadAuthorizationIntegrationTest#outsiderCannotResolvePrivateSkill,CliRestrictedReadAuthorizationIntegrationTest#outsiderCannotDownloadLatestPrivateSkill,CliRestrictedReadAuthorizationIntegrationTest#outsiderCannotDownloadVersionedPrivateSkill \ + -Dsurefire.failIfNoSpecifiedTests=false test +``` + +Expected: all three pass through the real access-denied path. + +- [ ] **Step 5: Align authentication design and OpenAPI priority rules** + +Document these exact rules: valid Bearer overrides Web Session; any Bearer +attempt that is empty, malformed, unknown, expired, revoked, or tied to an +unavailable user returns 401 without Session fallback; no Authorization header +or a non-Bearer scheme preserves a valid Session; without a Session, public +reads use anonymous visibility and `whoami` returns 401. Add cookie +`sessionAuth` to OpenAPI and list it as an alternative on all five operations. +OpenAPI descriptions must state the precedence because security alternatives +cannot encode it alone. + +- [ ] **Step 6: Confirm the review correction did not change production auth** + +```bash +git diff --name-only origin/main...HEAD +git diff --exit-code origin/main...HEAD -- server/skillhub-auth/src/main server/skillhub-app/src/main +``` + +Expected: only tests and documentation changed; the production-code diff +command exits 0. + +### Task 9: Quality gates and implementation review handoff **Files:** - Verify all changed files; do not create a PR in this stage. @@ -1111,6 +1228,11 @@ Expected: only the approved spec/plan, two test classes, authentication design, Provide the branch, focused commands, complete matrix result, 403 fixture result, docs path, runtime identity/replay evidence or explicit external blocker, and full gate output to the project tester. After tester passes, request structured reviewer/security review. Address any findings on the same branch and rerun affected gates. -- [ ] **Step 7: Report completion without creating a PR** +- [ ] **Step 7: Update the existing single PR and report completion** -Post the implementation result to the active issue thread. Include commit SHAs, endpoint-by-state matrix, RED mutation evidence, GREEN results, quality gates, OpenAPI path, production-code decision, and runtime identity/replay status. Do not create a PR, do not change issue status, and do not merge `main` during this stage. +Commit and push to the existing `fix/auth-revoked-token-validation` branch so +PR #609 updates in place. Post the implementation result to the active issue +thread. Include commit SHAs, endpoint-by-state matrix, RED mutation evidence, +GREEN results, quality gates, OpenAPI path, production-code decision, and +runtime identity/replay status. Do not create a second PR, do not change issue +status, and do not merge `main` during this stage. diff --git a/docs/superpowers/specs/2026-07-28-revoked-token-validation-design.md b/docs/superpowers/specs/2026-07-28-revoked-token-validation-design.md index e0116d9a..1b6d6488 100644 --- a/docs/superpowers/specs/2026-07-28-revoked-token-validation-design.md +++ b/docs/superpowers/specs/2026-07-28-revoked-token-validation-design.md @@ -4,11 +4,12 @@ Prove and preserve fail-closed API-token behavior across the CLI API using a real persisted token lifecycle. Invalid Bearer credentials must return HTTP -401 before endpoint business logic runs, while requests without a recognized -Bearer credential retain the existing anonymous-public-read contract. This -includes an absent `Authorization` header and unsupported schemes such as -Basic. Valid credentials without sufficient authorization continue to return -HTTP 403. +401 before endpoint business logic runs, including when a valid Web Session is +also present. A valid Bearer credential overrides the Session identity. When +Bearer is absent or the Authorization scheme is unsupported, the existing Web +Session identity is preserved; without a valid Session, public reads remain +anonymous and `whoami` returns 401. Valid credentials without sufficient +authorization continue to return HTTP 403. ## Scope @@ -23,8 +24,10 @@ This change covers the following CLI routes: It also covers the authenticated-versus-forbidden boundary on the affected restricted read routes. An existing scope-protected CLI route may provide supplementary scope-filter evidence only. This change does not add endpoints, -change response fields, change token storage, add a database migration, or -change anonymous resource visibility rules. +change runtime response fields, change token storage, add a database migration, +or change anonymous resource visibility rules. The OpenAPI correction marks +the already-nullable `whoami.email` value accurately without changing its JSON +field presence. ## Current-State Finding @@ -101,9 +104,13 @@ source-code conclusion is accepted. ## Architecture `ApiTokenAuthenticationFilter` remains the single Bearer-authentication entry -point. It ignores Basic and other non-Bearer schemes, which therefore reach -public read routes as anonymous requests; controllers must not duplicate token -parsing or lifecycle checks. +point. Spring Security loads an existing Web Session identity before the token +filter runs. A valid Bearer token replaces that identity; an invalid, empty, or +malformed Bearer attempt clears it and returns 401. The filter ignores Basic +and other non-Bearer schemes, preserving the loaded Session identity. If no +Session exists, those schemes reach public reads anonymously and `whoami` +returns 401. Controllers must not duplicate token parsing, Session resolution, +or lifecycle checks. The regression test will boot the Spring application with MockMvc, real `ApiTokenService`, real `ApiTokenRepository`, and real user persistence. CLI @@ -154,12 +161,15 @@ arguments and assertions for every credential state. |---|---:|---:|---:|---:|---:|---| | No `Authorization` header | 401 | 200 | 200 | Existing 200/302 success | Existing 200/302 success | Anonymous access is preserved only where already public | | Basic or another non-Bearer scheme | 401 | 200 | 200 | Existing 200/302 success | Existing 200/302 success | Unsupported schemes are not treated as API-token attempts | +| Valid Web Session, no `Authorization` header | 200 as Session user | 200 as Session user | 200 as Session user | Existing 200/302 as Session user | Existing 200/302 as Session user | Existing browser identity is preserved | +| Valid Web Session + Basic | 200 as Session user | 200 as Session user | 200 as Session user | Existing 200/302 as Session user | Existing 200/302 as Session user | Non-Bearer schemes do not erase Session identity | | Valid active token | 200 | 200 | 200 | Existing 200/302 success | Existing 200/302 success | Principal and roles/scopes are projected | -| Revoked token | 401 | 401 | 401 | 401 | 401 | Credential cannot degrade to anonymous | -| Expired token | 401 | 401 | 401 | 401 | 401 | Credential cannot degrade to anonymous | -| Unknown token | 401 | 401 | 401 | 401 | 401 | Credential cannot degrade to anonymous | -| Empty Bearer credential | 401 | 401 | 401 | 401 | 401 | Empty authentication attempt is rejected before business logic | -| Malformed Bearer credential | 401 | 401 | 401 | 401 | 401 | Malformed authentication attempt is rejected before business logic | +| Valid Web Session + valid active token | 200 as token user | 200 as token user | 200 as token user | Existing 200/302 as token user | Existing 200/302 as token user | Bearer identity overrides Session identity | +| Valid Web Session + revoked token | 401 | 401 | 401 | 401 | 401 | Credential cannot fall back to Session or anonymous | +| Valid Web Session + expired token | 401 | 401 | 401 | 401 | 401 | Credential cannot fall back to Session or anonymous | +| Valid Web Session + unknown token | 401 | 401 | 401 | 401 | 401 | Credential cannot fall back to Session or anonymous | +| Valid Web Session + empty Bearer credential | 401 | 401 | 401 | 401 | 401 | Empty authentication attempt is rejected before business logic | +| Valid Web Session + malformed Bearer credential | 401 | 401 | 401 | 401 | 401 | Malformed authentication attempt is rejected before business logic | The authorization row uses a persisted PRIVATE or NAMESPACE_ONLY fixture and the real read-authorization path: @@ -190,13 +200,14 @@ evidence for the API-token scope filter only. Two documentation updates are required: 1. Update `docs/03-authentication-design.md` so the CLI API section uses the - current `/api/cli/v1/...` routes and explicitly states the 401/403 and - anonymous-access boundary. + current `/api/cli/v1/...` routes and explicitly states Bearer-over-Session + priority, Session fallback, and the anonymous/401/403 boundary. 2. Add `docs/api/authentication.openapi.yaml` using OpenAPI 3.0. The document - must define Bearer authentication, all affected paths, query/path - parameters, success schemas, the common response envelope, HTTP 401 and 403 - responses, examples, and the rule that absent credentials are allowed only - on existing public-read routes. + must define Bearer and Web Session authentication, all affected paths, + query/path parameters, success schemas, the common response envelope, HTTP + 401 and 403 responses, examples, credential priority, and the rule that + requests without either identity are allowed only on existing public-read + routes. `CliWhoAmI.email` remains required but is nullable. No controller signature or response schema changes are planned. Therefore the generated `web/src/api/generated/schema.d.ts` should remain unchanged; if a @@ -217,7 +228,12 @@ steps rather than collapsing them into one generic download case: the real read-authorization path to prove 403 for restricted `resolve`, latest download, and versioned download and success for an authorized user. 6. Update the authentication design and OpenAPI contract. -7. Identify the published/running image and replay the valid-to-revoked token +7. Exercise Session-only, Session + Basic, Basic-only, and Session + valid or + invalid Bearer independently on all five endpoints; latest and versioned + download remain separate cases. +8. Prove PRIVATE search omission with a non-empty same-keyword PUBLIC result + and assert the fixed five-field 403 envelope on each restricted read. +9. Identify the published/running image and replay the valid-to-revoked token lifecycle against that exact digest, or record the external access blocker without treating the field contradiction as resolved. @@ -247,8 +263,8 @@ Verification proceeds in this order: 10. Replay the same valid-to-revoked token lifecycle against the identified runtime and record endpoint-level status, request ID, and replica evidence, keeping latest and versioned download results separate. -11. Perform structured security and code review before opening the single final - pull request. +11. Perform structured security and code review before updating the existing + single final pull request. ## Delivery Constraints diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliRestrictedReadAuthorizationIntegrationTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliRestrictedReadAuthorizationIntegrationTest.java index 5a29cffa..8f4498ea 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliRestrictedReadAuthorizationIntegrationTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliRestrictedReadAuthorizationIntegrationTest.java @@ -28,6 +28,7 @@ import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilde import static org.hamcrest.Matchers.aMapWithSize; import static org.hamcrest.Matchers.hasItem; import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.nullValue; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -47,6 +48,7 @@ class CliRestrictedReadAuthorizationIntegrationTest { private String namespaceSlug; private String skillSlug; + private String publicSkillSlug; private String version; private String ownerToken; private String outsiderToken; @@ -57,7 +59,8 @@ class CliRestrictedReadAuthorizationIntegrationTest { String ownerId = "private-owner-" + suffix; String outsiderId = "private-outsider-" + suffix; namespaceSlug = "private-ns-" + suffix; - skillSlug = "private-skill-" + suffix; + skillSlug = Long.toUnsignedString(UUID.randomUUID().getMostSignificantBits()); + publicSkillSlug = "public-skill-" + suffix; version = "1.0.0"; userAccountRepository.save(new UserAccount( @@ -94,45 +97,77 @@ class CliRestrictedReadAuthorizationIntegrationTest { "", SkillVisibility.PRIVATE.name(), skill.getStatus().name())); + + Skill publicSkill = skillRepository.save(new Skill( + namespace.getId(), publicSkillSlug, ownerId, SkillVisibility.PUBLIC)); + SkillVersion publicPublished = new SkillVersion(publicSkill.getId(), version, ownerId); + publicPublished.setStatus(SkillVersionStatus.PUBLISHED); + publicPublished.setPublishedAt(Instant.parse("2026-07-28T00:00:00Z")); + publicPublished.setDownloadReady(true); + publicPublished = skillVersionRepository.save(publicPublished); + publicSkill.setLatestVersionId(publicPublished.getId()); + skillRepository.save(publicSkill); + skillRepository.flush(); + skillVersionRepository.flush(); + skillSearchDocumentRepository.saveAndFlush(new SkillSearchDocumentEntity( + publicSkill.getId(), + namespace.getId(), + namespaceSlug, + ownerId, + skillSlug, + "Public match for " + publicSkillSlug, + "public", + skillSlug, + "", + SkillVisibility.PUBLIC.name(), + publicSkill.getStatus().name())); } @Test - void outsiderSearchOmitsPersistedPrivateSkill() throws Exception { + void outsiderSearchReturnsMatchingPublicSkillAndOmitsPrivateSkill() throws Exception { mockMvc.perform(withBearer( - get("/api/cli/v1/skills/search").param("limit", "20"), + get("/api/cli/v1/skills/search") + .param("q", skillSlug) + .param("limit", "20"), outsiderToken)) .andExpect(status().isOk()) .andExpect(jsonPath("$", aMapWithSize(5))) .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.items[*].slug", hasItem(publicSkillSlug))) .andExpect(jsonPath("$.data.items[*].slug", not(hasItem(skillSlug)))); } @Test void outsiderCannotResolvePrivateSkill() throws Exception { - mockMvc.perform(withBearer( - get("/api/cli/v1/skills/{namespace}/{slug}/resolve", namespaceSlug, skillSlug), - outsiderToken)) - .andExpect(status().isForbidden()) - .andExpect(jsonPath("$.code").value(403)); + assertForbiddenEnvelope(withBearer( + get("/api/cli/v1/skills/{namespace}/{slug}/resolve", namespaceSlug, skillSlug), + outsiderToken)); } @Test void outsiderCannotDownloadLatestPrivateSkill() throws Exception { - mockMvc.perform(withBearer( - get("/api/cli/v1/skills/{namespace}/{slug}/download", namespaceSlug, skillSlug), - outsiderToken)) - .andExpect(status().isForbidden()) - .andExpect(jsonPath("$.code").value(403)); + assertForbiddenEnvelope(withBearer( + get("/api/cli/v1/skills/{namespace}/{slug}/download", namespaceSlug, skillSlug), + outsiderToken)); } @Test void outsiderCannotDownloadVersionedPrivateSkill() throws Exception { - mockMvc.perform(withBearer( - get("/api/cli/v1/skills/{namespace}/{slug}/versions/{version}/download", - namespaceSlug, skillSlug, version), - outsiderToken)) + assertForbiddenEnvelope(withBearer( + get("/api/cli/v1/skills/{namespace}/{slug}/versions/{version}/download", + namespaceSlug, skillSlug, version), + outsiderToken)); + } + + private void assertForbiddenEnvelope(MockHttpServletRequestBuilder request) throws Exception { + mockMvc.perform(request) .andExpect(status().isForbidden()) - .andExpect(jsonPath("$.code").value(403)); + .andExpect(jsonPath("$", aMapWithSize(5))) + .andExpect(jsonPath("$.code").value(403)) + .andExpect(jsonPath("$.msg").isString()) + .andExpect(jsonPath("$.data").value(nullValue())) + .andExpect(jsonPath("$.timestamp").isString()) + .andExpect(jsonPath("$.requestId").isString()); } @Test diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliTokenLifecycleSecurityIntegrationTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliTokenLifecycleSecurityIntegrationTest.java index e351a7e5..1783bf71 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliTokenLifecycleSecurityIntegrationTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliTokenLifecycleSecurityIntegrationTest.java @@ -8,16 +8,21 @@ import com.iflytek.skillhub.domain.user.UserAccount; import com.iflytek.skillhub.domain.user.UserAccountRepository; import com.iflytek.skillhub.dto.cli.CliResolveResponse; import com.iflytek.skillhub.service.cli.CliSkillAppService; +import jakarta.servlet.http.HttpServletRequest; import java.io.ByteArrayInputStream; import java.time.Clock; import java.time.Instant; import java.util.List; import java.util.Set; import java.util.UUID; +import java.util.stream.Stream; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.ArgumentCaptor; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; @@ -27,20 +32,26 @@ import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.context.HttpSessionSecurityContextRepository; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.ResultActions; +import org.springframework.mock.web.MockHttpSession; import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; import static org.hamcrest.Matchers.aMapWithSize; +import static org.hamcrest.Matchers.hasKey; import static org.hamcrest.Matchers.nullValue; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.clearInvocations; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; -import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; @@ -59,6 +70,21 @@ class CliTokenLifecycleSecurityIntegrationTest { MALFORMED } + private enum EndpointCase { + WHOAMI, + SEARCH, + RESOLVE, + LATEST_DOWNLOAD, + VERSIONED_DOWNLOAD + } + + private enum MixedCredentialState { + SESSION_ONLY, + SESSION_BASIC, + BASIC_ONLY, + SESSION_VALID_BEARER + } + @Autowired MockMvc mockMvc; @Autowired ApiTokenService apiTokenService; @Autowired ApiTokenRepository apiTokenRepository; @@ -67,12 +93,16 @@ class CliTokenLifecycleSecurityIntegrationTest { @MockBean CliSkillAppService cliSkillAppService; private String userId; + private String sessionUserId; @BeforeEach void setUp() { userId = "token-matrix-" + UUID.randomUUID(); + sessionUserId = "session-matrix-" + UUID.randomUUID(); userAccountRepository.save(new UserAccount( userId, "Token Matrix", userId + "@example.com", "")); + userAccountRepository.save(new UserAccount( + sessionUserId, "Session Matrix", sessionUserId + "@example.com", "")); given(cliSkillAppService.search(any(), anyInt(), any(), any())) .willReturn(new CliSkillAppService.CliSearchResult(List.of(), 0, 20)); given(cliSkillAppService.resolve(anyString(), anyString(), any(), any(), any())) @@ -100,13 +130,54 @@ class CliTokenLifecycleSecurityIntegrationTest { .andExpect(jsonPath("$.data.handle").value(userId)); } + @ParameterizedTest(name = "{0} with {1}") + @MethodSource("mixedCredentialMatrix") + void sessionAndAuthorizationSchemeMatrix( + EndpointCase endpoint, + MixedCredentialState credentialState) throws Exception { + clearInvocations(cliSkillAppService); + String expectedUserId = expectedUserId(credentialState); + MockHttpServletRequestBuilder request = withCredentials(requestFor(endpoint), credentialState); + + if (endpoint == EndpointCase.WHOAMI) { + if (credentialState == MixedCredentialState.BASIC_ONLY) { + assertUnauthorizedEnvelope(request); + } else { + assertSuccessEnvelope(request) + .andExpect(jsonPath("$.data.handle").value(expectedUserId)); + } + verifyNoInteractions(cliSkillAppService); + return; + } + + ResultActions result = mockMvc.perform(request).andExpect(status().isOk()); + if (endpoint == EndpointCase.LATEST_DOWNLOAD + || endpoint == EndpointCase.VERSIONED_DOWNLOAD) { + result.andExpect(content().contentType("application/zip")); + } else { + result.andExpect(jsonPath("$", aMapWithSize(5))) + .andExpect(jsonPath("$.code").value(0)); + } + assertProjectedUser(endpoint, expectedUserId); + } + + @Test + void whoamiReturnsNullEmailForPersistedUserWithoutEmail() throws Exception { + String noEmailUserId = "token-no-email-" + UUID.randomUUID(); + userAccountRepository.save(new UserAccount(noEmailUserId, "No Email User", null, "")); + String rawToken = apiTokenService.createToken( + noEmailUserId, "no-email-" + UUID.randomUUID(), "[\"skill:read\"]").rawToken(); + + assertSuccessEnvelope(withBearer(get("/api/cli/v1/auth/whoami"), rawToken)) + .andExpect(jsonPath("$.data", hasKey("email"))) + .andExpect(jsonPath("$.data.email").value(nullValue())); + } + @ParameterizedTest(name = "whoami rejects {0}") @EnumSource(InvalidCredentialState.class) void whoamiRejectsInvalidBearer(InvalidCredentialState state) throws Exception { clearInvocations(cliSkillAppService); - mockMvc.perform(withInvalidBearer(get("/api/cli/v1/auth/whoami"), state)) - .andExpect(status().isUnauthorized()) - .andExpect(jsonPath("$.code").value(401)); + assertUnauthorizedEnvelope(withInvalidBearer(get("/api/cli/v1/auth/whoami"), state)); verifyNoInteractions(cliSkillAppService); } @@ -128,10 +199,8 @@ class CliTokenLifecycleSecurityIntegrationTest { @EnumSource(InvalidCredentialState.class) void searchRejectsInvalidBearer(InvalidCredentialState state) throws Exception { clearInvocations(cliSkillAppService); - mockMvc.perform(withInvalidBearer( - get("/api/cli/v1/skills/search").param("q", "demo").param("limit", "20"), state)) - .andExpect(status().isUnauthorized()) - .andExpect(jsonPath("$.code").value(401)); + assertUnauthorizedEnvelope(withInvalidBearer( + get("/api/cli/v1/skills/search").param("q", "demo").param("limit", "20"), state)); verifyNoInteractions(cliSkillAppService); } @@ -152,9 +221,8 @@ class CliTokenLifecycleSecurityIntegrationTest { @EnumSource(InvalidCredentialState.class) void resolveRejectsInvalidBearer(InvalidCredentialState state) throws Exception { clearInvocations(cliSkillAppService); - mockMvc.perform(withInvalidBearer(get("/api/cli/v1/skills/global/demo/resolve"), state)) - .andExpect(status().isUnauthorized()) - .andExpect(jsonPath("$.code").value(401)); + assertUnauthorizedEnvelope(withInvalidBearer( + get("/api/cli/v1/skills/global/demo/resolve"), state)); verifyNoInteractions(cliSkillAppService); } @@ -176,9 +244,8 @@ class CliTokenLifecycleSecurityIntegrationTest { @EnumSource(InvalidCredentialState.class) void latestDownloadRejectsInvalidBearer(InvalidCredentialState state) throws Exception { clearInvocations(cliSkillAppService); - mockMvc.perform(withInvalidBearer(get("/api/cli/v1/skills/global/demo/download"), state)) - .andExpect(status().isUnauthorized()) - .andExpect(jsonPath("$.code").value(401)); + assertUnauthorizedEnvelope(withInvalidBearer( + get("/api/cli/v1/skills/global/demo/download"), state)); verifyNoInteractions(cliSkillAppService); } @@ -201,10 +268,8 @@ class CliTokenLifecycleSecurityIntegrationTest { @EnumSource(InvalidCredentialState.class) void versionedDownloadRejectsInvalidBearer(InvalidCredentialState state) throws Exception { clearInvocations(cliSkillAppService); - mockMvc.perform(withInvalidBearer( - get("/api/cli/v1/skills/global/demo/versions/1.0.0/download"), state)) - .andExpect(status().isUnauthorized()) - .andExpect(jsonPath("$.code").value(401)); + assertUnauthorizedEnvelope(withInvalidBearer( + get("/api/cli/v1/skills/global/demo/versions/1.0.0/download"), state)); verifyNoInteractions(cliSkillAppService); } @@ -272,7 +337,70 @@ class CliTokenLifecycleSecurityIntegrationTest { InvalidCredentialState state) { return request .header(HttpHeaders.AUTHORIZATION, authorizationHeader(state)) - .with(authentication(sessionAuthentication())); + .session(session()); + } + + private static Stream mixedCredentialMatrix() { + return Stream.of(EndpointCase.values()) + .flatMap(endpoint -> Stream.of(MixedCredentialState.values()) + .map(state -> Arguments.of(endpoint, state))); + } + + private MockHttpServletRequestBuilder requestFor(EndpointCase endpoint) { + return switch (endpoint) { + case WHOAMI -> get("/api/cli/v1/auth/whoami"); + case SEARCH -> get("/api/cli/v1/skills/search") + .param("q", "demo") + .param("limit", "20"); + case RESOLVE -> get("/api/cli/v1/skills/global/demo/resolve"); + case LATEST_DOWNLOAD -> get("/api/cli/v1/skills/global/demo/download"); + case VERSIONED_DOWNLOAD -> + get("/api/cli/v1/skills/global/demo/versions/1.0.0/download"); + }; + } + + private MockHttpServletRequestBuilder withCredentials( + MockHttpServletRequestBuilder request, + MixedCredentialState state) { + return switch (state) { + case SESSION_ONLY -> request.session(session()); + case SESSION_BASIC -> request.session(session()) + .header(HttpHeaders.AUTHORIZATION, "Basic dGVzdDp0ZXN0"); + case BASIC_ONLY -> request.header(HttpHeaders.AUTHORIZATION, "Basic dGVzdDp0ZXN0"); + case SESSION_VALID_BEARER -> withBearer(request.session(session()), createActiveToken()); + }; + } + + private String expectedUserId(MixedCredentialState state) { + return switch (state) { + case SESSION_ONLY, SESSION_BASIC -> sessionUserId; + case BASIC_ONLY -> null; + case SESSION_VALID_BEARER -> userId; + }; + } + + private void assertProjectedUser(EndpointCase endpoint, String expectedUserId) { + if (endpoint == EndpointCase.SEARCH) { + ArgumentCaptor userCaptor = ArgumentCaptor.forClass(String.class); + verify(cliSkillAppService).search(any(), anyInt(), userCaptor.capture(), any()); + assertEquals(expectedUserId, userCaptor.getValue()); + return; + } + if (endpoint == EndpointCase.RESOLVE) { + ArgumentCaptor userCaptor = ArgumentCaptor.forClass(String.class); + verify(cliSkillAppService).resolve(anyString(), anyString(), any(), userCaptor.capture(), any()); + assertEquals(expectedUserId, userCaptor.getValue()); + return; + } + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(HttpServletRequest.class); + if (endpoint == EndpointCase.LATEST_DOWNLOAD) { + verify(cliSkillAppService).downloadLatest(anyString(), anyString(), requestCaptor.capture()); + } else { + verify(cliSkillAppService).downloadVersion( + anyString(), anyString(), anyString(), requestCaptor.capture()); + } + assertEquals(expectedUserId, requestCaptor.getValue().getAttribute("userId")); } private MockHttpServletRequestBuilder withBearer( @@ -310,9 +438,24 @@ class CliTokenLifecycleSecurityIntegrationTest { userId, "matrix-" + UUID.randomUUID(), "[\"skill:read\"]"); } + private MockHttpSession session() { + SecurityContext securityContext = SecurityContextHolder.createEmptyContext(); + securityContext.setAuthentication(sessionAuthentication()); + MockHttpSession session = new MockHttpSession(); + session.setAttribute( + HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, + securityContext); + return session; + } + private UsernamePasswordAuthenticationToken sessionAuthentication() { PlatformPrincipal principal = new PlatformPrincipal( - userId, "Session User", userId + "@example.com", "", "session", Set.of("USER")); + sessionUserId, + "Session User", + sessionUserId + "@example.com", + "", + "session", + Set.of("USER")); return new UsernamePasswordAuthenticationToken(principal, null, List.of()); } From 8435ee1ab16501a70849104c83c8826489fd77bc Mon Sep 17 00:00:00 2001 From: Gal Eyal Date: Mon, 27 Jul 2026 21:47:59 +0300 Subject: [PATCH 64/81] fix(auth): read device-code state via ObjectMapper conversion, not cast The shared RedisTemplate uses GenericJackson2JsonRedisSerializer with the application ObjectMapper, which embeds no type information, so stored DeviceCodeData deserializes as a LinkedHashMap. The typed casts in pollToken and authorizeDeviceCode then throw ClassCastException on every call, making the whole device authorization flow unusable (every poll returns 500). Convert the raw value with ObjectMapper.convertValue instead of casting; this reads both the current untyped map format and any typed format, so no stored-data migration is needed. Adds bean setters to DeviceCodeData for map conversion and regression tests that feed the service exactly what Redis returns in production (untyped maps). Fixes #604 Co-Authored-By: Claude Fable 5 Signed-off-by: Gal Eyal --- .../auth/device/DeviceAuthService.java | 19 +++- .../skillhub/auth/device/DeviceCodeData.java | 2 + .../auth/device/DeviceAuthServiceTest.java | 106 ++++++++++++++++++ 3 files changed, 125 insertions(+), 2 deletions(-) create mode 100644 server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/device/DeviceAuthServiceTest.java diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/device/DeviceAuthService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/device/DeviceAuthService.java index e838c9a8..5061f854 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/device/DeviceAuthService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/device/DeviceAuthService.java @@ -1,5 +1,6 @@ package com.iflytek.skillhub.auth.device; +import com.fasterxml.jackson.databind.ObjectMapper; import com.iflytek.skillhub.auth.token.ApiTokenService; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import org.springframework.beans.factory.annotation.Value; @@ -33,14 +34,17 @@ public class DeviceAuthService { private final RedisTemplate redisTemplate; private final ApiTokenService apiTokenService; + private final ObjectMapper objectMapper; private final String verificationUri; private final SecureRandom random = new SecureRandom(); public DeviceAuthService(RedisTemplate redisTemplate, ApiTokenService apiTokenService, + ObjectMapper objectMapper, @Value("${skillhub.device-auth.verification-uri:/cli/auth}") String verificationUri) { this.redisTemplate = redisTemplate; this.apiTokenService = apiTokenService; + this.objectMapper = objectMapper; this.verificationUri = verificationUri; } @@ -71,7 +75,7 @@ public class DeviceAuthService { throw new DomainBadRequestException("error.deviceAuth.userCode.invalid"); } - DeviceCodeData data = (DeviceCodeData) redisTemplate.opsForValue().get(DEVICE_CODE_PREFIX + deviceCode); + DeviceCodeData data = readDeviceCodeData(deviceCode); if (data == null) { throw new DomainBadRequestException("error.deviceAuth.deviceCode.expired"); } @@ -97,7 +101,7 @@ public class DeviceAuthService { * into an API token exactly once. */ public DeviceTokenResponse pollToken(String deviceCode) { - DeviceCodeData data = (DeviceCodeData) redisTemplate.opsForValue().get(DEVICE_CODE_PREFIX + deviceCode); + DeviceCodeData data = readDeviceCodeData(deviceCode); if (data == null) { throw new DomainBadRequestException("error.deviceAuth.deviceCode.invalid"); @@ -147,6 +151,17 @@ public class DeviceAuthService { } } + /** + * Reads device-code state from Redis. The shared template's JSON value + * serializer carries no type information, so values deserialize as plain + * maps; convert explicitly instead of casting (a direct cast throws + * {@code ClassCastException} on every read). + */ + private DeviceCodeData readDeviceCodeData(String deviceCode) { + Object raw = redisTemplate.opsForValue().get(DEVICE_CODE_PREFIX + deviceCode); + return raw == null ? null : objectMapper.convertValue(raw, DeviceCodeData.class); + } + private String generateRandomDeviceCode() { byte[] bytes = new byte[32]; random.nextBytes(bytes); diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/device/DeviceCodeData.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/device/DeviceCodeData.java index 015896b7..7c44a22d 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/device/DeviceCodeData.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/device/DeviceCodeData.java @@ -19,7 +19,9 @@ public class DeviceCodeData implements Serializable { } public String getDeviceCode() { return deviceCode; } + public void setDeviceCode(String deviceCode) { this.deviceCode = deviceCode; } public String getUserCode() { return userCode; } + public void setUserCode(String userCode) { this.userCode = userCode; } public DeviceCodeStatus getStatus() { return status; } public void setStatus(DeviceCodeStatus status) { this.status = status; } public String getUserId() { return userId; } diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/device/DeviceAuthServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/device/DeviceAuthServiceTest.java new file mode 100644 index 00000000..fca992b2 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/device/DeviceAuthServiceTest.java @@ -0,0 +1,106 @@ +package com.iflytek.skillhub.auth.device; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.auth.token.ApiTokenService; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; + +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.startsWith; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class DeviceAuthServiceTest { + + private static final String DEVICE_CODE = "device-code-1"; + private static final String USER_CODE = "ABCD-2345"; + + @Mock + private RedisTemplate redisTemplate; + + @Mock + private ValueOperations valueOperations; + + @Mock + private ApiTokenService apiTokenService; + + private DeviceAuthService service; + + @BeforeEach + void setUp() { + lenient().when(redisTemplate.opsForValue()).thenReturn(valueOperations); + service = new DeviceAuthService(redisTemplate, apiTokenService, new ObjectMapper(), "/cli/auth"); + } + + /** + * The shared RedisTemplate's JSON serializer keeps no type information, so + * stored DeviceCodeData comes back as a plain map. A typed cast used to + * throw ClassCastException on every poll; the service must convert instead. + */ + private static Map storedDeviceCode(DeviceCodeStatus status, String userId) { + Map raw = new LinkedHashMap<>(); + raw.put("deviceCode", DEVICE_CODE); + raw.put("userCode", USER_CODE); + raw.put("status", status.name()); + raw.put("userId", userId); + return raw; + } + + @Test + void pollTokenReturnsPendingWhenRedisValueIsUntypedMap() { + when(valueOperations.get("device:code:" + DEVICE_CODE)) + .thenReturn(storedDeviceCode(DeviceCodeStatus.PENDING, null)); + + DeviceTokenResponse response = service.pollToken(DEVICE_CODE); + + assertThat(response.error()).isEqualTo("authorization_pending"); + } + + @Test + void pollTokenRedeemsAuthorizedCodeFromUntypedMap() { + when(valueOperations.get("device:code:" + DEVICE_CODE)) + .thenReturn(storedDeviceCode(DeviceCodeStatus.AUTHORIZED, "usr_1")); + when(valueOperations.setIfAbsent(eq("device:claim:" + DEVICE_CODE), any(), anyLong(), any())) + .thenReturn(Boolean.TRUE); + when(apiTokenService.rotateToken(eq("usr_1"), any(), any())) + .thenReturn(new ApiTokenService.TokenCreateResult("sk_test_token", null)); + + DeviceTokenResponse response = service.pollToken(DEVICE_CODE); + + assertThat(response.accessToken()).isEqualTo("sk_test_token"); + } + + @Test + void pollTokenRejectsUnknownDeviceCode() { + when(valueOperations.get("device:code:" + DEVICE_CODE)).thenReturn(null); + + assertThatThrownBy(() -> service.pollToken(DEVICE_CODE)) + .isInstanceOf(DomainBadRequestException.class); + } + + @Test + void authorizeDeviceCodeMarksPendingCodeFromUntypedMap() { + when(valueOperations.get("device:usercode:" + USER_CODE)).thenReturn(DEVICE_CODE); + when(valueOperations.get("device:code:" + DEVICE_CODE)) + .thenReturn(storedDeviceCode(DeviceCodeStatus.PENDING, null)); + + service.authorizeDeviceCode(USER_CODE, "usr_1"); + + verify(valueOperations).set(startsWith("device:code:"), any(DeviceCodeData.class), anyLong(), any()); + } +} From 1d679c526ab498cba1d56a761d20d7028195e8d2 Mon Sep 17 00:00:00 2001 From: "1664940968@qq.com" <1664940968@qq.com> Date: Tue, 28 Jul 2026 16:36:47 +0800 Subject: [PATCH 65/81] fix(auth): recover login page from stale lazy-loaded chunks after logout (#560) * fix(auth): recover from stale login chunks after logout * fix(auth): prevent repeated stale chunk reloads Signed-off-by: ylhu16 --------- Signed-off-by: ylhu16 Co-authored-by: ylhu16 --- web/src/app/router.tsx | 11 ++- .../lib/dynamic-import-recovery.test.ts | 87 +++++++++++++++++++ web/src/shared/lib/dynamic-import-recovery.ts | 48 ++++++++++ 3 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 web/src/shared/lib/dynamic-import-recovery.test.ts create mode 100644 web/src/shared/lib/dynamic-import-recovery.ts diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index c9b3bac8..ec9749a5 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -4,6 +4,7 @@ import { Layout } from './layout' import { getCurrentUser } from '@/api/client' import { RoleGuard } from '@/shared/components/role-guard' import { createRequireAuth } from '@/shared/lib/auth-route' +import { clearDynamicImportReloadGuard, recoverFromDynamicImportError } from '@/shared/lib/dynamic-import-recovery' import { normalizeSearchQuery } from '@/shared/lib/search-query' /** @@ -25,7 +26,15 @@ function createLazyRouteComponent>( // Lazy route modules are wrapped in a uniform suspense fallback so route transitions behave // consistently across public and dashboard pages. const LazyComponent = lazy(async () => { - const module = await importer() + const module = await importer().catch((error) => { + if (recoverFromDynamicImportError(error)) { + return new Promise(() => {}) + } + throw error + }) + // Router resolution can finish before React.lazy imports the route module. Only clear the + // one-time reload guard after the chunk itself has loaded successfully. + clearDynamicImportReloadGuard() return { default: module[exportName] as ComponentType> } }) diff --git a/web/src/shared/lib/dynamic-import-recovery.test.ts b/web/src/shared/lib/dynamic-import-recovery.test.ts new file mode 100644 index 00000000..8cba7774 --- /dev/null +++ b/web/src/shared/lib/dynamic-import-recovery.test.ts @@ -0,0 +1,87 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + clearDynamicImportReloadGuard, + isDynamicImportFetchError, + recoverFromDynamicImportError, +} from './dynamic-import-recovery' + +const values = new Map() +const reload = vi.fn() +const sessionStorage = { + get length() { + return values.size + }, + clear: vi.fn(() => values.clear()), + getItem: vi.fn((key: string) => values.get(key) ?? null), + key: vi.fn((index: number) => Array.from(values.keys())[index] ?? null), + removeItem: vi.fn((key: string) => values.delete(key)), + setItem: vi.fn((key: string, value: string) => values.set(key, value)), +} satisfies Storage + +describe('dynamic import recovery', () => { + beforeEach(() => { + values.clear() + reload.mockClear() + vi.stubGlobal('window', { + location: { reload }, + sessionStorage, + }) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it.each([ + 'Failed to fetch dynamically imported module: /assets/login.js', + 'error loading dynamically imported module: /assets/login.js', + 'Importing a module script failed', + 'ChunkLoadError: Loading chunk 42 failed', + ])('recognizes a stale dynamic import error: %s', (message) => { + expect(isDynamicImportFetchError(new Error(message))).toBe(true) + }) + + it('ignores unrelated errors', () => { + expect(isDynamicImportFetchError(new Error('Request failed with status 500'))).toBe(false) + }) + + it('recognizes errors whose name is ChunkLoadError', () => { + const error = new Error('Loading chunk 42 failed') + error.name = 'ChunkLoadError' + + expect(isDynamicImportFetchError(error)).toBe(true) + }) + + it('reloads only once while the recovery guard is active', () => { + const error = new Error('Failed to fetch dynamically imported module') + + expect(recoverFromDynamicImportError(error)).toBe(true) + expect(recoverFromDynamicImportError(error)).toBe(false) + expect(recoverFromDynamicImportError(error)).toBe(false) + expect(reload).toHaveBeenCalledTimes(1) + }) + + it('allows recovery again after a dynamic import succeeds', () => { + const error = new Error('Failed to fetch dynamically imported module') + + expect(recoverFromDynamicImportError(error)).toBe(true) + clearDynamicImportReloadGuard() + expect(recoverFromDynamicImportError(error)).toBe(true) + expect(reload).toHaveBeenCalledTimes(2) + }) + + it('does not mask the original import error when session storage is unavailable', () => { + vi.stubGlobal('window', { + location: { reload }, + get sessionStorage() { + throw new DOMException('Access denied', 'SecurityError') + }, + }) + + const error = new Error('Failed to fetch dynamically imported module') + + expect(recoverFromDynamicImportError(error)).toBe(false) + expect(() => clearDynamicImportReloadGuard()).not.toThrow() + expect(reload).not.toHaveBeenCalled() + }) +}) diff --git a/web/src/shared/lib/dynamic-import-recovery.ts b/web/src/shared/lib/dynamic-import-recovery.ts new file mode 100644 index 00000000..27c04264 --- /dev/null +++ b/web/src/shared/lib/dynamic-import-recovery.ts @@ -0,0 +1,48 @@ +const RELOAD_GUARD_KEY = 'skillhub:dynamic-import-reload' + +function resolveErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message + } + return String(error ?? '') +} + +export function isDynamicImportFetchError(error: unknown): boolean { + const message = resolveErrorMessage(error) + return (error instanceof Error && error.name === 'ChunkLoadError') + || message.includes('Failed to fetch dynamically imported module') + || message.includes('error loading dynamically imported module') + || message.includes('Importing a module script failed') + || message.includes('ChunkLoadError') +} + +export function recoverFromDynamicImportError(error: unknown): boolean { + if (typeof window === 'undefined' || !isDynamicImportFetchError(error)) { + return false + } + + let sessionStorage: Storage + try { + sessionStorage = window.sessionStorage + if (sessionStorage.getItem(RELOAD_GUARD_KEY) === '1') { + return false + } + sessionStorage.setItem(RELOAD_GUARD_KEY, '1') + } catch { + return false + } + + window.location.reload() + return true +} + +export function clearDynamicImportReloadGuard(): void { + if (typeof window === 'undefined') { + return + } + try { + window.sessionStorage.removeItem(RELOAD_GUARD_KEY) + } catch { + // Session storage can be unavailable in restricted browsing contexts. + } +} From a94073004f8e85607e3b6f4dd8b3d506b3fbf7bf Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Tue, 28 Jul 2026 10:57:34 +0800 Subject: [PATCH 66/81] docs(cli): define namespace error fix plan (#606) Signed-off-by: dongmucat <1127093059@qq.com> --- .../plans/2026-07-28-cli-namespace-errors.md | 307 ++++++++++++++++++ .../2026-07-28-cli-namespace-errors-design.md | 101 ++++++ 2 files changed, 408 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-28-cli-namespace-errors.md create mode 100644 docs/superpowers/specs/2026-07-28-cli-namespace-errors-design.md diff --git a/docs/superpowers/plans/2026-07-28-cli-namespace-errors.md b/docs/superpowers/plans/2026-07-28-cli-namespace-errors.md new file mode 100644 index 00000000..ff19e193 --- /dev/null +++ b/docs/superpowers/plans/2026-07-28-cli-namespace-errors.md @@ -0,0 +1,307 @@ +# CLI Namespace Errors Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make every documented namespace coordinate reach the correct registry path and preserve public server error messages and request IDs without misclassifying all 403 responses as token-scope failures. + +**Architecture:** Extend the shared coordinate parser with a resolver that owns explicit namespace conflict handling, then make install/remove consume it while removing the argument parser's early `global` default. Add one response-error converter inside `SkillHubClient` so JSON endpoints and downloads share safe `msg`/`requestId` extraction while retaining status-based exit codes. + +**Tech Stack:** TypeScript, Bun test/build, cac, npm package tarballs. + +--- + +### Task 1: Establish release artifact baseline + +**Files:** +- Inspect: `cli/package.json` +- Inspect: npm package `@astron-team/skillhub@0.1.9` + +- [ ] **Step 1: Read published metadata and download the package** + +Run: + +```bash +npm view @astron-team/skillhub@0.1.9 version dist.tarball dist.integrity --json +npm pack @astron-team/skillhub@0.1.9 --pack-destination /tmp/skillhub-npm-019-issue-606 --json +``` + +Expected: version `0.1.9`, a tarball with `dist/index.js`, `README.md`, +`LICENSE`, and `package.json`. + +- [ ] **Step 2: Confirm the published bundle contains both bug signatures** + +Run: + +```bash +tar -xOf /tmp/skillhub-npm-019-issue-606/astron-team-skillhub-0.1.9.tgz package/dist/index.js | rg 'indexOf\("--"\)|token may lack required scope' +``` + +Expected: both patterns are present, proving 0.1.9 includes the double-dash +parser but also the misleading 403 fallback. + +### Task 2: Normalize coordinates and reject conflicts + +**Files:** +- Modify: `cli/test/unit/shared/skill-name-parser.test.ts` +- Modify: `cli/src/shared/skill-name-parser.ts` + +- [ ] **Step 1: Replace permissive edge tests with the public coordinate matrix** + +Add table-driven assertions for `my-skill`, `team/my-skill`, +`@team/my-skill`, and `team--my-skill`. Add resolver assertions for an explicit +namespace on a bare slug, a matching coordinate namespace, and a conflicting +namespace. Add malformed-input assertions for empty or incomplete coordinates. + +- [ ] **Step 2: Run the parser test and verify RED** + +Run: + +```bash +cd cli && bun test test/unit/shared/skill-name-parser.test.ts +``` + +Expected: failures for slash forms, malformed input, and the missing resolver. + +- [ ] **Step 3: Implement the minimal parser and resolver** + +Keep `ParsedSkillName` unchanged. Add `resolveSkillName(skillName, +explicitNamespace?)` returning `ParsedSkillName`. It calls one internal parser, +applies `global` only to bare slugs, accepts a matching explicit namespace, and +throws `CliError(..., EXIT.usage)` on malformed input or conflict. + +- [ ] **Step 4: Run the parser test and verify GREEN** + +Run: + +```bash +cd cli && bun test test/unit/shared/skill-name-parser.test.ts +``` + +Expected: all parser tests pass with no warnings. + +### Task 3: Wire the resolver through real CLI parsing + +**Files:** +- Modify: `cli/src/commands/install.ts` +- Modify: `cli/src/commands/remove.ts` +- Modify: `cli/src/index.ts` +- Modify: `cli/test/unit/commands/install-command.test.ts` +- Modify: `cli/test/integration/install-command.test.ts` + +- [ ] **Step 1: Add failing command and integration tests** + +Capture `installSkill` options in the unit test and assert a namespaced +coordinate passes `namespace: 'team'` and `slug: 'my-skill'`. In the integration +test, register a `team/my-skill` fixture and execute: + +```text +skillhub install @team/my-skill --dir --registry --token sk_ok --json +``` + +Assert exit 0, JSON namespace `team`, and fake-registry resolve state +`{ namespace: 'team', slug: 'my-skill' }`. Add a conflicting +`--namespace other` case that exits with usage code 5 before registry access. + +- [ ] **Step 2: Run the focused command tests and verify RED** + +Run: + +```bash +cd cli && bun test test/unit/commands/install-command.test.ts test/integration/install-command.test.ts +``` + +Expected: the namespaced integration case resolves `global` or fails, and the +conflict case does not produce the expected usage error. + +- [ ] **Step 3: Use `resolveSkillName` and remove the cac default** + +Change install/remove to call: + +```typescript +const { namespace, slug } = resolveSkillName(skillNameArg, options.namespace) +``` + +Change install's option declaration to: + +```typescript +.option('--namespace ', 'Namespace for a bare skill slug') +``` + +- [ ] **Step 4: Run the focused command tests and verify GREEN** + +Run the same Bun test command. Expected: all focused command tests pass. + +### Task 4: Preserve structured API errors and request IDs + +**Files:** +- Modify: `cli/test/unit/clients/skillhub-client.test.ts` +- Modify: `cli/test/unit/shared/output.test.ts` +- Modify: `cli/src/clients/skillhub-client.ts` +- Modify: `cli/src/shared/output.ts` + +- [ ] **Step 1: Add failing response and output tests** + +Add client tests for: + +```typescript +Response.json( + { code: 403, msg: 'token has been revoked', requestId: 'req-403' }, + { status: 403 } +) +``` + +Assert message `token has been revoked`, auth exit code, and details containing +`requestId: 'req-403'`. Add 403 tests without `msg`, with invalid JSON, and a +404 with structured fields. Add a download 403 structured-response test. Add a +human output assertion for `Request ID: req-403`. + +- [ ] **Step 2: Run focused tests and verify RED** + +Run: + +```bash +cd cli && bun test test/unit/clients/skillhub-client.test.ts test/unit/shared/output.test.ts +``` + +Expected: structured messages/request IDs are discarded and human output omits +the request ID. + +- [ ] **Step 3: Implement one safe response-error converter** + +Inside `SkillHubClient`, add a private method that reads non-success bodies once, +parses only object-shaped JSON, accepts only non-empty string `msg` and +`requestId`, selects status-specific fallback text and exit codes, and returns a +`CliError`. Use it from both `handleJsonResponse` and `download`. Do not add the +old token-scope hint to 403 errors. Update `renderError` with: + +```typescript +if (typeof cliError.details.requestId === 'string') { + lines.push(`Request ID: ${cliError.details.requestId}`) +} +``` + +- [ ] **Step 4: Run focused tests and verify GREEN** + +Run the same focused Bun test command. Expected: all client/output tests pass. + +### Task 5: Document the public contract and release impact + +**Files:** +- Modify: `cli/src/commands/help.ts` +- Modify: `cli/README.md` +- Create: `cli/CHANGELOG.md` +- Modify: `cli/package.json` +- Modify: `cli/test/integration/help-command.test.ts` + +- [ ] **Step 1: Add a failing help assertion** + +Assert `skillhub help install` includes `@team/my-skill`, +`team/my-skill`, and `team--my-skill` examples. + +- [ ] **Step 2: Run the help test and verify RED** + +Run: + +```bash +cd cli && bun test test/integration/help-command.test.ts +``` + +Expected: the coordinate examples are absent. + +- [ ] **Step 3: Update help, README, and release notes** + +Use `` in install usage. Document all accepted forms and the +same-namespace/conflict rule. Add an Unreleased changelog entry covering +coordinate normalization and structured 403 messages/request IDs. Include +`CHANGELOG.md` in the npm package `files` list. + +- [ ] **Step 4: Run the help test and verify GREEN** + +Run the same Bun test command. Expected: all help tests pass. + +### Task 6: Verify source, build, and packed artifact + +**Files:** +- Verify: all files changed by Tasks 2-5 +- Produce locally: `cli/dist/index.js` +- Produce locally: npm tarball under `/tmp` + +- [ ] **Step 1: Run the complete CLI quality gate** + +Run: + +```bash +cd cli && bun test +cd cli && bun run typecheck +cd cli && bun run lint +cd cli && bun run build +``` + +Expected: every command exits 0 with no errors or warnings. + +- [ ] **Step 2: Pack and inspect the candidate artifact** + +Run: + +```bash +cd cli && npm pack --pack-destination /tmp/skillhub-cli-issue-606 --json +tar -tf /tmp/skillhub-cli-issue-606/astron-team-skillhub-0.1.9.tgz +``` + +Expected: the package contains the built executable, README, changelog, +license, and package metadata. + +- [ ] **Step 3: Run packed-bundle smoke checks** + +Extract the tarball to a temporary directory and run the built executable's +`version` and `help install` commands. Expected: version reports 0.1.9 and help +shows every coordinate form. Run the relevant unit/integration suites against +source to verify request paths and structured errors. + +- [ ] **Step 4: Review the diff and commit** + +Run: + +```bash +git diff --check +git status --short +git diff --stat +``` + +Expected: only CLI implementation/tests/docs and the two planning documents are +changed; generated `cli/dist/index.js` and tarballs are not committed. + +Commit with a conventional message containing the issue ID: + +```bash +git commit -m "fix(cli): normalize namespace coordinates and errors (#606)" +``` + +### Task 7: Review and create the single final PR + +**Files:** +- Review: committed diff against `origin/main` + +- [ ] **Step 1: Run tester and reviewer gates** + +The tester must confirm focused and full CLI gates plus package smoke evidence. +The reviewer must inspect coordinate compatibility, error disclosure, test +coverage, docs, commit metadata, and absence of unrelated changes. Resolve all +blocking findings before continuing. + +- [ ] **Step 2: Push only the assigned branch** + +Run: + +```bash +git push -u origin fix/cli-namespace-errors +``` + +Expected: only the assigned branch is created or updated remotely. + +- [ ] **Step 3: Create one PR linked to the issue** + +Create one PR titled `fix(cli): normalize namespace coordinates and errors` +with `Related to #606` in the body, complete test/package evidence, docs and +risk sections, and no close intent unless the project manager requests it. +Do not merge the PR. diff --git a/docs/superpowers/specs/2026-07-28-cli-namespace-errors-design.md b/docs/superpowers/specs/2026-07-28-cli-namespace-errors-design.md new file mode 100644 index 00000000..fd809ae5 --- /dev/null +++ b/docs/superpowers/specs/2026-07-28-cli-namespace-errors-design.md @@ -0,0 +1,101 @@ +# CLI Namespace Coordinates and Structured Errors Design + +## Context and approval + +GitHub issue #606 reports two coupled CLI 0.1.9 failures: namespaced install +coordinates can silently resolve against `global`, and JSON API responses with +HTTP 403 are always rewritten as a token-scope error. The Multica issue's +technical-analysis comment defines the desired normalization, conflict, error, +documentation, and package-verification behavior. The project manager then +assigned implementation against that design on `fix/cli-namespace-errors`, so +that comment and assignment are the approved design baseline. + +## Considered approaches + +1. Centralize coordinate normalization and structured response errors in the + existing shared parser and client. This is the selected approach because all + commands receive one interpretation and tests can exercise the public + contract without duplicating parsing or status handling. +2. Patch `install` only. This would be smaller, but `remove --remote` already + consumes the same parser and would retain inconsistent behavior. +3. Change the server or documentation to accept only `--namespace`. This would + preserve the CLI bug and contradict documented coordinate forms. + +## Coordinate contract + +The CLI accepts these equivalent inputs: + +| Input | Namespace | Slug | +|---|---|---| +| `my-skill` | `global` | `my-skill` | +| `team/my-skill` | `team` | `my-skill` | +| `@team/my-skill` | `team` | `my-skill` | +| `team--my-skill` | `team` | `my-skill` | +| `my-skill --namespace team` | `team` | `my-skill` | + +The command parser must not inject `global` before coordinate normalization. +`global` is applied only when the input is a bare slug and no explicit +`--namespace` is supplied. If a coordinate and `--namespace` name the same +namespace, the input is accepted. If they differ, the command fails with a +usage error instead of silently choosing either value. + +Structurally incomplete coordinates such as an empty string, `@team`, +`team/`, `/my-skill`, `--my-skill`, and `team--` fail with a usage error. The +normalizer does not add new namespace or slug character restrictions; server +validation remains authoritative for those rules. + +## Error contract + +For unsuccessful JSON API responses, the client reads the body once and only +uses the documented public fields `msg` and `requestId` when they are non-empty +strings. A server `msg` becomes the `CliError` message. A `requestId` is stored +in error details and rendered in both JSON and human-readable CLI output. + +Exit classification remains stable: + +- 401 and 403 use the authentication exit code. +- 404 and other application failures use the generic exit code. +- 502 and 503 use the network exit code. + +When `msg` is absent, invalid, or the body is not JSON, the CLI uses a status- +specific fallback. In particular, the 403 fallback is `access denied` and does +not speculate about token scope. Raw non-JSON bodies and unrecognized fields +are not surfaced, avoiding disclosure of internal response content. Download +responses use the same structured error extraction while retaining their +download-specific fallbacks. + +## Components and data flow + +- `cli/src/shared/skill-name-parser.ts` parses and resolves coordinates, + including explicit namespace conflict detection. +- `cli/src/commands/install.ts` and `cli/src/commands/remove.ts` consume the + resolved coordinate. +- `cli/src/index.ts` leaves `--namespace` unset unless the caller supplies it. +- `cli/src/clients/skillhub-client.ts` converts unsuccessful responses into + structured `CliError` instances. +- `cli/src/shared/output.ts` renders `requestId` for human users; JSON output + already serializes error details. +- `cli/src/commands/help.ts`, `cli/README.md`, and `cli/CHANGELOG.md` document + supported forms, conflicts, and the 403 behavior change. + +## Testing and package verification + +Unit tests cover the coordinate matrix, malformed inputs, matching/conflicting +`--namespace`, structured and unstructured 401/403/404/500/502 responses, and +human request-ID rendering. An integration install test executes the real CLI +argument parser against a fake registry so the former `default: 'global'` +override cannot regress. + +The release check builds and packs the CLI, inspects the tarball file list, and +runs the packed executable for version/help plus focused coordinate/error smoke +tests. The published npm 0.1.9 package is retained only as a comparison +artifact; no package publication or main-branch merge is part of this work. + +## Risks + +- Rejecting ambiguous coordinate/flag combinations is an intentional behavior + tightening and is called out in release notes. +- Server `msg` is treated as the public localized message defined by the API + envelope. Raw body content is deliberately not exposed. +- This change does not publish a new npm version; release owners must verify the + future dist-tag after the approved PR is merged and released. From 95da3cd5e87e8190dd0ea97aeb0e89b326f39cf8 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Tue, 28 Jul 2026 11:10:10 +0800 Subject: [PATCH 67/81] fix(cli): normalize namespace coordinates (#606) Signed-off-by: dongmucat <1127093059@qq.com> --- cli/src/commands/install.ts | 6 +- cli/src/commands/remove.ts | 6 +- cli/src/index.ts | 2 +- cli/src/shared/skill-name-parser.ts | 85 ++++++++-- cli/test/integration/install-command.test.ts | 73 +++++++++ .../unit/commands/install-command.test.ts | 58 +++++++ .../unit/shared/skill-name-parser.test.ts | 150 +++++++++--------- 7 files changed, 279 insertions(+), 101 deletions(-) diff --git a/cli/src/commands/install.ts b/cli/src/commands/install.ts index 0feed791..009b9acc 100644 --- a/cli/src/commands/install.ts +++ b/cli/src/commands/install.ts @@ -5,7 +5,7 @@ import { installSkill } from '../services/install-service' import { resolveInstallTargets } from '../agents/resolver' import { CliError } from '../shared/errors' import { EXIT } from '../shared/constants' -import { parseSkillName } from '../shared/skill-name-parser' +import { resolveSkillName } from '../shared/skill-name-parser' export interface InstallCommandOptions { namespace?: string | undefined @@ -94,9 +94,7 @@ export async function installCommand( const registry = resolveRegistry(options, process.env, await configStore.read()) const token = resolveToken(options, process.env, await credentialsStore.getToken(registry)) - const parsed = parseSkillName(skillNameArg) - const namespace = options.namespace ?? parsed.namespace - const slug = parsed.slug + const { namespace, slug } = resolveSkillName(skillNameArg, options.namespace) const resolveTargets = deps.resolveInstallTargets ?? resolveInstallTargets const targets = await resolveTargets({ diff --git a/cli/src/commands/remove.ts b/cli/src/commands/remove.ts index 4e8543b7..9ffa999e 100644 --- a/cli/src/commands/remove.ts +++ b/cli/src/commands/remove.ts @@ -5,7 +5,7 @@ import { resolveRegistry, resolveToken } from '../services/registry-service' import { removeLocalSkill } from '../services/remove-service' import { CliError } from '../shared/errors' import { EXIT } from '../shared/constants' -import { parseSkillName } from '../shared/skill-name-parser' +import { resolveSkillName } from '../shared/skill-name-parser' export interface RemoveCommandOptions { agent?: string[] | undefined @@ -30,9 +30,7 @@ export async function removeCommand(skillNameArg: string, options: RemoveCommand const credentialsStore = new CredentialsStore() const registry = resolveRegistry(options, process.env, await configStore.read()) - const parsed = parseSkillName(skillNameArg) - const namespace = options.namespace ?? parsed.namespace - const slug = parsed.slug + const { namespace, slug } = resolveSkillName(skillNameArg, options.namespace) if (options.remote) { const token = resolveToken(options, process.env, await credentialsStore.getToken(registry)) diff --git a/cli/src/index.ts b/cli/src/index.ts index 512b5b1b..349d4d43 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -232,7 +232,7 @@ cli cli .command('install ', 'Install a skill locally') - .option('--namespace ', 'Namespace', { default: 'global' }) + .option('--namespace ', 'Namespace for a bare skill slug') .option('--version ', 'Version') .option('--scope ', 'Install scope: user or project') .option('--agent ', 'Agent profile (repeatable)') diff --git a/cli/src/shared/skill-name-parser.ts b/cli/src/shared/skill-name-parser.ts index 05e0662b..575f92fa 100644 --- a/cli/src/shared/skill-name-parser.ts +++ b/cli/src/shared/skill-name-parser.ts @@ -1,27 +1,86 @@ +import { EXIT } from './constants' +import { CliError } from './errors' + export interface ParsedSkillName { namespace: string slug: string } -export function parseSkillName(skillName: string, defaultNamespace = 'global'): ParsedSkillName { - const separatorIndex = skillName.indexOf('--') +interface ParsedCoordinate { + namespace?: string + slug: string +} - if (separatorIndex <= 0) { - return { - namespace: defaultNamespace, - slug: separatorIndex === 0 ? skillName.slice(2) : skillName - } +function invalidCoordinate(skillName: string): CliError { + return new CliError(`invalid skill coordinate "${skillName}"`, EXIT.usage) +} + +function parseSeparatedCoordinate( + skillName: string, + separatorIndex: number, + separatorLength: number, + namespaceStart = 0 +): ParsedCoordinate { + const namespace = skillName.slice(namespaceStart, separatorIndex) + const slug = skillName.slice(separatorIndex + separatorLength) + + if (!namespace || !slug) { + throw invalidCoordinate(skillName) } - if (separatorIndex === skillName.length - 2) { - return { - namespace: defaultNamespace, - slug: skillName.slice(0, -2) + return { namespace, slug } +} + +function parseCoordinate(skillName: string): ParsedCoordinate { + if (!skillName) { + throw invalidCoordinate(skillName) + } + + const slashIndex = skillName.indexOf('/') + + if (skillName.startsWith('@')) { + if (slashIndex < 0) { + throw invalidCoordinate(skillName) } + return parseSeparatedCoordinate(skillName, slashIndex, 1, 1) + } + + const doubleDashIndex = skillName.indexOf('--') + if (slashIndex >= 0 && (doubleDashIndex < 0 || slashIndex < doubleDashIndex)) { + return parseSeparatedCoordinate(skillName, slashIndex, 1) + } + if (doubleDashIndex >= 0) { + return parseSeparatedCoordinate(skillName, doubleDashIndex, 2) + } + + return { slug: skillName } +} + +export function parseSkillName(skillName: string, defaultNamespace = 'global'): ParsedSkillName { + const parsed = parseCoordinate(skillName) + return { + namespace: parsed.namespace ?? defaultNamespace, + slug: parsed.slug + } +} + +/** Resolve a skill coordinate and an optional command-line namespace into one registry identity. */ +export function resolveSkillName(skillName: string, explicitNamespace?: string): ParsedSkillName { + const parsed = parseCoordinate(skillName) + + if ( + parsed.namespace !== undefined && + explicitNamespace !== undefined && + parsed.namespace !== explicitNamespace + ) { + throw new CliError( + `skill coordinate namespace "${parsed.namespace}" conflicts with --namespace "${explicitNamespace}"`, + EXIT.usage + ) } return { - namespace: skillName.slice(0, separatorIndex), - slug: skillName.slice(separatorIndex + 2) + namespace: parsed.namespace ?? explicitNamespace ?? 'global', + slug: parsed.slug } } diff --git a/cli/test/integration/install-command.test.ts b/cli/test/integration/install-command.test.ts index a4ca3bd5..62a12837 100644 --- a/cli/test/integration/install-command.test.ts +++ b/cli/test/integration/install-command.test.ts @@ -324,6 +324,79 @@ describe('install command — P1', () => { expect(meta.version).toBe('2.0.0') }) + test('@namespace/slug resolves the namespaced registry path', async () => { + const env = await createTempHome() + registry = await startFakeRegistry({ + token: 'sk_ok', + skills: [{ + namespace: 'team', + slug: 'my-skill', + version: '1.0.0', + zipBytes: makeSkillZip() + }] + }) + + const installDir = join(env.cwd, 'skills-coordinate') + await mkdir(installDir, { recursive: true }) + + const result = await runCli( + [ + 'install', '@team/my-skill', + '--dir', installDir, + '--registry', registry.url, + '--token', 'sk_ok', + '--json' + ], + { HOME: env.home, USERPROFILE: env.home } + ) + + expect(result.exitCode).toBe(0) + expect(JSON.parse(result.stdout)).toMatchObject({ + ok: true, + namespace: 'team', + slug: 'my-skill' + }) + expect(registry.received.resolve).toMatchObject({ + namespace: 'team', + slug: 'my-skill' + }) + }) + + test('coordinate conflicting with --namespace fails before registry access', async () => { + const env = await createTempHome() + registry = await startFakeRegistry({ + token: 'sk_ok', + skills: [{ + namespace: 'team', + slug: 'my-skill', + version: '1.0.0', + zipBytes: makeSkillZip() + }] + }) + + const installDir = join(env.cwd, 'skills-coordinate-conflict') + await mkdir(installDir, { recursive: true }) + + const result = await runCli( + [ + 'install', '@team/my-skill', + '--namespace', 'other', + '--dir', installDir, + '--registry', registry.url, + '--token', 'sk_ok', + '--json' + ], + { HOME: env.home, USERPROFILE: env.home } + ) + + expect(result.exitCode).toBe(5) + expect(JSON.parse(result.stderr)).toMatchObject({ + ok: false, + exitCode: 5 + }) + expect(registry.received.resolve).toBeNull() + }) + // ------------------------------------------------------------------------- // NOTE: multi-target interactive selection (TTY branch) is not tested here // because Bun.spawn does not support PTY allocation. The interactive path diff --git a/cli/test/unit/commands/install-command.test.ts b/cli/test/unit/commands/install-command.test.ts index 14d46b63..49911588 100644 --- a/cli/test/unit/commands/install-command.test.ts +++ b/cli/test/unit/commands/install-command.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from 'bun:test' import { CliError } from '../../../src/shared/errors' +import { EXIT } from '../../../src/shared/constants' import { computeStrictIsTTY, installCommand, @@ -136,6 +137,63 @@ describe('installCommand dependency injection', () => { return async () => ({ installed: [{ agent: 'codex', dir: '/home/u/.codex/skills/foo' }] }) } + function fakeResolveInstallTargets(): NonNullable { + return async () => [{ + agent: 'codex', + rootDir: '/home/u/.codex/skills', + scope: 'user', + source: 'explicit' + }] as AgentCandidate[] + } + + test('passes a namespaced coordinate to installSkill', async () => { + let received: Parameters>[0] | undefined + const deps: InstallCommandDeps = { + isTTY: () => false, + resolveInstallTargets: fakeResolveInstallTargets(), + installSkill: async (options) => { + received = options + return { installed: [{ agent: 'codex', dir: '/home/u/.codex/skills/my-skill' }] } + } + } + + await installCommand('@team/my-skill', { + registry: 'http://localhost', + token: 'sk' + }, deps) + + expect(received).toMatchObject({ + namespace: 'team', + slug: 'my-skill' + }) + }) + + test('rejects a conflicting namespace before installing', async () => { + let installCalls = 0 + let error: unknown + const deps: InstallCommandDeps = { + isTTY: () => false, + resolveInstallTargets: fakeResolveInstallTargets(), + installSkill: async () => { + installCalls += 1 + return { installed: [] } + } + } + + try { + await installCommand('@team/my-skill', { + namespace: 'other', + registry: 'http://localhost', + token: 'sk' + }, deps) + } catch (caught) { + error = caught + } + expect(error).toBeInstanceOf(CliError) + expect((error as CliError).exitCode).toBe(EXIT.usage) + expect(installCalls).toBe(0) + }) + test('passes prompted scope and strict isTTY into resolveInstallTargets', async () => { const calls: { promptScope: number; resolverCalls: ResolveInstallTargetOptions[] } = { promptScope: 0, diff --git a/cli/test/unit/shared/skill-name-parser.test.ts b/cli/test/unit/shared/skill-name-parser.test.ts index b86771ce..19a22c4c 100644 --- a/cli/test/unit/shared/skill-name-parser.test.ts +++ b/cli/test/unit/shared/skill-name-parser.test.ts @@ -1,90 +1,82 @@ -import { describe, test, expect } from 'bun:test' -import { parseSkillName } from '../../../src/shared/skill-name-parser' +import { describe, expect, test } from 'bun:test' +import { parseSkillName, resolveSkillName } from '../../../src/shared/skill-name-parser' +import { EXIT } from '../../../src/shared/constants' +import { CliError } from '../../../src/shared/errors' + +function expectUsageError(callback: () => unknown): void { + let error: unknown + try { + callback() + } catch (caught) { + error = caught + } + expect(error).toBeInstanceOf(CliError) + expect((error as CliError).exitCode).toBe(EXIT.usage) +} describe('parseSkillName', () => { - describe('with namespace--slug format', () => { - test('should parse namespace and slug separated by double dash', () => { - const result = parseSkillName('astroclaw--api-gateway') - expect(result).toEqual({ - namespace: 'astroclaw', - slug: 'api-gateway' - }) - }) + test.each([ + ['my-skill', { namespace: 'global', slug: 'my-skill' }], + ['team/my-skill', { namespace: 'team', slug: 'my-skill' }], + ['@team/my-skill', { namespace: 'team', slug: 'my-skill' }], + ['team--my-skill', { namespace: 'team', slug: 'my-skill' }] + ])('parses %s', (skillName, expected) => { + expect(parseSkillName(skillName)).toEqual(expected) + }) - test('should handle namespace and slug with single dashes', () => { - const result = parseSkillName('my-org--my-skill-name') - expect(result).toEqual({ - namespace: 'my-org', - slug: 'my-skill-name' - }) - }) - - test('should handle multiple double dashes by using first as separator', () => { - const result = parseSkillName('namespace--slug--with--dashes') - expect(result).toEqual({ - namespace: 'namespace', - slug: 'slug--with--dashes' - }) + test('preserves double dashes after the coordinate separator', () => { + expect(parseSkillName('namespace--slug--with--dashes')).toEqual({ + namespace: 'namespace', + slug: 'slug--with--dashes' }) }) - describe('with slug only format', () => { - test('should use default namespace when no separator present', () => { - const result = parseSkillName('api-gateway') - expect(result).toEqual({ - namespace: 'global', - slug: 'api-gateway' - }) - }) - - test('should use custom default namespace when provided', () => { - const result = parseSkillName('api-gateway', 'myorg') - expect(result).toEqual({ - namespace: 'myorg', - slug: 'api-gateway' - }) - }) - - test('should handle slug with single dashes', () => { - const result = parseSkillName('my-skill-name') - expect(result).toEqual({ - namespace: 'global', - slug: 'my-skill-name' - }) + test('preserves the custom default namespace for a bare slug', () => { + expect(parseSkillName('api-gateway', 'myorg')).toEqual({ + namespace: 'myorg', + slug: 'api-gateway' }) }) - describe('edge cases', () => { - test('should handle separator at start', () => { - const result = parseSkillName('--api-gateway') - expect(result).toEqual({ - namespace: 'global', - slug: 'api-gateway' - }) - }) - - test('should handle separator at end', () => { - const result = parseSkillName('astroclaw--') - expect(result).toEqual({ - namespace: 'global', - slug: 'astroclaw' - }) - }) - - test('should handle empty string', () => { - const result = parseSkillName('') - expect(result).toEqual({ - namespace: 'global', - slug: '' - }) - }) - - test('should handle just separator', () => { - const result = parseSkillName('--') - expect(result).toEqual({ - namespace: 'global', - slug: '' - }) - }) + test.each([ + '', + '@team', + 'team/', + '/my-skill', + '--my-skill', + 'team--' + ])('rejects malformed coordinate %p', (skillName) => { + expectUsageError(() => parseSkillName(skillName)) + }) +}) + +describe('resolveSkillName', () => { + test('uses global for a bare slug without an explicit namespace', () => { + expect(resolveSkillName('my-skill')).toEqual({ + namespace: 'global', + slug: 'my-skill' + }) + }) + + test('uses an explicit namespace for a bare slug', () => { + expect(resolveSkillName('my-skill', 'team')).toEqual({ + namespace: 'team', + slug: 'my-skill' + }) + }) + + test.each([ + 'team/my-skill', + '@team/my-skill', + 'team--my-skill' + ])('accepts matching --namespace for %s', (skillName) => { + expect(resolveSkillName(skillName, 'team')).toEqual({ + namespace: 'team', + slug: 'my-skill' + }) + }) + + test('rejects a coordinate that conflicts with --namespace', () => { + expectUsageError(() => resolveSkillName('@team/my-skill', 'other')) }) }) From 3e66c80f94862fb801fd6c4763fe19e3e009b460 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Tue, 28 Jul 2026 11:24:44 +0800 Subject: [PATCH 68/81] fix(cli): preserve structured registry errors (#606) Signed-off-by: dongmucat <1127093059@qq.com> --- cli/src/clients/skillhub-client.ts | 83 +++++++++---- cli/src/shared/output.ts | 3 + cli/test/unit/clients/skillhub-client.test.ts | 109 +++++++++++++++++- cli/test/unit/shared/output.test.ts | 12 ++ 4 files changed, 183 insertions(+), 24 deletions(-) diff --git a/cli/src/clients/skillhub-client.ts b/cli/src/clients/skillhub-client.ts index 14e14ec3..c8ee09ae 100644 --- a/cli/src/clients/skillhub-client.ts +++ b/cli/src/clients/skillhub-client.ts @@ -52,6 +52,13 @@ export interface DryRunResponse { resolvedVersion: string | null } +interface PublicErrorFields { + msg?: string + requestId?: string +} + +type ErrorResponseKind = 'json' | 'download' + export class SkillHubClient { constructor( readonly registry: string, @@ -88,14 +95,8 @@ export class SkillHubClient { } catch { throw new CliError('registry unreachable', EXIT.network, { registry: this.registry, next: 'check network or pass --registry' }) } - if (response.status === 401 || response.status === 403) { - throw new CliError('authentication failed', EXIT.auth, { registry: this.registry, next: 'run `skillhub login`' }) - } - if (response.status === 404) { - throw new CliError('skill or version not found', EXIT.generic, { registry: this.registry }) - } if (!response.ok) { - throw new CliError(`download failed with status ${response.status}`, EXIT.generic, { registry: this.registry }) + throw await this.createResponseError(response, 'download') } return response } @@ -151,27 +152,65 @@ export class SkillHubClient { } private async handleJsonResponse(response: Response): Promise { - if (response.status === 401) { - throw new CliError('authentication failed', EXIT.auth, { registry: this.registry, next: 'run `skillhub login`' }) - } - if (response.status === 403) { - throw new CliError('access denied — token may lack required scope', EXIT.auth, { registry: this.registry, next: 'regenerate token with required scopes or run `skillhub login`' }) - } - if (response.status === 404) { - throw new CliError('resource not found', EXIT.generic, { registry: this.registry }) - } - // 502/503 indicate network-level failures (connection refused, service unavailable) - if (response.status === 502 || response.status === 503) { - throw new CliError(`registry returned ${response.status}`, EXIT.network, { registry: this.registry }) - } if (!response.ok) { - const text = await response.text().catch(() => '') - throw new CliError(`registry returned ${response.status}`, EXIT.generic, { registry: this.registry, detail: text }) + throw await this.createResponseError(response, 'json') } const body = await response.json() return body.data as T } + private async createResponseError(response: Response, kind: ErrorResponseKind): Promise { + const publicFields = await this.readPublicErrorFields(response) + const details: Record = { registry: this.registry } + if (publicFields.requestId) { + details.requestId = publicFields.requestId + } + + let fallback: string + let exitCode: number = EXIT.generic + + if (response.status === 401) { + fallback = 'authentication failed' + exitCode = EXIT.auth + details.next = 'run `skillhub login`' + } else if (response.status === 403) { + fallback = 'access denied' + exitCode = EXIT.auth + } else if (response.status === 404) { + fallback = kind === 'download' ? 'skill or version not found' : 'resource not found' + } else if (response.status === 502 || response.status === 503) { + fallback = `registry returned ${response.status}` + exitCode = EXIT.network + } else { + fallback = kind === 'download' + ? `download failed with status ${response.status}` + : `registry returned ${response.status}` + } + + return new CliError(publicFields.msg ?? fallback, exitCode, details) + } + + private async readPublicErrorFields(response: Response): Promise { + let body: unknown + try { + body = await response.json() + } catch { + return {} + } + + if (typeof body !== 'object' || body === null || Array.isArray(body)) { + return {} + } + + const record = body as Record + const msg = typeof record.msg === 'string' ? record.msg.trim() : '' + const requestId = typeof record.requestId === 'string' ? record.requestId.trim() : '' + return { + ...(msg ? { msg } : {}), + ...(requestId ? { requestId } : {}) + } + } + private headers(): HeadersInit { return this.token ? { Authorization: `Bearer ${this.token}` } : {} } diff --git a/cli/src/shared/output.ts b/cli/src/shared/output.ts index 9b2eafd9..977116bc 100644 --- a/cli/src/shared/output.ts +++ b/cli/src/shared/output.ts @@ -30,6 +30,9 @@ export function renderError(error: unknown, json: boolean): string { if (typeof cliError.details.path === 'string') { lines.push(`Context: path ${cliError.details.path}`) } + if (typeof cliError.details.requestId === 'string') { + lines.push(`Request ID: ${cliError.details.requestId}`) + } if (typeof cliError.details.next === 'string') { lines.push(`Next: ${cliError.details.next}`) } diff --git a/cli/test/unit/clients/skillhub-client.test.ts b/cli/test/unit/clients/skillhub-client.test.ts index c07083c2..686c8cf2 100644 --- a/cli/test/unit/clients/skillhub-client.test.ts +++ b/cli/test/unit/clients/skillhub-client.test.ts @@ -36,12 +36,12 @@ describe('SkillHubClient', () => { await err.toHaveProperty('exitCode', EXIT.auth) }) - test('download() throws auth error on 403', async () => { + test('download() throws a neutral access error on 403', async () => { const fetchImpl = (async () => new Response(null, { status: 403 })) as unknown as typeof fetch const client = new SkillHubClient('http://registry.test', 'token', fetchImpl) const err = expect(client.download('ns', 'slug')).rejects await err.toBeInstanceOf(CliError) - await err.toHaveProperty('message', 'authentication failed') + await err.toHaveProperty('message', 'access denied') await err.toHaveProperty('exitCode', EXIT.auth) }) @@ -159,6 +159,111 @@ describe('SkillHubClient', () => { // --- handleJsonResponse() non-2xx classification --- + test('search() preserves a public 403 message and request ID', async () => { + const fetchImpl = (async () => Response.json({ + code: 403, + msg: 'token has been revoked', + requestId: 'req-403' + }, { status: 403 })) as unknown as typeof fetch + const client = new SkillHubClient('http://registry.test', 'token', fetchImpl) + + try { + await client.search('test', 20) + throw new Error('expected search to fail') + } catch (error) { + expect(error).toBeInstanceOf(CliError) + expect((error as CliError).message).toBe('token has been revoked') + expect((error as CliError).exitCode).toBe(EXIT.auth) + expect((error as CliError).details).toEqual({ + registry: 'http://registry.test', + requestId: 'req-403' + }) + } + }) + + test('search() uses a neutral 403 fallback when msg is absent', async () => { + const fetchImpl = (async () => Response.json({ + code: 403, + requestId: 'req-fallback' + }, { status: 403 })) as unknown as typeof fetch + const client = new SkillHubClient('http://registry.test', 'token', fetchImpl) + + try { + await client.search('test', 20) + throw new Error('expected search to fail') + } catch (error) { + expect(error).toBeInstanceOf(CliError) + expect((error as CliError).message).toBe('access denied') + expect((error as CliError).exitCode).toBe(EXIT.auth) + expect((error as CliError).details).toEqual({ + registry: 'http://registry.test', + requestId: 'req-fallback' + }) + } + }) + + test('search() uses a neutral 403 fallback for a non-JSON body', async () => { + const fetchImpl = (async () => new Response('forbidden', { + status: 403, + headers: { 'Content-Type': 'text/html' } + })) as unknown as typeof fetch + const client = new SkillHubClient('http://registry.test', 'token', fetchImpl) + + try { + await client.search('test', 20) + throw new Error('expected search to fail') + } catch (error) { + expect(error).toBeInstanceOf(CliError) + expect((error as CliError).message).toBe('access denied') + expect((error as CliError).exitCode).toBe(EXIT.auth) + expect((error as CliError).details).toEqual({ registry: 'http://registry.test' }) + } + }) + + test('whoami() preserves a structured 404 message and request ID', async () => { + const fetchImpl = (async () => Response.json({ + code: 404, + msg: 'namespace not found', + requestId: 'req-404' + }, { status: 404 })) as unknown as typeof fetch + const client = new SkillHubClient('http://registry.test', 'token', fetchImpl) + + try { + await client.whoami() + throw new Error('expected whoami to fail') + } catch (error) { + expect(error).toBeInstanceOf(CliError) + expect((error as CliError).message).toBe('namespace not found') + expect((error as CliError).exitCode).toBe(EXIT.generic) + expect((error as CliError).details).toEqual({ + registry: 'http://registry.test', + requestId: 'req-404' + }) + } + }) + + test('download() preserves a structured 403 message and request ID', async () => { + const fetchImpl = (async () => Response.json({ + code: 403, + msg: 'namespace access denied', + requestId: 'req-download' + }, { status: 403 })) as unknown as typeof fetch + const client = new SkillHubClient('http://registry.test', 'token', fetchImpl) + + try { + await client.download('team', 'private-skill') + throw new Error('expected download to fail') + } catch (error) { + expect(error).toBeInstanceOf(CliError) + expect((error as CliError).message).toBe('namespace access denied') + expect((error as CliError).exitCode).toBe(EXIT.auth) + expect((error as CliError).details).toEqual({ + registry: 'http://registry.test', + requestId: 'req-download' + }) + } + }) + test('whoami() throws generic error on 500', async () => { const fetchImpl = (async () => new Response(null, { status: 500 })) as unknown as typeof fetch const client = new SkillHubClient('http://registry.test', 'token', fetchImpl) diff --git a/cli/test/unit/shared/output.test.ts b/cli/test/unit/shared/output.test.ts index 8d051172..c157a780 100644 --- a/cli/test/unit/shared/output.test.ts +++ b/cli/test/unit/shared/output.test.ts @@ -24,6 +24,18 @@ describe('renderError', () => { 'Next: check network or pass --registry' ].join('\n')) }) + + test('renders a server request ID for human-readable errors', () => { + const error = new CliError('token has been revoked', 2, { + registry: 'https://registry.example.com', + requestId: 'req-403' + }) + expect(renderError(error, false)).toBe([ + 'Error: token has been revoked', + 'Context: registry https://registry.example.com', + 'Request ID: req-403' + ].join('\n')) + }) }) describe('printResult', () => { From 27efaa1b61a253ea64014af1f5f01b65f2408417 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Tue, 28 Jul 2026 11:26:26 +0800 Subject: [PATCH 69/81] docs(cli): document namespace and error behavior (#606) Signed-off-by: dongmucat <1127093059@qq.com> --- cli/CHANGELOG.md | 16 +++++++++++++ cli/README.md | 28 +++++++++++++++++++++-- cli/package.json | 1 + cli/src/commands/help.ts | 7 +++--- cli/src/index.ts | 2 +- cli/test/integration/help-command.test.ts | 5 +++- 6 files changed, 52 insertions(+), 7 deletions(-) create mode 100644 cli/CHANGELOG.md diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md new file mode 100644 index 00000000..f64cfc06 --- /dev/null +++ b/cli/CHANGELOG.md @@ -0,0 +1,16 @@ +# Changelog + +All notable CLI behavior changes are documented in this file. + +## Unreleased + +### Fixed + +- Resolve `namespace/slug`, `@namespace/slug`, and `namespace--slug` + coordinates against their declared namespace instead of silently falling + back to `global`. +- Reject a namespaced coordinate combined with a conflicting `--namespace` + value; a matching value remains valid. +- Preserve public registry `msg` and `requestId` fields for unsuccessful + responses. HTTP 403 without a public message now reports the neutral + `access denied` fallback instead of assuming the token lacks scope. diff --git a/cli/README.md b/cli/README.md index b2a8cdf3..0a8e289c 100644 --- a/cli/README.md +++ b/cli/README.md @@ -126,15 +126,34 @@ Output format: `namespace/slug version summary` ## 📥 Install Skills +The install coordinate accepts a bare slug or any of the equivalent namespace +forms below: + +| Coordinate | Resolved namespace | Resolved slug | +|------------|--------------------|---------------| +| `my-skill` | `global` | `my-skill` | +| `team/my-skill` | `team` | `my-skill` | +| `@team/my-skill` | `team` | `my-skill` | +| `team--my-skill` | `team` | `my-skill` | + +For a bare slug, `--namespace team` selects a non-global namespace. A +namespaced coordinate may be combined with the same `--namespace` value, but a +conflicting value is rejected instead of silently overriding the coordinate. + ```bash # Install to auto-detected Agent directory skillhub install pdf-parser +# Equivalent namespaced coordinates +skillhub install team/my-skill +skillhub install @team/my-skill +skillhub install team--my-skill + # Choose install scope explicitly skillhub install pdf-parser --scope user skillhub install pdf-parser --scope project --agent codex -# Specify namespace (default: global) +# Specify namespace for a bare slug (default: global) skillhub install pdf-parser --namespace myspace # Specify version @@ -337,7 +356,7 @@ Update mechanism: | `skillhub logout [--registry ] [--json]` | Remove token for specified registry | | `skillhub whoami [--registry ] [--token ] [--json]` | Validate current token and display user information | | `skillhub search [--registry ] [--token ] [--limit ] [--json]` | Search published skills | -| `skillhub install [--scope ] [--namespace ] [--version ] [--agent ] [--dir ] [--force] [--registry ] [--token ] [--json]` | Install a skill | +| `skillhub install [--scope ] [--namespace ] [--version ] [--agent ] [--dir ] [--force] [--registry ] [--token ] [--json]` | Install a skill | | `skillhub list [--agent ] [--dir ] [--registry ] [--json]` | List installed skills | | `skillhub remove [--agent ] [--all] [--remote] [--hard] [--namespace ] [--registry ] [--token ] [--json]` | Remove a skill | | `skillhub doctor [--json]` | Scan project directory and rebuild local inventory | @@ -364,6 +383,11 @@ skillhub whoami skillhub login --token sk_xxx ``` +For structured registry failures, the CLI prints the server's public `msg` and +`requestId`. HTTP 403 without a public message falls back to `access denied`; +it is not automatically described as a missing token scope. Include the +request ID when asking a registry operator to investigate. + ### Network Error ```bash diff --git a/cli/package.json b/cli/package.json index a7b94f7d..e16b3a20 100644 --- a/cli/package.json +++ b/cli/package.json @@ -28,6 +28,7 @@ "files": [ "dist", "README.md", + "CHANGELOG.md", "LICENSE" ], "scripts": { diff --git a/cli/src/commands/help.ts b/cli/src/commands/help.ts index 9b35f3b4..05d90fb5 100644 --- a/cli/src/commands/help.ts +++ b/cli/src/commands/help.ts @@ -33,11 +33,12 @@ export const commands = { }, install: { summary: 'Install a skill locally', - usage: 'skillhub install [--scope ] [--namespace ] [--version ] [--agent ] [--dir ] [--force] [--json]', + usage: 'skillhub install [--scope ] [--namespace ] [--version ] [--agent ] [--dir ] [--force] [--json]', examples: [ 'skillhub install pdf-parser', - 'skillhub install pdf-parser --scope user', - 'skillhub install pdf-parser --scope project --agent codex' + 'skillhub install team/my-skill', + 'skillhub install @team/my-skill', + 'skillhub install team--my-skill' ] }, list: { diff --git a/cli/src/index.ts b/cli/src/index.ts index 349d4d43..ee6eaa73 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -231,7 +231,7 @@ cli }) cli - .command('install ', 'Install a skill locally') + .command('install ', 'Install a skill locally') .option('--namespace ', 'Namespace for a bare skill slug') .option('--version ', 'Version') .option('--scope ', 'Install scope: user or project') diff --git a/cli/test/integration/help-command.test.ts b/cli/test/integration/help-command.test.ts index a61d336f..278daaa5 100644 --- a/cli/test/integration/help-command.test.ts +++ b/cli/test/integration/help-command.test.ts @@ -5,8 +5,11 @@ describe('help command', () => { test('prints detailed help for install', async () => { const result = await runCli(['help', 'install']) expect(result.exitCode).toBe(0) - expect(result.stdout).toContain('Usage: skillhub install ') + expect(result.stdout).toContain('Usage: skillhub install ') expect(result.stdout).toContain('--agent ') + expect(result.stdout).toContain('@team/my-skill') + expect(result.stdout).toContain('team/my-skill') + expect(result.stdout).toContain('team--my-skill') }) test('prints search help with optional query', async () => { From b702f0f9f6d5c7db7f932ffb372e304c99db85c0 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Tue, 28 Jul 2026 11:28:50 +0800 Subject: [PATCH 70/81] test(cli): align namespace error contracts (#606) Signed-off-by: dongmucat <1127093059@qq.com> --- cli/src/commands/help.ts | 4 +++- cli/test/integration/error-output.test.ts | 2 +- cli/test/integration/publish-dry-run.test.ts | 5 +++-- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/cli/src/commands/help.ts b/cli/src/commands/help.ts index 05d90fb5..0ef9c152 100644 --- a/cli/src/commands/help.ts +++ b/cli/src/commands/help.ts @@ -38,7 +38,9 @@ export const commands = { 'skillhub install pdf-parser', 'skillhub install team/my-skill', 'skillhub install @team/my-skill', - 'skillhub install team--my-skill' + 'skillhub install team--my-skill', + 'skillhub install pdf-parser --scope user', + 'skillhub install pdf-parser --scope project --agent codex' ] }, list: { diff --git a/cli/test/integration/error-output.test.ts b/cli/test/integration/error-output.test.ts index 246b3070..3d11cdfb 100644 --- a/cli/test/integration/error-output.test.ts +++ b/cli/test/integration/error-output.test.ts @@ -67,7 +67,7 @@ describe('cli error output', () => { expect(result.exitCode).toBe(5) expect(result.stderr).toContain('Error: missing required argument') - expect(result.stderr).toContain('Usage: skillhub install ') + expect(result.stderr).toContain('Usage: skillhub install ') expect(result.stderr).toContain('Run "skillhub help install" for more information.') }) diff --git a/cli/test/integration/publish-dry-run.test.ts b/cli/test/integration/publish-dry-run.test.ts index deabb7b2..9f5afb43 100644 --- a/cli/test/integration/publish-dry-run.test.ts +++ b/cli/test/integration/publish-dry-run.test.ts @@ -159,7 +159,7 @@ describe('publish --dry-run', () => { expect(result.stderr).toContain('authentication') }) - test('--dry-run reports scope error on 403', async () => { + test('--dry-run uses a neutral fallback on an unstructured 403', async () => { const env = await createTempHome() registry = await startFakeRegistry({ token: 'sk_ok', failures: { validate: 'forbidden' } }) await login(env, registry.url) @@ -171,6 +171,7 @@ describe('publish --dry-run', () => { }) expect(result.exitCode).toBe(2) - expect(result.stderr).toContain('scope') + expect(result.stderr).toContain('access denied') + expect(result.stderr).not.toContain('scope') }) }) From bd83d2d95f5d1237d99802c48c8bbc189e280f20 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Tue, 28 Jul 2026 11:33:45 +0800 Subject: [PATCH 71/81] fix(cli): reject ambiguous namespace paths (#606) Signed-off-by: dongmucat <1127093059@qq.com> --- cli/src/shared/skill-name-parser.ts | 2 +- cli/test/unit/shared/skill-name-parser.test.ts | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/cli/src/shared/skill-name-parser.ts b/cli/src/shared/skill-name-parser.ts index 575f92fa..98a9a096 100644 --- a/cli/src/shared/skill-name-parser.ts +++ b/cli/src/shared/skill-name-parser.ts @@ -24,7 +24,7 @@ function parseSeparatedCoordinate( const namespace = skillName.slice(namespaceStart, separatorIndex) const slug = skillName.slice(separatorIndex + separatorLength) - if (!namespace || !slug) { + if (!namespace || !slug || slug.includes('/')) { throw invalidCoordinate(skillName) } diff --git a/cli/test/unit/shared/skill-name-parser.test.ts b/cli/test/unit/shared/skill-name-parser.test.ts index 19a22c4c..87c56f96 100644 --- a/cli/test/unit/shared/skill-name-parser.test.ts +++ b/cli/test/unit/shared/skill-name-parser.test.ts @@ -44,7 +44,10 @@ describe('parseSkillName', () => { 'team/', '/my-skill', '--my-skill', - 'team--' + 'team--', + 'team/my-skill/extra', + '@team/my-skill/extra', + 'team--my-skill/extra' ])('rejects malformed coordinate %p', (skillName) => { expectUsageError(() => parseSkillName(skillName)) }) From 6e6cce05882f8185a965a357eaf1ba555f159b47 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Tue, 28 Jul 2026 11:36:28 +0800 Subject: [PATCH 72/81] test(cli): cover all namespace request paths (#606) Signed-off-by: dongmucat <1127093059@qq.com> --- cli/test/integration/install-command.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/cli/test/integration/install-command.test.ts b/cli/test/integration/install-command.test.ts index 62a12837..27b0623c 100644 --- a/cli/test/integration/install-command.test.ts +++ b/cli/test/integration/install-command.test.ts @@ -324,7 +324,11 @@ describe('install command — P1', () => { expect(meta.version).toBe('2.0.0') }) - test('@namespace/slug resolves the namespaced registry path', async () => { + test.each([ + 'team/my-skill', + '@team/my-skill', + 'team--my-skill' + ])('%s resolves the namespaced registry path', async (coordinate) => { const env = await createTempHome() registry = await startFakeRegistry({ token: 'sk_ok', @@ -341,7 +345,7 @@ describe('install command — P1', () => { const result = await runCli( [ - 'install', '@team/my-skill', + 'install', coordinate, '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok', From d4d1f65705dbfe8b386a15cad94e8ac203c36e3c Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Tue, 28 Jul 2026 12:24:45 +0800 Subject: [PATCH 73/81] fix(cli): scope local remove by namespace (#606) Signed-off-by: dongmucat <1127093059@qq.com> --- cli/CHANGELOG.md | 4 + cli/README.md | 12 +- cli/src/commands/help.ts | 9 +- cli/src/commands/remove.ts | 9 +- cli/src/index.ts | 8 +- cli/src/services/remove-service.ts | 7 +- cli/src/shared/skill-name-parser.ts | 5 + cli/test/integration/help-command.test.ts | 15 ++ cli/test/integration/remove-command.test.ts | 145 +++++++++++++++--- cli/test/unit/services/remove-service.test.ts | 43 +++++- docs/skillhub/en/guide/cli.md | 19 ++- docs/skillhub/guide/cli.md | 18 ++- 12 files changed, 252 insertions(+), 42 deletions(-) diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index f64cfc06..8a378be0 100644 --- a/cli/CHANGELOG.md +++ b/cli/CHANGELOG.md @@ -11,6 +11,10 @@ All notable CLI behavior changes are documented in this file. back to `global`. - Reject a namespaced coordinate combined with a conflicting `--namespace` value; a matching value remains valid. +- Limit local removal with a namespaced coordinate or explicit `--namespace` + to the matching namespace, preventing collateral deletion of same-slug + installations in other namespaces. Bare-slug removal retains its existing + cross-namespace behavior for compatibility. - Preserve public registry `msg` and `requestId` fields for unsuccessful responses. HTTP 403 without a public message now reports the neutral `access denied` fallback instead of assuming the token lacks scope. diff --git a/cli/README.md b/cli/README.md index 0a8e289c..37016be4 100644 --- a/cli/README.md +++ b/cli/README.md @@ -258,9 +258,17 @@ skillhub list --json ### Remove Skills ```bash -# Remove all local installation targets +# A bare slug removes matching local installations across namespaces skillhub remove pdf-parser +# A namespaced coordinate removes only that namespace +skillhub remove myspace/pdf-parser +skillhub remove @myspace/pdf-parser +skillhub remove myspace--pdf-parser + +# Equivalent precise local removal with an explicit namespace +skillhub remove pdf-parser --namespace myspace + # Remove only specific Agent's installation skillhub remove pdf-parser --agent codex @@ -358,7 +366,7 @@ Update mechanism: | `skillhub search [--registry ] [--token ] [--limit ] [--json]` | Search published skills | | `skillhub install [--scope ] [--namespace ] [--version ] [--agent ] [--dir ] [--force] [--registry ] [--token ] [--json]` | Install a skill | | `skillhub list [--agent ] [--dir ] [--registry ] [--json]` | List installed skills | -| `skillhub remove [--agent ] [--all] [--remote] [--hard] [--namespace ] [--registry ] [--token ] [--json]` | Remove a skill | +| `skillhub remove [--agent ] [--all] [--remote] [--hard] [--namespace ] [--registry ] [--token ] [--json]` | Remove a skill | | `skillhub doctor [--json]` | Scan project directory and rebuild local inventory | | `skillhub publish [--namespace ] [--visibility ] [--registry ] [--token ] [--json]` | Publish a skill | | `skillhub update [--check] [--json]` | Check or execute CLI self-update | diff --git a/cli/src/commands/help.ts b/cli/src/commands/help.ts index 0ef9c152..00a197c3 100644 --- a/cli/src/commands/help.ts +++ b/cli/src/commands/help.ts @@ -50,8 +50,13 @@ export const commands = { }, remove: { summary: 'Remove local or remote skill', - usage: 'skillhub remove [--agent ] [--all] [--remote] [--hard] [--namespace ] [--json]', - examples: ['skillhub remove pdf-parser', 'skillhub remove pdf-parser --remote --hard'] + usage: 'skillhub remove [--agent ] [--all] [--remote] [--hard] [--namespace ] [--json]', + examples: [ + 'skillhub remove pdf-parser', + 'skillhub remove team/my-skill', + 'skillhub remove my-skill --namespace team', + 'skillhub remove pdf-parser --remote --hard' + ] }, doctor: { summary: 'Scan project and merge into local inventory (preserves entries outside scan scope)', diff --git a/cli/src/commands/remove.ts b/cli/src/commands/remove.ts index 9ffa999e..9a2e262b 100644 --- a/cli/src/commands/remove.ts +++ b/cli/src/commands/remove.ts @@ -5,7 +5,7 @@ import { resolveRegistry, resolveToken } from '../services/registry-service' import { removeLocalSkill } from '../services/remove-service' import { CliError } from '../shared/errors' import { EXIT } from '../shared/constants' -import { resolveSkillName } from '../shared/skill-name-parser' +import { hasExplicitNamespace, resolveSkillName } from '../shared/skill-name-parser' export interface RemoveCommandOptions { agent?: string[] | undefined @@ -60,8 +60,13 @@ export async function removeCommand(skillNameArg: string, options: RemoveCommand } // Local remove + const namespaceFilter = options.namespace !== undefined || hasExplicitNamespace(skillNameArg) + ? namespace + : undefined const result = await removeLocalSkill({ - registry, slug, + registry, + namespace: namespaceFilter, + slug, agents: options.agent, all: options.all }) diff --git a/cli/src/index.ts b/cli/src/index.ts index ee6eaa73..12a2408e 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -256,17 +256,17 @@ cli }) cli - .command('remove ', 'Remove local or remote skill') + .command('remove ', 'Remove local or remote skill') .option('--agent ', 'Filter by agent (repeatable)') .option('--all', 'Remove all targets') .option('--remote', 'Delete remote skill') .option('--hard', 'Skip confirmation for remote delete') - .option('--namespace ', 'Namespace for remote delete') + .option('--namespace ', 'Namespace for local or remote delete') .option('--registry ', 'Registry URL') .option('--token ', 'API token') .option('--json', 'Output JSON') - .action((slug: string, options: RemoveCommandOptions & { agent?: string | string[] }) => { - return runCommand(() => removeCommand(slug, { ...options, agent: toArray(options.agent) }), Boolean(options.json)) + .action((coordinate: string, options: RemoveCommandOptions & { agent?: string | string[] }) => { + return runCommand(() => removeCommand(coordinate, { ...options, agent: toArray(options.agent) }), Boolean(options.json)) }) cli diff --git a/cli/src/services/remove-service.ts b/cli/src/services/remove-service.ts index 83f57a3d..e3e019f5 100644 --- a/cli/src/services/remove-service.ts +++ b/cli/src/services/remove-service.ts @@ -15,6 +15,7 @@ function isPathUnder(child: string, parent: string): boolean { export interface RemoveLocalOptions { registry: string + namespace?: string | undefined slug: string agents?: string[] | undefined all?: boolean | undefined @@ -29,7 +30,11 @@ export async function removeLocalSkill(options: RemoveLocalOptions): Promise i.registry === options.registry && i.slug === options.slug) + const items = inventory.items.filter(item => + item.registry === options.registry && + item.slug === options.slug && + (options.namespace === undefined || item.namespace === options.namespace) + ) if (items.length === 0) { throw new CliError(`skill not found locally: ${options.slug}`, EXIT.generic, { next: 'run `skillhub list` to see installed skills' diff --git a/cli/src/shared/skill-name-parser.ts b/cli/src/shared/skill-name-parser.ts index 98a9a096..26ab166c 100644 --- a/cli/src/shared/skill-name-parser.ts +++ b/cli/src/shared/skill-name-parser.ts @@ -64,6 +64,11 @@ export function parseSkillName(skillName: string, defaultNamespace = 'global'): } } +/** Return whether a skill coordinate explicitly includes a namespace. */ +export function hasExplicitNamespace(skillName: string): boolean { + return parseCoordinate(skillName).namespace !== undefined +} + /** Resolve a skill coordinate and an optional command-line namespace into one registry identity. */ export function resolveSkillName(skillName: string, explicitNamespace?: string): ParsedSkillName { const parsed = parseCoordinate(skillName) diff --git a/cli/test/integration/help-command.test.ts b/cli/test/integration/help-command.test.ts index 278daaa5..a3132d1e 100644 --- a/cli/test/integration/help-command.test.ts +++ b/cli/test/integration/help-command.test.ts @@ -12,6 +12,21 @@ describe('help command', () => { expect(result.stdout).toContain('team--my-skill') }) + test('prints namespaced local remove contract in command help', async () => { + const result = await runCli(['help', 'remove']) + expect(result.exitCode).toBe(0) + expect(result.stdout).toContain('Usage: skillhub remove ') + expect(result.stdout).toContain('skillhub remove team/my-skill') + expect(result.stdout).toContain('skillhub remove my-skill --namespace team') + }) + + test('prints namespaced local remove contract in --help', async () => { + const result = await runCli(['remove', '--help']) + expect(result.exitCode).toBe(0) + expect(result.stdout).toContain('remove ') + expect(result.stdout).toContain('Namespace for local or remote delete') + }) + test('prints search help with optional query', async () => { const result = await runCli(['help', 'search']) expect(result.exitCode).toBe(0) diff --git a/cli/test/integration/remove-command.test.ts b/cli/test/integration/remove-command.test.ts index 35148d77..5f44f8a1 100644 --- a/cli/test/integration/remove-command.test.ts +++ b/cli/test/integration/remove-command.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, test } from 'bun:test' -import { mkdir, writeFile } from 'node:fs/promises' +import { access, mkdir, writeFile } from 'node:fs/promises' import { createTempHome } from '../helpers/temp-env' import { startFakeRegistry } from '../helpers/fake-registry' import { runCli } from '../helpers/run-cli' @@ -27,6 +27,15 @@ async function createInstallDir(path: string) { await mkdir(path, { recursive: true }) } +async function pathExists(path: string): Promise { + try { + await access(path) + return true + } catch { + return false + } +} + /** Build a minimal inventory item with one target. */ function makeItem(opts: { registry: string @@ -388,43 +397,131 @@ describe('remove command — local remove (P1)', () => { expect(survived!.targets.map(t => t.agent)).toEqual(['claude-code']) }) - // ------------------------------------------------------------------------- - // P1: --agent + --namespace together filter precisely so a same-slug skill - // in a different namespace is not collateral damage. - // ------------------------------------------------------------------------- - test('--agent + --namespace filters precisely; same slug under different namespace is untouched', async () => { + const namespacedRemoveCases: Array<[string, string[]]> = [ + ['namespace/slug coordinate', ['team/shared-skill']], + ['@namespace/slug coordinate', ['@team/shared-skill']], + ['namespace--slug coordinate', ['team--shared-skill']], + ['--namespace flag', ['shared-skill', '--namespace', 'team']] + ] + + test.each(namespacedRemoveCases)('%s only removes the targeted same-slug namespace', async (_label, removeArgs) => { const env = await createTempHome() registry = await startFakeRegistry({ token: 'sk_ok' }) const rootDir = `${env.home}/agents` - const aDir = `${rootDir}/codex/skills/dup-slug-A` - const bDir = `${rootDir}/codex/skills/dup-slug-B` - await createInstallDir(aDir) - await createInstallDir(bDir) + const globalDir = `${rootDir}/codex/skills/shared-skill` + const teamDir = `${rootDir}/claude-code/skills/shared-skill` + const otherDir = `${rootDir}/cursor/skills/shared-skill` + await createInstallDir(globalDir) + await createInstallDir(teamDir) + await createInstallDir(otherDir) await seedInventory(env.home, [ - { - registry: registry.url, namespace: 'team-a', slug: 'dup-slug-A', version: '1.0.0', - targets: [{ agent: 'codex', rootDir: `${rootDir}/codex`, installDir: aDir, installedAt: '2026-04-20T00:00:00Z' }] - }, - { - registry: registry.url, namespace: 'team-b', slug: 'dup-slug-B', version: '1.0.0', - targets: [{ agent: 'codex', rootDir: `${rootDir}/codex`, installDir: bDir, installedAt: '2026-04-20T00:00:00Z' }] - } + makeItem({ + registry: registry.url, + namespace: 'global', + slug: 'shared-skill', + agent: 'codex', + rootDir: `${rootDir}/codex`, + installDir: globalDir + }), + makeItem({ + registry: registry.url, + namespace: 'team', + slug: 'shared-skill', + agent: 'claude-code', + rootDir: `${rootDir}/claude-code`, + installDir: teamDir + }), + makeItem({ + registry: registry.url, + namespace: 'other', + slug: 'shared-skill', + agent: 'cursor', + rootDir: `${rootDir}/cursor`, + installDir: otherDir + }) ]) - // Remove dup-slug-A only — dup-slug-B should survive even though both - // share the codex agent. const result = await runCli( - ['remove', 'dup-slug-A', '--agent', 'codex', '--registry', registry.url], + ['remove', ...removeArgs, '--registry', registry.url, '--json'], { HOME: env.home, USERPROFILE: env.home } ) + expect(result.exitCode).toBe(0) + const parsed = JSON.parse(result.stdout) + expect(parsed.removed).toHaveLength(1) + expect(parsed.removed[0]).toMatchObject({ namespace: 'team', agent: 'claude-code' }) + expect(await pathExists(globalDir)).toBe(true) + expect(await pathExists(teamDir)).toBe(false) + expect(await pathExists(otherDir)).toBe(true) const inv = JSON.parse(await Bun.file(`${env.home}/.skillhub/inventory.json`).text()) as { - items: Array<{ slug: string }> + items: Array<{ namespace: string; slug: string; targets: Array<{ installDir: string }> }> } - const slugs = inv.items.map(i => i.slug).sort() - expect(slugs).toEqual(['dup-slug-B']) + expect(inv.items.map(item => item.namespace).sort()).toEqual(['global', 'other']) + expect(inv.items.every(item => item.slug === 'shared-skill')).toBe(true) + expect(inv.items.map(item => item.targets[0]?.installDir).sort()).toEqual([globalDir, otherDir].sort()) + }) + + test('bare slug retains cross-namespace local removal compatibility', async () => { + const env = await createTempHome() + registry = await startFakeRegistry({ token: 'sk_ok' }) + + const rootDir = `${env.home}/agents` + const globalDir = `${rootDir}/codex/skills/shared-skill` + const teamDir = `${rootDir}/claude-code/skills/shared-skill` + const otherDir = `${rootDir}/cursor/skills/shared-skill` + await createInstallDir(globalDir) + await createInstallDir(teamDir) + await createInstallDir(otherDir) + + await seedInventory(env.home, [ + makeItem({ + registry: registry.url, + namespace: 'global', + slug: 'shared-skill', + agent: 'codex', + rootDir: `${rootDir}/codex`, + installDir: globalDir + }), + makeItem({ + registry: registry.url, + namespace: 'team', + slug: 'shared-skill', + agent: 'claude-code', + rootDir: `${rootDir}/claude-code`, + installDir: teamDir + }), + makeItem({ + registry: registry.url, + namespace: 'other', + slug: 'shared-skill', + agent: 'cursor', + rootDir: `${rootDir}/cursor`, + installDir: otherDir + }) + ]) + + const result = await runCli( + ['remove', 'shared-skill', '--registry', registry.url, '--json'], + { HOME: env.home, USERPROFILE: env.home } + ) + + expect(result.exitCode).toBe(0) + const parsed = JSON.parse(result.stdout) + expect(parsed.removed.map((item: { namespace: string }) => item.namespace).sort()).toEqual([ + 'global', + 'other', + 'team' + ]) + expect(await pathExists(globalDir)).toBe(false) + expect(await pathExists(teamDir)).toBe(false) + expect(await pathExists(otherDir)).toBe(false) + + const inv = JSON.parse(await Bun.file(`${env.home}/.skillhub/inventory.json`).text()) as { + items: object[] + } + expect(inv.items).toEqual([]) }) }) diff --git a/cli/test/unit/services/remove-service.test.ts b/cli/test/unit/services/remove-service.test.ts index d1a86782..2e686f06 100644 --- a/cli/test/unit/services/remove-service.test.ts +++ b/cli/test/unit/services/remove-service.test.ts @@ -15,7 +15,7 @@ async function exists(path: string): Promise { } describe('removeLocalSkill', () => { - test('removes all current-registry installs with the same slug across namespaces', async () => { + test('bare slug removes all current-registry installs with the same slug across namespaces', async () => { const home = await mkdtemp(join(tmpdir(), 'skillhub-remove-home-')) const root = await mkdtemp(join(tmpdir(), 'skillhub-remove-root-')) const globalDir = join(root, 'codex', 'demo') @@ -51,6 +51,47 @@ describe('removeLocalSkill', () => { expect((await store.read()).items).toEqual([]) }) + test('namespace filter removes only the matching same-slug install', async () => { + const home = await mkdtemp(join(tmpdir(), 'skillhub-remove-home-')) + const root = await mkdtemp(join(tmpdir(), 'skillhub-remove-root-')) + const globalDir = join(root, 'codex', 'demo') + const teamDir = join(root, 'claude', 'demo') + await mkdir(globalDir, { recursive: true }) + await mkdir(teamDir, { recursive: true }) + + const store = new InventoryStore(home) + await store.write({ + items: [ + { + registry: 'https://skill.xfyun.cn', + namespace: 'global', + slug: 'demo', + version: '1.0.0', + targets: [{ agent: 'codex', rootDir: join(root, 'codex'), installDir: globalDir, installedAt: '2026-04-20T00:00:00Z' }] + }, + { + registry: 'https://skill.xfyun.cn', + namespace: 'team', + slug: 'demo', + version: '1.0.0', + targets: [{ agent: 'claude-code', rootDir: join(root, 'claude'), installDir: teamDir, installedAt: '2026-04-20T00:00:00Z' }] + } + ] + }) + + const result = await removeLocalSkill({ + registry: 'https://skill.xfyun.cn', + namespace: 'team', + slug: 'demo', + home + }) + + expect(result.removed.map(item => item.namespace)).toEqual(['team']) + expect(await exists(globalDir)).toBe(true) + expect(await exists(teamDir)).toBe(false) + expect((await store.read()).items.map(item => item.namespace)).toEqual(['global']) + }) + test('throws on path traversal in installDir', async () => { const home = await mkdtemp(join(tmpdir(), 'skillhub-remove-traversal-')) diff --git a/docs/skillhub/en/guide/cli.md b/docs/skillhub/en/guide/cli.md index 6cbc779c..116fc70c 100644 --- a/docs/skillhub/en/guide/cli.md +++ b/docs/skillhub/en/guide/cli.md @@ -236,9 +236,17 @@ skillhub list --json ### Remove Skills ```bash -# Remove all local installation targets +# A bare slug removes same-named local installations across namespaces skillhub remove pdf-parser +# An explicit namespaced coordinate removes only that namespace +skillhub remove myspace/pdf-parser +skillhub remove @myspace/pdf-parser +skillhub remove myspace--pdf-parser + +# Equivalent precise local removal with an explicit namespace +skillhub remove pdf-parser --namespace myspace + # Remove only specific Agent's installation skillhub remove pdf-parser --agent codex @@ -499,7 +507,7 @@ Options: ### remove ```bash -skillhub remove [options] +skillhub remove [options] ``` Options: @@ -507,11 +515,16 @@ Options: - `--all` — Remove all targets - `--remote` — Remove remote skill - `--hard` — Skip remote deletion confirmation -- `--namespace ` — Namespace for remote deletion +- `--namespace ` — Namespace for local or remote deletion - `--registry ` — Registry URL - `--token ` — API token - `--json` — JSON output +An explicit namespaced coordinate (`team/my-skill`, `@team/my-skill`, or +`team--my-skill`) or `--namespace team` removes local installations only from +that namespace. For compatibility, a bare slug removes same-named local +installations across all namespaces in the current registry. + ### doctor ```bash diff --git a/docs/skillhub/guide/cli.md b/docs/skillhub/guide/cli.md index 22162d56..6863ee94 100644 --- a/docs/skillhub/guide/cli.md +++ b/docs/skillhub/guide/cli.md @@ -236,9 +236,17 @@ skillhub list --json ### 删除技能 ```bash -# 删除所有本地安装目标 +# 裸 slug 删除所有 namespace 中的同名本地安装 skillhub remove pdf-parser +# 显式 namespace 坐标只删除该 namespace +skillhub remove myspace/pdf-parser +skillhub remove @myspace/pdf-parser +skillhub remove myspace--pdf-parser + +# 使用 namespace 参数进行等价的精确本地删除 +skillhub remove pdf-parser --namespace myspace + # 只删除指定 Agent 的安装 skillhub remove pdf-parser --agent codex @@ -499,7 +507,7 @@ skillhub list [options] ### remove ```bash -skillhub remove [options] +skillhub remove [options] ``` 选项: @@ -507,11 +515,15 @@ skillhub remove [options] - `--all` — 删除所有目标 - `--remote` — 删除远程技能 - `--hard` — 跳过远程删除确认 -- `--namespace ` — 远程删除的 namespace +- `--namespace ` — 本地或远程删除的 namespace - `--registry ` — Registry URL - `--token ` — API token - `--json` — JSON 输出 +显式命名空间坐标(`team/my-skill`、`@team/my-skill`、`team--my-skill`)或 +`--namespace team` 只删除该 namespace 中的本地安装。为保持兼容,裸 slug +会删除当前 registry 中所有 namespace 下的同名本地安装。 + ### doctor ```bash From d977ea9dc41cd226985fee3374a39e669a32fc42 Mon Sep 17 00:00:00 2001 From: gale-popai Date: Tue, 28 Jul 2026 12:42:20 +0300 Subject: [PATCH 74/81] fix(api): tell callers why a request was forbidden (#610) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(api): tell callers why a request was forbidden The scope filter already computes an exact reason ("Missing API token scope: skill:delete", "API token cannot access endpoint: /x") and the access-denied handler discarded it, returning a bare "Forbidden" for every case: missing scope, endpoint closed to API tokens, and paths that simply don't exist. Clients cannot tell those apart, so they guess — the published CLI reports every 403 as "token may lack required scope", which sent us debugging token scopes for an hour when the real causes were a revoked token and a mistyped namespace path. The reason now rides in the response via a new error.forbidden.detail message (en + zh), and is logged alongside the exception type. Signed-off-by: Gal Eyal Co-Authored-By: Claude Fable 5 * fix(api): safely expose API token denial reasons Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --------- Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Co-authored-by: Claude Fable 5 Co-authored-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- cli/src/clients/skillhub-client.ts | 32 +++- cli/src/shared/output.ts | 3 + cli/test/helpers/fake-registry.ts | 8 +- cli/test/integration/publish-dry-run.test.ts | 1 + cli/test/unit/clients/skillhub-client.test.ts | 48 +++++- cli/test/unit/shared/output.test.ts | 2 + docs/03-authentication-design.md | 1 + docs/skillhub/en/guide/cli.md | 2 + docs/skillhub/guide/cli.md | 2 + .../security/ApiAccessDeniedHandler.java | 18 ++- .../src/main/resources/messages.properties | 2 + .../src/main/resources/messages_zh.properties | 2 + .../security/ApiAccessDeniedHandlerTest.java | 137 ++++++++++++++++++ .../token/ApiTokenAccessDeniedException.java | 42 ++++++ .../auth/token/ApiTokenScopeFilter.java | 10 +- .../auth/token/ApiTokenScopeFilterTest.java | 10 ++ 16 files changed, 302 insertions(+), 18 deletions(-) create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/security/ApiAccessDeniedHandlerTest.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenAccessDeniedException.java diff --git a/cli/src/clients/skillhub-client.ts b/cli/src/clients/skillhub-client.ts index 14e14ec3..8d008483 100644 --- a/cli/src/clients/skillhub-client.ts +++ b/cli/src/clients/skillhub-client.ts @@ -52,6 +52,11 @@ export interface DryRunResponse { resolvedVersion: string | null } +interface ErrorEnvelope { + msg?: unknown + requestId?: unknown +} + export class SkillHubClient { constructor( readonly registry: string, @@ -88,9 +93,12 @@ export class SkillHubClient { } catch { throw new CliError('registry unreachable', EXIT.network, { registry: this.registry, next: 'check network or pass --registry' }) } - if (response.status === 401 || response.status === 403) { + if (response.status === 401) { throw new CliError('authentication failed', EXIT.auth, { registry: this.registry, next: 'run `skillhub login`' }) } + if (response.status === 403) { + throw await this.createAccessDeniedError(response) + } if (response.status === 404) { throw new CliError('skill or version not found', EXIT.generic, { registry: this.registry }) } @@ -155,7 +163,7 @@ export class SkillHubClient { throw new CliError('authentication failed', EXIT.auth, { registry: this.registry, next: 'run `skillhub login`' }) } if (response.status === 403) { - throw new CliError('access denied — token may lack required scope', EXIT.auth, { registry: this.registry, next: 'regenerate token with required scopes or run `skillhub login`' }) + throw await this.createAccessDeniedError(response) } if (response.status === 404) { throw new CliError('resource not found', EXIT.generic, { registry: this.registry }) @@ -172,6 +180,26 @@ export class SkillHubClient { return body.data as T } + private async createAccessDeniedError(response: Response): Promise { + const error = await this.readErrorEnvelope(response) + return new CliError(error.message ?? 'access denied', EXIT.auth, { + registry: this.registry, + ...(error.requestId ? { requestId: error.requestId } : {}) + }) + } + + private async readErrorEnvelope(response: Response): Promise<{ message?: string; requestId?: string }> { + try { + const body = await response.json() as ErrorEnvelope + return { + ...(typeof body.msg === 'string' && body.msg.trim() ? { message: body.msg } : {}), + ...(typeof body.requestId === 'string' && body.requestId.trim() ? { requestId: body.requestId } : {}) + } + } catch { + return {} + } + } + private headers(): HeadersInit { return this.token ? { Authorization: `Bearer ${this.token}` } : {} } diff --git a/cli/src/shared/output.ts b/cli/src/shared/output.ts index 9b2eafd9..977116bc 100644 --- a/cli/src/shared/output.ts +++ b/cli/src/shared/output.ts @@ -30,6 +30,9 @@ export function renderError(error: unknown, json: boolean): string { if (typeof cliError.details.path === 'string') { lines.push(`Context: path ${cliError.details.path}`) } + if (typeof cliError.details.requestId === 'string') { + lines.push(`Request ID: ${cliError.details.requestId}`) + } if (typeof cliError.details.next === 'string') { lines.push(`Next: ${cliError.details.next}`) } diff --git a/cli/test/helpers/fake-registry.ts b/cli/test/helpers/fake-registry.ts index a3ea9ef8..4fde3a95 100644 --- a/cli/test/helpers/fake-registry.ts +++ b/cli/test/helpers/fake-registry.ts @@ -22,7 +22,7 @@ export function createFakeRegistry(handlers: Record) { /** * Controls how a specific endpoint behaves when a failure is injected: * 'auth' => 401 { code: 401, message: 'unauthorized' } - * 'forbidden' => 403 { code: 403, message: 'forbidden' } + * 'forbidden' => 403 with a standard SkillHub error envelope * 'not_found' => 404 { code: 404, message: 'not found' } * 'server_error' => 500 { code: 500, message: 'internal error' } * 'network' => handler throws, causing fetch() to reject with a TypeError @@ -34,7 +34,11 @@ function failureResponse(mode: FailureMode): Response { case 'auth': return Response.json({ code: 401, message: 'unauthorized' }, { status: 401 }) case 'forbidden': - return Response.json({ code: 403, message: 'forbidden' }, { status: 403 }) + return Response.json({ + code: 403, + msg: 'API token is missing required scope: skill:publish', + requestId: 'req-test-forbidden' + }, { status: 403 }) case 'not_found': return Response.json({ code: 404, message: 'not found' }, { status: 404 }) case 'server_error': diff --git a/cli/test/integration/publish-dry-run.test.ts b/cli/test/integration/publish-dry-run.test.ts index deabb7b2..456572d1 100644 --- a/cli/test/integration/publish-dry-run.test.ts +++ b/cli/test/integration/publish-dry-run.test.ts @@ -172,5 +172,6 @@ describe('publish --dry-run', () => { expect(result.exitCode).toBe(2) expect(result.stderr).toContain('scope') + expect(result.stderr).toContain('Request ID: req-test-forbidden') }) }) diff --git a/cli/test/unit/clients/skillhub-client.test.ts b/cli/test/unit/clients/skillhub-client.test.ts index c07083c2..e545e99f 100644 --- a/cli/test/unit/clients/skillhub-client.test.ts +++ b/cli/test/unit/clients/skillhub-client.test.ts @@ -37,12 +37,21 @@ describe('SkillHubClient', () => { }) test('download() throws auth error on 403', async () => { - const fetchImpl = (async () => new Response(null, { status: 403 })) as unknown as typeof fetch + const fetchImpl = (async () => Response.json({ + code: 403, + msg: 'API token is missing required scope: skill:read', + requestId: 'req-download' + }, { status: 403 })) as unknown as typeof fetch const client = new SkillHubClient('http://registry.test', 'token', fetchImpl) - const err = expect(client.download('ns', 'slug')).rejects - await err.toBeInstanceOf(CliError) - await err.toHaveProperty('message', 'authentication failed') - await err.toHaveProperty('exitCode', EXIT.auth) + + await expect(client.download('ns', 'slug')).rejects.toMatchObject({ + message: 'API token is missing required scope: skill:read', + exitCode: EXIT.auth, + details: { + registry: 'http://registry.test', + requestId: 'req-download' + } + }) }) test('download() throws not-found error on 404', async () => { @@ -159,6 +168,35 @@ describe('SkillHubClient', () => { // --- handleJsonResponse() non-2xx classification --- + test('whoami() surfaces server reason and request ID on 403', async () => { + const fetchImpl = (async () => Response.json({ + code: 403, + msg: 'API token cannot access endpoint: /api/cli/v1/whoami', + requestId: 'req-610' + }, { status: 403 })) as unknown as typeof fetch + const client = new SkillHubClient('http://registry.test', 'token', fetchImpl) + + await expect(client.whoami()).rejects.toMatchObject({ + message: 'API token cannot access endpoint: /api/cli/v1/whoami', + exitCode: EXIT.auth, + details: { + registry: 'http://registry.test', + requestId: 'req-610' + } + }) + }) + + test('whoami() falls back to generic access denied when 403 body is invalid', async () => { + const fetchImpl = (async () => new Response('not-json', { status: 403 })) as unknown as typeof fetch + const client = new SkillHubClient('http://registry.test', 'token', fetchImpl) + + await expect(client.whoami()).rejects.toMatchObject({ + message: 'access denied', + exitCode: EXIT.auth, + details: { registry: 'http://registry.test' } + }) + }) + test('whoami() throws generic error on 500', async () => { const fetchImpl = (async () => new Response(null, { status: 500 })) as unknown as typeof fetch const client = new SkillHubClient('http://registry.test', 'token', fetchImpl) diff --git a/cli/test/unit/shared/output.test.ts b/cli/test/unit/shared/output.test.ts index 8d051172..d71bcce0 100644 --- a/cli/test/unit/shared/output.test.ts +++ b/cli/test/unit/shared/output.test.ts @@ -16,11 +16,13 @@ describe('renderError', () => { test('renders human error without stack trace', () => { const error = new CliError('registry unreachable', 3, { registry: 'https://registry.example.com', + requestId: 'req-610', next: 'check network or pass --registry' }) expect(renderError(error, false)).toBe([ 'Error: registry unreachable', 'Context: registry https://registry.example.com', + 'Request ID: req-610', 'Next: check network or pass --registry' ].join('\n')) }) diff --git a/docs/03-authentication-design.md b/docs/03-authentication-design.md index b4fb0cd1..2be902e8 100644 --- a/docs/03-authentication-design.md +++ b/docs/03-authentication-design.md @@ -379,6 +379,7 @@ API Token 仍保留,但定位从“CLI 唯一认证方式”调整为“平台 - 校验:从 `Authorization: Bearer ` 提取 → 哈希比对 → 加载关联用户 → 检查用户状态 - 失败闭合:公共读接口只有在缺少 `Authorization` 头时才按匿名访问处理;只要出现 Bearer 凭证,空值、格式错误、未知、过期、已吊销、用户缺失或用户禁用均返回 401,不能回退为匿名访问 - 作用域:`skill:read`, `skill:publish`, `skill:delete`, `token:manage` +- 拒绝原因:API Token 缺少作用域或不能访问某个接口时,403 响应返回本地化的安全原因和 `requestId`;其他授权失败仍返回通用信息,避免暴露内部异常 > **一期作用域说明(非最小权限)**:一期 Token 作用域为粗粒度动作级别,不与 namespace 绑定。Token 继承用户的全部权限——如果用户是某个 namespace 的 MEMBER,则该用户的任何 Token(只要包含 `skill:publish` scope)都可以向该 namespace 发布技能。这是有意的一期简化,不满足最小权限原则。后续版本计划引入 namespace 级别的 Token 作用域限定(如 `namespace:ai-team:skill:publish`),或通过 `api_token_scope` 子表实现 Token 与 namespace 的绑定。 diff --git a/docs/skillhub/en/guide/cli.md b/docs/skillhub/en/guide/cli.md index 6cbc779c..e4fa2842 100644 --- a/docs/skillhub/en/guide/cli.md +++ b/docs/skillhub/en/guide/cli.md @@ -83,6 +83,8 @@ skillhub login --token sk_xxx --registry https://skillhub.example.com `login` validates the token, stores it in `~/.skillhub/credentials.json`, and writes the registry to `~/.skillhub/config.json`. +When an API-token request is denied, the CLI shows the safe reason returned by the server and its `Request ID`. Use that ID to correlate the failure with server logs. Other authorization failures continue to use a generic message. + ### Check Current Identity ```bash diff --git a/docs/skillhub/guide/cli.md b/docs/skillhub/guide/cli.md index 22162d56..dfadf6d3 100644 --- a/docs/skillhub/guide/cli.md +++ b/docs/skillhub/guide/cli.md @@ -83,6 +83,8 @@ skillhub login --token sk_xxx --registry https://skillhub.example.com `login` 会验证 token 有效性,然后将 token 存储到 `~/.skillhub/credentials.json`,同时将 registry 写入 `~/.skillhub/config.json`。 +API Token 请求被拒绝时,CLI 会显示服务端返回的具体原因和 `Request ID`。排查问题时可使用该 ID 对照服务端日志;非 API Token 的授权失败仍只显示通用信息。 + ### 查看当前身份 ```bash diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAccessDeniedHandler.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAccessDeniedHandler.java index 81cebbde..2c930aa6 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAccessDeniedHandler.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAccessDeniedHandler.java @@ -1,6 +1,7 @@ package com.iflytek.skillhub.security; import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.auth.token.ApiTokenAccessDeniedException; import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; import jakarta.servlet.http.HttpServletRequest; @@ -38,14 +39,25 @@ public class ApiAccessDeniedHandler implements AccessDeniedHandler { public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException { + ApiTokenAccessDeniedException apiTokenException = + accessDeniedException instanceof ApiTokenAccessDeniedException typedException + ? typedException + : null; logger.info( - "Forbidden API request [requestId={}, method={}, path={}, reason={}]", + "Forbidden API request [requestId={}, method={}, path={}, reason={}, detail={}]", MDC.get("requestId"), request.getMethod(), sensitiveLogSanitizer.sanitizeRequestTarget(request), - accessDeniedException.getClass().getSimpleName() + accessDeniedException.getClass().getSimpleName(), + apiTokenException != null ? apiTokenException.getMessage() : null ); - ApiResponse body = apiResponseFactory.error(403, "error.forbidden"); + ApiResponse body = apiTokenException != null + ? apiResponseFactory.error( + 403, + apiTokenException.getMessageCode(), + apiTokenException.getMessageArgs() + ) + : apiResponseFactory.error(403, "error.forbidden"); response.setStatus(HttpServletResponse.SC_FORBIDDEN); response.setContentType(MediaType.APPLICATION_JSON_VALUE); objectMapper.writeValue(response.getOutputStream(), body); diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index 19189122..79195af7 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -48,6 +48,8 @@ error.auth.sessionBootstrap.providerUnsupported=Unsupported session bootstrap pr error.auth.sessionBootstrap.notAuthenticated=No authenticated external session found error.badRequest=Invalid request error.forbidden=Forbidden +error.apiToken.scope.missing=API token is missing required scope: {0} +error.apiToken.endpoint.unsupported=API token cannot access endpoint: {0} error.request.timeout=Request timed out error.rateLimit.exceeded=Rate limit exceeded error.storage.unavailable=Object storage is temporarily unavailable. Please try again later. diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index 6885dfd3..d7b6b11b 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -48,6 +48,8 @@ error.auth.sessionBootstrap.providerUnsupported=不支持的会话引导提供 error.auth.sessionBootstrap.notAuthenticated=未检测到已认证的外部会话 error.badRequest=请求参数不合法 error.forbidden=没有权限执行该操作 +error.apiToken.scope.missing=API 令牌缺少所需权限范围:{0} +error.apiToken.endpoint.unsupported=API 令牌无法访问接口:{0} error.request.timeout=请求超时 error.rateLimit.exceeded=请求过于频繁,请稍后再试 error.storage.unavailable=对象存储暂时不可用,请稍后再试 diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/security/ApiAccessDeniedHandlerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/security/ApiAccessDeniedHandlerTest.java new file mode 100644 index 00000000..db8982b7 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/security/ApiAccessDeniedHandlerTest.java @@ -0,0 +1,137 @@ +package com.iflytek.skillhub.security; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.auth.token.ApiTokenScopeFilter; +import com.iflytek.skillhub.auth.token.ApiTokenScopeService; +import com.iflytek.skillhub.auth.policy.RouteSecurityPolicyRegistry; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.dto.ApiResponseFactory; +import jakarta.servlet.FilterChain; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.MDC; +import org.springframework.context.i18n.LocaleContextHolder; +import org.springframework.context.support.ResourceBundleMessageSource; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; + +class ApiAccessDeniedHandlerTest { + + private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules(); + private ApiAccessDeniedHandler handler; + + @BeforeEach + void setUp() { + ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource(); + messageSource.setBasename("messages"); + messageSource.setDefaultEncoding("UTF-8"); + ApiResponseFactory responseFactory = new ApiResponseFactory( + messageSource, + Clock.fixed(Instant.parse("2026-07-28T00:00:00Z"), ZoneOffset.UTC) + ); + handler = new ApiAccessDeniedHandler( + objectMapper, + responseFactory, + new SensitiveLogSanitizer() + ); + MDC.put("requestId", "req-610"); + LocaleContextHolder.setLocale(Locale.ENGLISH); + } + + @AfterEach + void tearDown() { + MDC.clear(); + LocaleContextHolder.resetLocaleContext(); + SecurityContextHolder.clearContext(); + } + + @Test + void shouldExposeLocalizedApiTokenScopeReasonAndRequestId() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/v1/publish"); + MockHttpServletResponse response = new MockHttpServletResponse(); + ApiTokenScopeService scopeService = + new ApiTokenScopeService(objectMapper, new RouteSecurityPolicyRegistry()); + ApiTokenScopeFilter filter = new ApiTokenScopeFilter(scopeService, handler); + PlatformPrincipal principal = new PlatformPrincipal( + "user-1", + "Alice", + "alice@example.com", + "", + "api_token", + Set.of("USER") + ); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken( + principal, + null, + List.of(new SimpleGrantedAuthority("SCOPE_skill:read")) + ) + ); + FilterChain chain = (servletRequest, servletResponse) -> { + throw new AssertionError("Denied request must not continue"); + }; + + filter.doFilter(request, response, chain); + + JsonNode body = objectMapper.readTree(response.getContentAsByteArray()); + assertThat(response.getStatus()).isEqualTo(403); + assertThat(body.path("msg").asText()) + .isEqualTo("API token is missing required scope: skill:publish"); + assertThat(body.path("requestId").asText()).isEqualTo("req-610"); + } + + @Test + void shouldTranslateSafeApiTokenReason() throws Exception { + LocaleContextHolder.setLocale(Locale.SIMPLIFIED_CHINESE); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/cli/v1/whoami"); + MockHttpServletResponse response = new MockHttpServletResponse(); + ApiTokenScopeService scopeService = + new ApiTokenScopeService(objectMapper, new RouteSecurityPolicyRegistry()); + ApiTokenScopeFilter filter = new ApiTokenScopeFilter(scopeService, handler); + PlatformPrincipal principal = new PlatformPrincipal( + "user-1", + "Alice", + "alice@example.com", + "", + "api_token", + Set.of("USER") + ); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(principal, null, List.of()) + ); + + filter.doFilter(request, response, (servletRequest, servletResponse) -> { + throw new AssertionError("Denied request must not continue"); + }); + + JsonNode body = objectMapper.readTree(response.getContentAsByteArray()); + assertThat(body.path("msg").asText()) + .isEqualTo("API 令牌无法访问接口:/api/cli/v1/whoami"); + } + + @Test + void shouldHideGenericAccessDeniedExceptionMessage() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/v1/admin"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + handler.handle(request, response, new AccessDeniedException("internal authorization detail")); + + JsonNode body = objectMapper.readTree(response.getContentAsByteArray()); + assertThat(body.path("msg").asText()).isEqualTo("Forbidden"); + assertThat(response.getContentAsString()).doesNotContain("internal authorization detail"); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenAccessDeniedException.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenAccessDeniedException.java new file mode 100644 index 00000000..62646a53 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenAccessDeniedException.java @@ -0,0 +1,42 @@ +package com.iflytek.skillhub.auth.token; + +import org.springframework.security.access.AccessDeniedException; + +/** + * Marks an API-token authorization failure whose structured reason is safe to expose to clients. + */ +public final class ApiTokenAccessDeniedException extends AccessDeniedException { + + private final String messageCode; + private final Object[] messageArgs; + + private ApiTokenAccessDeniedException(String logMessage, String messageCode, Object... messageArgs) { + super(logMessage); + this.messageCode = messageCode; + this.messageArgs = messageArgs.clone(); + } + + static ApiTokenAccessDeniedException missingScope(String requiredScope) { + return new ApiTokenAccessDeniedException( + "Missing API token scope: " + requiredScope, + "error.apiToken.scope.missing", + requiredScope + ); + } + + static ApiTokenAccessDeniedException unsupportedEndpoint(String path) { + return new ApiTokenAccessDeniedException( + "API token cannot access endpoint: " + path, + "error.apiToken.endpoint.unsupported", + path + ); + } + + public String getMessageCode() { + return messageCode; + } + + public Object[] getMessageArgs() { + return messageArgs.clone(); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenScopeFilter.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenScopeFilter.java index 97145f5d..5182ce7f 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenScopeFilter.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenScopeFilter.java @@ -5,7 +5,6 @@ import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import org.springframework.security.access.AccessDeniedException; import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; @@ -59,11 +58,10 @@ public class ApiTokenScopeFilter extends OncePerRequestFilter { return; } - accessDeniedHandler.handle( - request, - response, - new AccessDeniedException(decision.message()) - ); + ApiTokenAccessDeniedException exception = decision.requiredScope() != null + ? ApiTokenAccessDeniedException.missingScope(decision.requiredScope()) + : ApiTokenAccessDeniedException.unsupportedEndpoint(request.getRequestURI()); + accessDeniedHandler.handle(request, response, exception); } @Override diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenScopeFilterTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenScopeFilterTest.java index 788e0291..085016f4 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenScopeFilterTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenScopeFilterTest.java @@ -17,8 +17,10 @@ import org.springframework.security.web.access.AccessDeniedHandler; import java.util.List; import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; @@ -38,7 +40,9 @@ class ApiTokenScopeFilterTest { @Test void shouldDenyApiTokenWithoutRequiredScope() throws Exception { + AtomicReference deniedException = new AtomicReference<>(); AccessDeniedHandler handler = (request, response, accessDeniedException) -> { + deniedException.set(accessDeniedException); response.sendError(HttpServletResponse.SC_FORBIDDEN, accessDeniedException.getMessage()); }; ApiTokenScopeFilter filter = new ApiTokenScopeFilter(scopeService, handler); @@ -69,6 +73,12 @@ class ApiTokenScopeFilterTest { assertEquals(HttpServletResponse.SC_FORBIDDEN, response.getStatus()); assertTrue(response.getErrorMessage().contains("Missing API token scope: skill:publish")); + ApiTokenAccessDeniedException exception = assertInstanceOf( + ApiTokenAccessDeniedException.class, + deniedException.get() + ); + assertEquals("error.apiToken.scope.missing", exception.getMessageCode()); + assertEquals("skill:publish", exception.getMessageArgs()[0]); verify(chain, never()).doFilter(request, response); } From e4fb26d4ba7067201ae6d76ea3392a902e8d175b Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Tue, 28 Jul 2026 20:03:50 +0800 Subject: [PATCH 75/81] fix(nginx): trust forwarded proto only when configured Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- .env.release.draft | 3 + .env.release.example | 3 + .github/workflows/pr-scripts.yml | 3 + compose.release.yml | 1 + docs/09-deployment.md | 3 + scripts/tests/nginx-forwarded-proto-test.sh | 101 ++++++++++++++++++ scripts/tests/validate-release-config-test.sh | 6 ++ scripts/tests/workflow-security-test.sh | 6 ++ scripts/validate-release-config.sh | 1 + web/Dockerfile | 1 + web/nginx.conf.template | 14 ++- 11 files changed, 140 insertions(+), 2 deletions(-) create mode 100755 scripts/tests/nginx-forwarded-proto-test.sh diff --git a/.env.release.draft b/.env.release.draft index c8f0872b..417aab30 100644 --- a/.env.release.draft +++ b/.env.release.draft @@ -18,6 +18,9 @@ SKILLHUB_PUBLIC_BASE_URL=https://skillhub.example.com # Usually keep empty when web and api are served from the same domain. SKILLHUB_WEB_API_BASE_URL= SKILLHUB_API_UPSTREAM=http://server:8080 +# Enable only when a trusted TLS-terminating proxy replaces X-Forwarded-Proto +# and the web container cannot be reached directly. +SKILLHUB_TRUST_FORWARDED_PROTO=false # Keep database and redis local-only on the host unless you explicitly need remote access. POSTGRES_BIND_ADDRESS=127.0.0.1 diff --git a/.env.release.example b/.env.release.example index 2d30c7bc..d038d6e1 100644 --- a/.env.release.example +++ b/.env.release.example @@ -15,6 +15,9 @@ SKILLHUB_PUBLIC_BASE_URL=http://localhost # Frontend usually keeps this empty and proxies to the backend through nginx. SKILLHUB_WEB_API_BASE_URL= SKILLHUB_API_UPSTREAM=http://server:8080 +# Keep false for direct exposure. Enable only behind a trusted proxy that replaces +# X-Forwarded-Proto and blocks direct access to the web container. +SKILLHUB_TRUST_FORWARDED_PROTO=false POSTGRES_BIND_ADDRESS=127.0.0.1 POSTGRES_PORT=5432 diff --git a/.github/workflows/pr-scripts.yml b/.github/workflows/pr-scripts.yml index e521eb9e..082ce102 100644 --- a/.github/workflows/pr-scripts.yml +++ b/.github/workflows/pr-scripts.yml @@ -8,6 +8,8 @@ on: - '.env.release.draft' - 'compose.release.yml' - 'Makefile' + - 'web/Dockerfile' + - 'web/nginx.conf.template' - '.github/workflows/pr-cli.yml' - '.github/workflows/pr-e2e.yml' - '.github/workflows/pr-tests.yml' @@ -33,5 +35,6 @@ jobs: - run: bash scripts/tests/publish-cli-test.sh - run: bash scripts/tests/runtime-secret-test.sh - run: bash scripts/tests/validate-release-config-test.sh + - run: bash scripts/tests/nginx-forwarded-proto-test.sh - run: bash scripts/tests/dev-web-host-test.sh - run: bash scripts/tests/workflow-security-test.sh diff --git a/compose.release.yml b/compose.release.yml index 5ed7086e..69c07496 100644 --- a/compose.release.yml +++ b/compose.release.yml @@ -116,6 +116,7 @@ services: - "${WEB_PORT:-80}:80" environment: SKILLHUB_API_UPSTREAM: ${SKILLHUB_API_UPSTREAM:-http://server:8080} + SKILLHUB_TRUST_FORWARDED_PROTO: ${SKILLHUB_TRUST_FORWARDED_PROTO:-false} SKILLHUB_WEB_API_BASE_URL: ${SKILLHUB_WEB_API_BASE_URL:-} SKILLHUB_PUBLIC_BASE_URL: ${SKILLHUB_PUBLIC_BASE_URL:-} SKILLHUB_WEB_AUTH_DIRECT_ENABLED: ${SKILLHUB_WEB_AUTH_DIRECT_ENABLED:-false} diff --git a/docs/09-deployment.md b/docs/09-deployment.md index fe631d80..48c5d9d0 100644 --- a/docs/09-deployment.md +++ b/docs/09-deployment.md @@ -194,6 +194,9 @@ docker compose --env-file .env.release -f compose.release.yml up -d - 推荐将敏感变量放入 CI/CD Secret 或主机上的受控 `.env.release` - 外部对象存储通过 `SKILLHUB_STORAGE_S3_*` 注入 - 前端反代和运行时 API 地址通过 `SKILLHUB_API_UPSTREAM` / `SKILLHUB_WEB_API_BASE_URL` 注入 +- `SKILLHUB_TRUST_FORWARDED_PROTO` 默认保持 `false`。只有 Web 容器仅能经由可信 + TLS 终止代理访问,且该代理会覆盖客户端传入的 `X-Forwarded-Proto` 时才设为 + `true`;否则客户端可伪造协议并影响 OAuth 回调、重定向和安全 Cookie 判断 - 如果要开放真实登录,再补充 `OAUTH2_GITHUB_CLIENT_ID` / `OAUTH2_GITHUB_CLIENT_SECRET` - 如果要启用密码重置验证码邮件,参见:`docs/19-smtp-password-reset-email-setup.md` diff --git a/scripts/tests/nginx-forwarded-proto-test.sh b/scripts/tests/nginx-forwarded-proto-test.sh new file mode 100755 index 00000000..01be85c3 --- /dev/null +++ b/scripts/tests/nginx-forwarded-proto-test.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +TEMPLATE="$REPO_ROOT/web/nginx.conf.template" +NGINX_IMAGE="${NGINX_TEST_IMAGE:-nginx:alpine}" +TEST_ID="skillhub-nginx-forwarded-proto-$$" +NETWORK="${TEST_ID}-network" +BACKEND="${TEST_ID}-backend" +DEFAULT_PROXY="${TEST_ID}-default" +TRUSTED_PROXY="${TEST_ID}-trusted" +TMP_DIR="$(mktemp -d)" +CONTAINERS=() + +cleanup() { + if ((${#CONTAINERS[@]} > 0)); then + docker rm -f "${CONTAINERS[@]}" >/dev/null 2>&1 || true + fi + docker network rm "$NETWORK" >/dev/null 2>&1 || true + rm -rf "$TMP_DIR" +} +trap cleanup EXIT + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +wait_for_nginx() { + local container="$1" + local attempt + for attempt in {1..30}; do + if docker exec "$container" wget -qO- http://127.0.0.1/nginx-health >/dev/null 2>&1; then + return 0 + fi + sleep 0.2 + done + docker logs "$container" >&2 || true + fail "$container did not become healthy" +} + +start_proxy() { + local container="$1" + local trust_forwarded_proto="$2" + docker run --detach \ + --name "$container" \ + --network "$NETWORK" \ + --env "SKILLHUB_API_UPSTREAM=http://$BACKEND:8080" \ + --env "SKILLHUB_TRUST_FORWARDED_PROTO=$trust_forwarded_proto" \ + --volume "$TEMPLATE:/etc/nginx/templates/default.conf.template:ro" \ + "$NGINX_IMAGE" >/dev/null + CONTAINERS+=("$container") + wait_for_nginx "$container" +} + +assert_proto() { + local container="$1" + local expected="$2" + local header="${3:-}" + local path="${4:-/api/proto}" + local actual + if [[ -n "$header" ]]; then + actual="$(docker exec "$container" wget -qO- \ + --header="X-Forwarded-Proto: $header" \ + "http://127.0.0.1$path")" + else + actual="$(docker exec "$container" wget -qO- "http://127.0.0.1$path")" + fi + [[ "$actual" == "$expected" ]] \ + || fail "$container forwarded proto '$actual', expected '$expected' for $path with header '${header:-}'" +} + +cat >"$TMP_DIR/backend.conf" <<'EOF' +server { + listen 8080; + location / { + default_type text/plain; + return 200 $http_x_forwarded_proto; + } +} +EOF + +docker network create "$NETWORK" >/dev/null +docker run --detach \ + --name "$BACKEND" \ + --network "$NETWORK" \ + --volume "$TMP_DIR/backend.conf:/etc/nginx/conf.d/default.conf:ro" \ + "$NGINX_IMAGE" >/dev/null +CONTAINERS+=("$BACKEND") + +start_proxy "$DEFAULT_PROXY" false +start_proxy "$TRUSTED_PROXY" true + +for path in /api/proto /oauth2/proto /login/oauth2/proto /.well-known/proto; do + assert_proto "$DEFAULT_PROXY" http https "$path" + assert_proto "$TRUSTED_PROXY" https https "$path" +done +assert_proto "$TRUSTED_PROXY" http +assert_proto "$TRUSTED_PROXY" http "https,http" + +echo "nginx-forwarded-proto-test passed" diff --git a/scripts/tests/validate-release-config-test.sh b/scripts/tests/validate-release-config-test.sh index fd76ddfc..d94ed62c 100755 --- a/scripts/tests/validate-release-config-test.sh +++ b/scripts/tests/validate-release-config-test.sh @@ -36,6 +36,7 @@ POSTGRES_USER=skillhub POSTGRES_PASSWORD=strong-postgres-password SESSION_COOKIE_SECURE=true BOOTSTRAP_ADMIN_ENABLED=false +SKILLHUB_TRUST_FORWARDED_PROTO=false SKILLHUB_STORAGE_PROVIDER=s3 SKILLHUB_STORAGE_S3_ENDPOINT=https://storage.example.com SKILLHUB_STORAGE_S3_BUCKET=skillhub @@ -80,6 +81,11 @@ short_env="$tmp/short.env" write_env "$short_env" "too-short" expect_fail "$short_env" "SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET must be at least 32 characters" +invalid_forwarded_proto_env="$tmp/invalid-forwarded-proto.env" +write_env "$invalid_forwarded_proto_env" "release-download-secret-32-bytes-minimum" +printf '%s\n' "SKILLHUB_TRUST_FORWARDED_PROTO=yes" >>"$invalid_forwarded_proto_env" +expect_fail "$invalid_forwarded_proto_env" "SKILLHUB_TRUST_FORWARDED_PROTO must be true or false" + draft_env="$tmp/draft.env" while IFS= read -r line || [[ -n "$line" ]]; do case "$line" in diff --git a/scripts/tests/workflow-security-test.sh b/scripts/tests/workflow-security-test.sh index 9a1688ef..ec1f70ce 100755 --- a/scripts/tests/workflow-security-test.sh +++ b/scripts/tests/workflow-security-test.sh @@ -64,8 +64,14 @@ grep -Fq '.env.release.draft' "$PR_SCRIPTS_WORKFLOW" \ || fail "pr-scripts must run when release env draft changes" grep -Fq 'compose.release.yml' "$PR_SCRIPTS_WORKFLOW" \ || fail "pr-scripts must run when release compose changes" +grep -Fq 'web/Dockerfile' "$PR_SCRIPTS_WORKFLOW" \ + || fail "pr-scripts must run when the web image changes" +grep -Fq 'web/nginx.conf.template' "$PR_SCRIPTS_WORKFLOW" \ + || fail "pr-scripts must run when the nginx template changes" grep -Fq 'bash scripts/tests/validate-release-config-test.sh' "$PR_SCRIPTS_WORKFLOW" \ || fail "pr-scripts must run validate-release-config-test" +grep -Fq 'bash scripts/tests/nginx-forwarded-proto-test.sh' "$PR_SCRIPTS_WORKFLOW" \ + || fail "pr-scripts must run nginx-forwarded-proto-test" grep -Fq 'bash scripts/tests/runtime-secret-test.sh' "$PR_SCRIPTS_WORKFLOW" \ || fail "pr-scripts must run runtime-secret-test" grep -Fq 'bash scripts/tests/dev-web-host-test.sh' "$PR_SCRIPTS_WORKFLOW" \ diff --git a/scripts/validate-release-config.sh b/scripts/validate-release-config.sh index 03e42bd8..27e9d042 100755 --- a/scripts/validate-release-config.sh +++ b/scripts/validate-release-config.sh @@ -151,6 +151,7 @@ reject_patterns SPRING_MAIL_PASSWORD "TODO_*" "todo_*" "replace*" validate_boolean SESSION_COOKIE_SECURE validate_boolean BOOTSTRAP_ADMIN_ENABLED +validate_boolean SKILLHUB_TRUST_FORWARDED_PROTO validate_boolean SKILLHUB_STORAGE_S3_FORCE_PATH_STYLE validate_boolean SKILLHUB_STORAGE_S3_AUTO_CREATE_BUCKET diff --git a/web/Dockerfile b/web/Dockerfile index e301f7c8..2ed67ae0 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -7,6 +7,7 @@ COPY . . RUN pnpm build FROM nginx:alpine +ENV SKILLHUB_TRUST_FORWARDED_PROTO=false COPY --from=build /app/dist /usr/share/nginx/html COPY --from=build /app/src/docs/skill.md.template /usr/share/nginx/html/registry/skill.md.template COPY nginx.conf.template /etc/nginx/templates/default.conf.template diff --git a/web/nginx.conf.template b/web/nginx.conf.template index be2a51a2..25db2869 100644 --- a/web/nginx.conf.template +++ b/web/nginx.conf.template @@ -10,9 +10,15 @@ server { gzip_types text/plain text/css application/json application/javascript text/xml; gzip_min_length 1000; + # Ignore client-supplied forwarded proto by default. Operators may explicitly trust a + # sanitizing upstream proxy; only canonical http/https values are then accepted. set $proxy_x_forwarded_proto $scheme; - if ($http_x_forwarded_proto) { - set $proxy_x_forwarded_proto $http_x_forwarded_proto; + set $forwarded_proto_source "${SKILLHUB_TRUST_FORWARDED_PROTO}:$http_x_forwarded_proto"; + if ($forwarded_proto_source ~* "^true:https$") { + set $proxy_x_forwarded_proto https; + } + if ($forwarded_proto_source ~* "^true:http$") { + set $proxy_x_forwarded_proto http; } location / { @@ -31,6 +37,7 @@ server { proxy_pass ${SKILLHUB_API_UPSTREAM}; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } @@ -38,12 +45,15 @@ server { proxy_pass ${SKILLHUB_API_UPSTREAM}; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } location /.well-known/ { proxy_pass ${SKILLHUB_API_UPSTREAM}; proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } From 155ab8f6d5091dc406a4bba504e46c7a87d75095 Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Tue, 28 Jul 2026 22:16:45 +0800 Subject: [PATCH 76/81] fix(auth): hide placeholder OAuth providers Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- .../skillhub/service/AuthMethodCatalog.java | 7 ++-- .../controller/AuthControllerTest.java | 16 ++++------ .../service/AuthMethodCatalogTest.java | 32 +++++++++++++++++++ 3 files changed, 41 insertions(+), 14 deletions(-) diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AuthMethodCatalog.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AuthMethodCatalog.java index cc927801..8c63cb9d 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AuthMethodCatalog.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AuthMethodCatalog.java @@ -12,6 +12,7 @@ import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Comparator; import java.util.List; +import java.util.Locale; import org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientProperties; import org.springframework.stereotype.Service; @@ -56,16 +57,14 @@ public class AuthMethodCatalog { } /** - * Check if an OAuth provider has valid configuration (non-empty client-id that is not a placeholder). + * Checks whether an OAuth provider has a non-empty, non-placeholder client ID. */ private boolean isValidOAuthProvider(OAuth2ClientProperties.Registration registration) { String clientId = registration.getClientId(); if (clientId == null || clientId.isBlank()) { return false; } - // Filter out placeholder values used in dev/test configs - String lowerClientId = clientId.toLowerCase(); - return !lowerClientId.contains("placeholder") && !lowerClientId.contains("local-placeholder"); + return !clientId.toLowerCase(Locale.ROOT).contains("placeholder"); } public List listMethods(String returnTo) { diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java index d79d368a..8e4118c6 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java @@ -150,13 +150,9 @@ class AuthControllerTest { mockMvc.perform(get("/api/v1/auth/providers")) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) - .andExpect(jsonPath("$.data.length()").value(3)) - .andExpect(jsonPath("$.data[*].id", hasItems("github", "gitee", "gitlab"))) - .andExpect(jsonPath("$.data[*].authorizationUrl", hasItems( - "/oauth2/authorization/github", - "/oauth2/authorization/gitee", - "/oauth2/authorization/gitlab" - ))) + .andExpect(jsonPath("$.data.length()").value(1)) + .andExpect(jsonPath("$.data[*].id", hasItems("github"))) + .andExpect(jsonPath("$.data[*].authorizationUrl", hasItems("/oauth2/authorization/github"))) .andExpect(jsonPath("$.timestamp").isNotEmpty()) .andExpect(jsonPath("$.requestId").isNotEmpty()); } @@ -167,8 +163,7 @@ class AuthControllerTest { .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.data[*].authorizationUrl", hasItems( - "/oauth2/authorization/github?returnTo=%2Fdashboard%2Fpublish", - "/oauth2/authorization/gitee?returnTo=%2Fdashboard%2Fpublish" + "/oauth2/authorization/github?returnTo=%2Fdashboard%2Fpublish" ))); } @@ -177,7 +172,8 @@ class AuthControllerTest { mockMvc.perform(get("/api/v1/auth/methods").param("returnTo", "/dashboard/publish")) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) - .andExpect(jsonPath("$.data[*].id", hasItems("local-password", "oauth-github", "oauth-gitee"))) + .andExpect(jsonPath("$.data.length()").value(2)) + .andExpect(jsonPath("$.data[*].id", hasItems("local-password", "oauth-github"))) .andExpect(jsonPath("$.data[?(@.id=='local-password')].methodType").value(hasItems("PASSWORD"))) .andExpect(jsonPath("$.data[?(@.id=='oauth-github')].actionUrl") .value(hasItems("/oauth2/authorization/github?returnTo=%2Fdashboard%2Fpublish"))); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AuthMethodCatalogTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AuthMethodCatalogTest.java index 35ca8d75..e9ef372f 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AuthMethodCatalogTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AuthMethodCatalogTest.java @@ -16,6 +16,31 @@ import org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2Clien class AuthMethodCatalogTest { + @Test + void catalogsShouldHideEmptyAndPlaceholderOAuthProviders() { + OAuth2ClientProperties oauthProperties = new OAuth2ClientProperties(); + oauthProperties.getRegistration().put("valid", registration("production-client", "Valid")); + oauthProperties.getRegistration().put("missing", registration(null, "Missing")); + oauthProperties.getRegistration().put("blank", registration(" ", "Blank")); + oauthProperties.getRegistration().put("placeholder", registration("PLACEHOLDER", "Placeholder")); + oauthProperties.getRegistration().put("local", registration("local-placeholder", "Local")); + + AuthMethodCatalog catalog = new AuthMethodCatalog( + oauthProperties, + new DirectAuthProperties(), + new AuthSessionBootstrapProperties(), + List.of(), + List.of() + ); + + assertThat(catalog.listOAuthProviders(null)) + .extracting(provider -> provider.id()) + .containsExactly("valid"); + assertThat(catalog.listMethods(null)) + .extracting(method -> method.id()) + .containsExactly("local-password", "oauth-valid"); + } + @Test void listMethodsShouldUseProviderDisplayNamesForCompatibleAuthMethods() { OAuth2ClientProperties oauthProperties = new OAuth2ClientProperties(); @@ -122,4 +147,11 @@ class AuthMethodCatalogTest { "bootstrap-private-sso:private-sso" ); } + + private static OAuth2ClientProperties.Registration registration(String clientId, String clientName) { + OAuth2ClientProperties.Registration registration = new OAuth2ClientProperties.Registration(); + registration.setClientId(clientId); + registration.setClientName(clientName); + return registration; + } } From a9007a4e8c2b862007a5fbf63bf93425e0c0363e Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Wed, 29 Jul 2026 10:31:49 +0800 Subject: [PATCH 77/81] fix(cli): preserve download error contract (#606) Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- cli/src/clients/skillhub-client.ts | 4 +- cli/test/unit/clients/skillhub-client.test.ts | 104 ++++++++++++++++-- docs/skillhub/en/guide/cli.md | 11 +- docs/skillhub/guide/cli.md | 10 +- .../plans/2026-07-28-cli-namespace-errors.md | 10 +- 5 files changed, 122 insertions(+), 17 deletions(-) diff --git a/cli/src/clients/skillhub-client.ts b/cli/src/clients/skillhub-client.ts index c8ee09ae..1842a178 100644 --- a/cli/src/clients/skillhub-client.ts +++ b/cli/src/clients/skillhub-client.ts @@ -179,7 +179,9 @@ export class SkillHubClient { } else if (response.status === 404) { fallback = kind === 'download' ? 'skill or version not found' : 'resource not found' } else if (response.status === 502 || response.status === 503) { - fallback = `registry returned ${response.status}` + fallback = kind === 'download' + ? `download failed with status ${response.status}` + : `registry returned ${response.status}` exitCode = EXIT.network } else { fallback = kind === 'download' diff --git a/cli/test/unit/clients/skillhub-client.test.ts b/cli/test/unit/clients/skillhub-client.test.ts index 55d0bfb7..187350c1 100644 --- a/cli/test/unit/clients/skillhub-client.test.ts +++ b/cli/test/unit/clients/skillhub-client.test.ts @@ -92,6 +92,17 @@ describe('SkillHubClient', () => { await err.toHaveProperty('exitCode', EXIT.generic) }) + test('download() retains its fallback while classifying 502 as a network error', async () => { + const fetchImpl = (async () => new Response(null, { status: 502 })) as unknown as typeof fetch + const client = new SkillHubClient('http://registry.test', 'token', fetchImpl) + + await expect(client.download('ns', 'slug')).rejects.toMatchObject({ + message: 'download failed with status 502', + exitCode: EXIT.network, + details: { registry: 'http://registry.test' } + }) + }) + test('download() throws network error on fetch failure', async () => { const fetchImpl = (async () => { throw new TypeError('fetch failed') }) as unknown as typeof fetch const client = new SkillHubClient('http://registry.test', 'token', fetchImpl) @@ -179,6 +190,27 @@ describe('SkillHubClient', () => { // --- handleJsonResponse() non-2xx classification --- + test('whoami() preserves public fields and ignores unknown fields on a structured 401', async () => { + const fetchImpl = (async () => Response.json({ + code: 401, + msg: 'token has been revoked', + requestId: 'req-401', + detail: 'internal token state', + stack: 'internal stack trace' + }, { status: 401 })) as unknown as typeof fetch + const client = new SkillHubClient('http://registry.test', 'token', fetchImpl) + + const error = await client.whoami().catch((caught: unknown) => caught) + expect(error).toBeInstanceOf(CliError) + expect((error as CliError).message).toBe('token has been revoked') + expect((error as CliError).exitCode).toBe(EXIT.auth) + expect((error as CliError).details).toEqual({ + registry: 'http://registry.test', + requestId: 'req-401', + next: 'run `skillhub login`' + }) + }) + test('search() preserves a public 403 message and request ID', async () => { const fetchImpl = (async () => Response.json({ code: 403, @@ -262,6 +294,17 @@ describe('SkillHubClient', () => { } }) + test('whoami() uses the resource fallback on an unstructured 404', async () => { + const fetchImpl = (async () => new Response(null, { status: 404 })) as unknown as typeof fetch + const client = new SkillHubClient('http://registry.test', 'token', fetchImpl) + + await expect(client.whoami()).rejects.toMatchObject({ + message: 'resource not found', + exitCode: EXIT.generic, + details: { registry: 'http://registry.test' } + }) + }) + test('download() preserves a structured 403 message and request ID', async () => { const fetchImpl = (async () => Response.json({ code: 403, @@ -313,20 +356,63 @@ describe('SkillHubClient', () => { }) }) - test('whoami() throws generic error on 500', async () => { - const fetchImpl = (async () => new Response(null, { status: 500 })) as unknown as typeof fetch + test('whoami() preserves public fields on a structured 500', async () => { + const fetchImpl = (async () => Response.json({ + code: 500, + msg: 'registry operation failed', + requestId: 'req-500', + detail: 'internal database error' + }, { status: 500 })) as unknown as typeof fetch const client = new SkillHubClient('http://registry.test', 'token', fetchImpl) - const err = expect(client.whoami()).rejects - await err.toBeInstanceOf(CliError) - await err.toHaveProperty('exitCode', EXIT.generic) + + await expect(client.whoami()).rejects.toMatchObject({ + message: 'registry operation failed', + exitCode: EXIT.generic, + details: { + registry: 'http://registry.test', + requestId: 'req-500' + } + }) }) - test('search() throws network error on 502', async () => { + test('whoami() does not expose a raw non-JSON 500 body', async () => { + const fetchImpl = (async () => new Response('internal stack trace', { status: 500 })) as unknown as typeof fetch + const client = new SkillHubClient('http://registry.test', 'token', fetchImpl) + + await expect(client.whoami()).rejects.toMatchObject({ + message: 'registry returned 500', + exitCode: EXIT.generic, + details: { registry: 'http://registry.test' } + }) + }) + + test('search() preserves public fields and network classification on a structured 502', async () => { + const fetchImpl = (async () => Response.json({ + code: 502, + msg: 'registry upstream unavailable', + requestId: 'req-502' + }, { status: 502 })) as unknown as typeof fetch + const client = new SkillHubClient('http://registry.test', 'token', fetchImpl) + + await expect(client.search('test', 20)).rejects.toMatchObject({ + message: 'registry upstream unavailable', + exitCode: EXIT.network, + details: { + registry: 'http://registry.test', + requestId: 'req-502' + } + }) + }) + + test('search() uses the network fallback on an unstructured 502', async () => { const fetchImpl = (async () => new Response(null, { status: 502 })) as unknown as typeof fetch const client = new SkillHubClient('http://registry.test', 'token', fetchImpl) - const err = expect(client.search('test', 20)).rejects - await err.toBeInstanceOf(CliError) - await err.toHaveProperty('exitCode', EXIT.network) + + await expect(client.search('test', 20)).rejects.toMatchObject({ + message: 'registry returned 502', + exitCode: EXIT.network, + details: { registry: 'http://registry.test' } + }) }) // --- deleteRemote() (P1) --- diff --git a/docs/skillhub/en/guide/cli.md b/docs/skillhub/en/guide/cli.md index 49428c9d..4712cbca 100644 --- a/docs/skillhub/en/guide/cli.md +++ b/docs/skillhub/en/guide/cli.md @@ -125,15 +125,24 @@ Output format: `namespace/slug version summary` ## Install Skills +Install coordinates accept a bare slug (resolved to `global` by default) and +three equivalent explicit namespace forms. When an explicit coordinate and +`--namespace` are both present, they must match. + ```bash # Install to auto-detected Agent directory skillhub install pdf-parser +# Equivalent namespace coordinates +skillhub install team/my-skill +skillhub install @team/my-skill +skillhub install team--my-skill + # Choose install scope explicitly skillhub install pdf-parser --scope user skillhub install pdf-parser --scope project --agent codex -# Specify namespace (default: global) +# Specify a namespace for a bare slug skillhub install pdf-parser --namespace myspace # Specify version diff --git a/docs/skillhub/guide/cli.md b/docs/skillhub/guide/cli.md index 4389940a..b4c00da7 100644 --- a/docs/skillhub/guide/cli.md +++ b/docs/skillhub/guide/cli.md @@ -125,15 +125,23 @@ skillhub search pdf --json ## 安装技能 +安装坐标支持裸 slug(默认解析到 `global`)和三种等价的显式 namespace +形式。显式坐标与 `--namespace` 同时出现时,两者必须一致。 + ```bash # 安装到自动探测的 Agent 目录 skillhub install pdf-parser +# 等价的 namespace 坐标 +skillhub install team/my-skill +skillhub install @team/my-skill +skillhub install team--my-skill + # 显式指定安装范围 skillhub install pdf-parser --scope user skillhub install pdf-parser --scope project --agent codex -# 指定 namespace(默认 global) +# 为裸 slug 指定 namespace skillhub install pdf-parser --namespace myspace # 指定版本 diff --git a/docs/superpowers/plans/2026-07-28-cli-namespace-errors.md b/docs/superpowers/plans/2026-07-28-cli-namespace-errors.md index 098e3542..ee71ab89 100644 --- a/docs/superpowers/plans/2026-07-28-cli-namespace-errors.md +++ b/docs/superpowers/plans/2026-07-28-cli-namespace-errors.md @@ -16,14 +16,14 @@ revalidation did not recreate historical RED states; it reran the current GREEN gates with the repository-pinned Bun 1.3.13: - Focused namespace/error/help regression: 142 tests passed. -- Complete CLI regression: 373 tests passed with - `bun test --max-concurrency=1` (peak RSS 170224 KiB). +- Complete CLI regression: 378 tests passed with + `bun test --max-concurrency=1` (peak RSS 152128 KiB). - Typecheck, lint, and build passed. - The packed `@astron-team/skillhub@0.1.9` artifact contained `dist/index.js`, `README.md`, `CHANGELOG.md`, `LICENSE`, and `package.json`. -- Packed Node artifact smoke passed for `version`, `help install`, and - `remove --help`; the install help listed all three namespaced coordinate - forms. +- Packed Node artifact smoke passed for `version`, `help install`, all three + namespaced coordinate forms, coordinate/`--namespace` conflict handling, and + structured 403 message/request-ID rendering. - The Chinese and English VitePress documentation build passed. --- From 2854b26b5d5ca33a4ad55756cce719e1ee4e2112 Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Wed, 29 Jul 2026 14:04:26 +0800 Subject: [PATCH 78/81] fix(helm): resolve deployment review blockers Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- .github/workflows/pr-helm-chart.yml | 2 +- .github/workflows/publish-chart.yml | 2 +- README.md | 14 +++++++ README_zh.md | 10 +++-- charts/skillhub/Chart.yaml | 2 +- charts/skillhub/README.md | 40 ++++++++++++++----- charts/skillhub/templates/ingress.yaml | 21 ++++++++++ .../skillhub/templates/server-deployment.yaml | 4 ++ charts/skillhub/templates/validate.yaml | 10 ++++- .../skillhub/tests/configuration-contracts.sh | 20 ++++++++++ charts/skillhub/values.schema.json | 4 +- .../skillhub/config/RedissonConfig.java | 22 ++++++---- .../src/main/resources/application.yml | 3 ++ .../skillhub/config/RedissonConfigTest.java | 16 +++++++- 14 files changed, 142 insertions(+), 28 deletions(-) diff --git a/.github/workflows/pr-helm-chart.yml b/.github/workflows/pr-helm-chart.yml index c980d3ec..c24b0377 100644 --- a/.github/workflows/pr-helm-chart.yml +++ b/.github/workflows/pr-helm-chart.yml @@ -106,7 +106,7 @@ jobs: args: >- --set s3.enabled=true --set s3.bucket=test-bucket - --set s3.endpoint=s3.amazonaws.com + --set s3.endpoint=https://s3.amazonaws.com --set s3.region=us-east-1 - name: external-secret description: 外部 Secret diff --git a/.github/workflows/publish-chart.yml b/.github/workflows/publish-chart.yml index 823524d5..5264d84c 100644 --- a/.github/workflows/publish-chart.yml +++ b/.github/workflows/publish-chart.yml @@ -6,7 +6,7 @@ on: workflow_dispatch: inputs: version: - description: Chart and application version (for example, 0.2.13) + description: Chart and application version (for example, 0.2.14) required: true type: string diff --git a/README.md b/README.md index 4c36db58..2d5dec57 100644 --- a/README.md +++ b/README.md @@ -343,6 +343,20 @@ Basic Kubernetes manifests are available under [`deploy/k8s/`](./deploy/k8s): - `backend-deployment.yaml` - `frontend-deployment.yaml` - `services.yaml` + +For a configurable deployment with bundled PostgreSQL and Redis dependencies, +use the Helm chart under [`charts/skillhub/`](./charts/skillhub): + +```bash +helm dependency build ./charts/skillhub +helm upgrade --install skillhub ./charts/skillhub \ + --namespace skillhub \ + --create-namespace \ + -f values-production.yaml +``` + +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/README_zh.md b/README_zh.md index 7923daae..694bdc27 100644 --- a/README_zh.md +++ b/README_zh.md @@ -228,8 +228,10 @@ curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- u # 应用 Kubernetes 清单 kubectl apply -f deploy/k8s/ -# 或使用 Helm(即将推出) -helm install skillhub ./deploy/helm +# 或使用 Helm Chart +helm dependency build ./charts/skillhub +helm upgrade --install skillhub ./charts/skillhub -n skillhub --create-namespace \ + -f values-production.yaml ``` ### 环境变量 @@ -319,7 +321,7 @@ SkillHub 采用清晰的分层架构: ### 基础设施 - **容器化**:Docker & Docker Compose - **监控**:Prometheus + Grafana -- **部署**:Kubernetes 清单 +- **部署**:Kubernetes 清单与 Helm Chart - **CI/CD**:GitHub Actions ## 路线图 @@ -332,7 +334,7 @@ SkillHub 采用清晰的分层架构: - [x] API 令牌管理 - [x] 账户合并 - [x] 国际化支持 -- [ ] Helm Chart 部署 +- [x] Helm Chart 部署 - [ ] 高级搜索过滤器 - [ ] 技能依赖管理 - [ ] Webhook 集成 diff --git a/charts/skillhub/Chart.yaml b/charts/skillhub/Chart.yaml index 3f125dd3..2bf90a60 100644 --- a/charts/skillhub/Chart.yaml +++ b/charts/skillhub/Chart.yaml @@ -3,7 +3,7 @@ name: skillhub description: Self-hosted, open-source agent skill registry for enterprises. type: application version: 0.1.0 -appVersion: 0.2.13 +appVersion: 0.2.14 keywords: - skillhub - ai diff --git a/charts/skillhub/README.md b/charts/skillhub/README.md index 445873d4..592a0319 100644 --- a/charts/skillhub/README.md +++ b/charts/skillhub/README.md @@ -181,7 +181,7 @@ global: images: registry: registry.example.com/library - tag: v0.2.13 + tag: v0.2.14 pullPolicy: IfNotPresent server: @@ -271,7 +271,9 @@ standalone → replication、Redis standalone/replication → Sentinel 等切换 ### Redis Sentinel 内置 Sentinel 使用 Bitnami Redis 的同一份密码同时保护 Redis 数据节点和 -Sentinel。节点地址由副本数自动生成,不需要手动配置: +Sentinel。节点地址由副本数自动生成,不需要手动配置。由于 Bitnami Sentinel +上报的 Pod 地址可能与客户端连接的 Headless Service FQDN 不同,Chart 仅在该 +内置模式下关闭 Redisson 的 Sentinel 地址一致性检查: ```bash helm -n skillhub upgrade -i skillhub ./charts/skillhub \ @@ -282,7 +284,9 @@ helm -n skillhub upgrade -i skillhub ./charts/skillhub \ 外部 Sentinel 必须提供至少一个 `host:port` 节点。Redis 数据密码和 Sentinel 密码可以不同;使用 `existingSecret` 时分别对应 `redis-password` 和 -`redis-sentinel-password`: +`redis-sentinel-password`。外部 Sentinel 默认保留 Redisson 地址一致性检查; +只有已确认服务发现会改写节点地址时,才通过 `server.extraEnv` 显式设置 +`SKILLHUB_REDIS_SENTINEL_CHECK_SENTINELS_LIST=false`: ```bash helm -n skillhub upgrade -i skillhub ./charts/skillhub \ @@ -294,6 +298,15 @@ helm -n skillhub upgrade -i skillhub ./charts/skillhub \ --set-json 'externalRedis.sentinel.nodes=["sentinel-0.example.com:26379","sentinel-1.example.com:26379"]' ``` +确需关闭检查时,在 values 文件中显式记录该兼容例外: + +```yaml +server: + extraEnv: + - name: SKILLHUB_REDIS_SENTINEL_CHECK_SENTINELS_LIST + value: "false" +``` + ### 存储配置 | 参数 | 描述 | 默认值 | @@ -327,8 +340,8 @@ helm -n skillhub upgrade -i skillhub ./charts/skillhub \ |------|------|--------| | `s3.enabled` | 启用 S3 | `false` | | `s3.bucket` | Bucket 名称 | `skillhub-storage` | -| `s3.endpoint` | S3 端点 | `""` | -| `s3.publicEndpoint` | S3 公网访问端点 | `""` | +| `s3.endpoint` | S3 端点,非空时必须是绝对 HTTP(S) URL | `""` | +| `s3.publicEndpoint` | S3 公网访问端点,非空时必须是绝对 HTTP(S) URL | `""` | | `s3.region` | 区域 | `us-east-1` | | `s3.forcePathStyle` | 强制 path-style 访问 | `true` | | `s3.disableChunkedEncoding` | 禁用 aws-chunked 编码 | `false` | @@ -341,7 +354,7 @@ helm -n skillhub upgrade -i skillhub ./charts/skillhub \ -f values-production.yaml \ --set s3.enabled=true \ --set s3.bucket=your-bucket \ - --set s3.endpoint=s3.amazonaws.com \ + --set s3.endpoint=https://s3.amazonaws.com \ --set s3.region=us-east-1 \ --set s3.accessKey=your-access-key \ --set s3.secretKey=your-secret-key @@ -374,9 +387,11 @@ ingress: alb.ingress.kubernetes.io/listen-ports: '[{"HTTPS":6443}]' ``` -`hosts` 是至少包含一个条目的对象数组。Chart 自动将 `/api` 转发给 Server, -`hosts[].paths` 中的路径转发给 Web,因此 `/api` 是保留路径。`tls` 同样是数组, -可为不同证书分别配置域名;TLS 域名会写入 cert-manager Certificate SAN: +`hosts` 是至少包含一个条目的对象数组。Chart 自动将 `/api`、`/oauth2`、 +`/login/oauth2` 和 `/.well-known` 直接转发给 Server,确保 TLS 终止后的 OAuth +回调协议保持正确;`hosts[].paths` 中的其他路径转发给 Web,因此上述四个前缀 +均为保留路径。`tls` 同样是数组,可为不同证书分别配置域名;TLS 域名会写入 +cert-manager Certificate SAN: ```yaml ingress: @@ -410,6 +425,13 @@ helm -n skillhub upgrade -i skillhub ./charts/skillhub \ 每个 HPA 至少需要一个非零 CPU 或内存利用率目标。本地存储的 Server HPA 同样 要求 RWX;也可以启用 S3 来避免共享 PVC。 +## 发布 + +`.github/workflows/publish-chart.yml` 在 GitHub Release 发布后或手动 +`workflow_dispatch` 时运行。Release tag 必须使用 `vX.Y.Z`、`chart-vX.Y.Z` 或 +`helm-vX.Y.Z`;手动运行时显式输入 `X.Y.Z`。工作流按该版本打包 Chart,并推送到 +`oci://ghcr.io/iflytek/charts`,同时保留构建 artifact。 + ## 卸载 ```bash diff --git a/charts/skillhub/templates/ingress.yaml b/charts/skillhub/templates/ingress.yaml index 60512116..dd5f28d2 100644 --- a/charts/skillhub/templates/ingress.yaml +++ b/charts/skillhub/templates/ingress.yaml @@ -30,6 +30,27 @@ spec: name: {{ include "skillhub.fullname" $ }}-server port: number: {{ $.Values.server.service.port }} + - path: /oauth2 + pathType: Prefix + backend: + service: + name: {{ include "skillhub.fullname" $ }}-server + port: + number: {{ $.Values.server.service.port }} + - path: /login/oauth2 + pathType: Prefix + backend: + service: + name: {{ include "skillhub.fullname" $ }}-server + port: + number: {{ $.Values.server.service.port }} + - path: /.well-known + pathType: Prefix + backend: + service: + name: {{ include "skillhub.fullname" $ }}-server + port: + number: {{ $.Values.server.service.port }} {{- range $path := $host.paths }} - path: {{ $path.path | quote }} pathType: {{ $path.pathType }} diff --git a/charts/skillhub/templates/server-deployment.yaml b/charts/skillhub/templates/server-deployment.yaml index 84e7ac9d..b1bd6393 100644 --- a/charts/skillhub/templates/server-deployment.yaml +++ b/charts/skillhub/templates/server-deployment.yaml @@ -100,6 +100,10 @@ spec: value: {{ .Values.redis.sentinel.masterSet | default "mymaster" | quote }} - name: SPRING_DATA_REDIS_SENTINEL_NODES value: {{ include "skillhub.redis.sentinel.nodes" . | quote }} + # Bitnami Sentinel pods advertise pod-local addresses that can differ from + # the headless-service FQDNs used by clients inside Kubernetes. + - name: SKILLHUB_REDIS_SENTINEL_CHECK_SENTINELS_LIST + value: "false" {{- else if and (not .Values.redis.enabled) .Values.externalRedis.sentinel.enabled }} - name: SPRING_DATA_REDIS_SENTINEL_MASTER value: {{ .Values.externalRedis.sentinel.masterSet | default "mymaster" | quote }} diff --git a/charts/skillhub/templates/validate.yaml b/charts/skillhub/templates/validate.yaml index e5a34aa2..5b1862f6 100644 --- a/charts/skillhub/templates/validate.yaml +++ b/charts/skillhub/templates/validate.yaml @@ -17,8 +17,8 @@ {{- end -}} {{- range $host := .Values.ingress.hosts -}} {{- range $path := $host.paths -}} -{{- if regexMatch "^/api(?:/|$)" $path.path -}} -{{- fail "ingress.hosts[].paths reserves /api for the SkillHub server" -}} +{{- 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 -}} {{- end -}} @@ -61,6 +61,12 @@ {{- 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)) -}} +{{- fail "s3.endpoint must be an absolute HTTP(S) URL" -}} +{{- end -}} +{{- if and .Values.s3.publicEndpoint (not (regexMatch "^https?://.+" .Values.s3.publicEndpoint)) -}} +{{- fail "s3.publicEndpoint must be an absolute HTTP(S) URL" -}} +{{- end -}} {{- if not .Values.secrets.allowAutoGenerated -}} {{- if not .Values.existingSecret -}} diff --git a/charts/skillhub/tests/configuration-contracts.sh b/charts/skillhub/tests/configuration-contracts.sh index acf671a6..36348f32 100755 --- a/charts/skillhub/tests/configuration-contracts.sh +++ b/charts/skillhub/tests/configuration-contracts.sh @@ -102,6 +102,8 @@ 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 external-sentinel "$CHART_DIR" \ --set postgresql.enabled=false \ @@ -115,6 +117,9 @@ render external-sentinel "$CHART_DIR" \ 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" +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 special "$CHART_DIR" \ --set-string 'bootstrapAdmin.displayName=Ops: Admin' \ @@ -131,6 +136,16 @@ render tls "$CHART_DIR" \ --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 \ @@ -203,6 +218,11 @@ assert_rejected old-sentinel-service-switch --set redis.sentinel.service.enabled 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 invalid-s3-endpoint --set s3.endpoint=s3.amazonaws.com +assert_rejected invalid-s3-public-endpoint --set s3.publicEndpoint=cdn.example.com assert_rejected empty-ingress-hosts --set-json 'ingress.hosts=[]' assert_rejected cert-manager-without-tls \ --set ingress.enabled=true \ diff --git a/charts/skillhub/values.schema.json b/charts/skillhub/values.schema.json index b1c6f3a0..a151d663 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" }, - "publicEndpoint": { "type": "string" }, + "endpoint": { "type": "string", "pattern": "^(|https?://.+)$" }, + "publicEndpoint": { "type": "string", "pattern": "^(|https?://.+)$" }, "region": { "type": "string", "minLength": 1 }, "forcePathStyle": { "type": "boolean" }, "disableChunkedEncoding": { "type": "boolean" }, diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/RedissonConfig.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/RedissonConfig.java index 09123c37..9f4de0b3 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/RedissonConfig.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/RedissonConfig.java @@ -5,6 +5,7 @@ import org.redisson.api.RedissonClient; import org.redisson.config.Config; import org.redisson.config.SentinelServersConfig; import org.redisson.config.SingleServerConfig; +import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.data.redis.RedisProperties; import org.springframework.context.annotation.Bean; @@ -18,14 +19,20 @@ import java.util.List; public class RedissonConfig { @Bean(destroyMethod = "shutdown") - public RedissonClient redissonClient(RedisProperties redisProperties) { - return Redisson.create(createConfig(redisProperties)); + public RedissonClient redissonClient( + RedisProperties redisProperties, + @Value("${skillhub.redis.sentinel.check-sentinels-list:true}") boolean checkSentinelsList) { + return Redisson.create(createConfig(redisProperties, checkSentinelsList)); } static Config createConfig(RedisProperties redisProperties) { + return createConfig(redisProperties, true); + } + + static Config createConfig(RedisProperties redisProperties, boolean checkSentinelsList) { Config config = new Config(); if (hasSentinelConfiguration(redisProperties)) { - configureSentinelServers(config, redisProperties); + configureSentinelServers(config, redisProperties, checkSentinelsList); return config; } @@ -38,13 +45,14 @@ public class RedissonConfig { return config; } - private static void configureSentinelServers(Config config, RedisProperties redisProperties) { + private static void configureSentinelServers( + Config config, + RedisProperties redisProperties, + boolean checkSentinelsList) { SentinelServersConfig sentinelServersConfig = config.useSentinelServers() .setMasterName(redisProperties.getSentinel().getMaster()) .setDatabase(redisProperties.getDatabase()) - // K8s headless DNS 场景下,客户端通过 pod FQDN 连接 sentinel, - // 与 sentinel 自身上报的地址格式不同,跳过地址一致性检查避免误报连接失败 - .setCheckSentinelsList(false); + .setCheckSentinelsList(checkSentinelsList); List nodes = redisProperties.getSentinel().getNodes(); nodes.stream() .map(String::trim) diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml index 286ff05f..7d5133c2 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -95,6 +95,9 @@ spring: skillhub: builtin-skills: enabled: ${SKILLHUB_BUILTIN_SKILLS_ENABLED:true} + redis: + sentinel: + check-sentinels-list: ${SKILLHUB_REDIS_SENTINEL_CHECK_SENTINELS_LIST:true} auth: mock: enabled: ${SKILLHUB_AUTH_MOCK_ENABLED:false} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/RedissonConfigTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/RedissonConfigTest.java index 1095f16e..9f290ede 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/RedissonConfigTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/RedissonConfigTest.java @@ -123,7 +123,7 @@ class RedissonConfigTest { } @Test - void createConfig_sentinelCheckSentinelsListDisabled() throws Exception { + void createConfig_keepsSentinelMembershipCheckEnabledByDefault() throws Exception { RedisProperties properties = new RedisProperties(); RedisProperties.Sentinel sentinel = new RedisProperties.Sentinel(); sentinel.setMaster("mymaster"); @@ -133,6 +133,20 @@ class RedissonConfigTest { Config config = RedissonConfig.createConfig(properties); SentinelServersConfig sentinelConfig = sentinelConfig(config); + assertThat(sentinelConfig.isCheckSentinelsList()).isTrue(); + } + + @Test + void createConfig_canDisableSentinelMembershipCheckForKubernetes() throws Exception { + RedisProperties properties = new RedisProperties(); + RedisProperties.Sentinel sentinel = new RedisProperties.Sentinel(); + sentinel.setMaster("mymaster"); + sentinel.setNodes(List.of("redis-sentinel-1:26379")); + properties.setSentinel(sentinel); + + Config config = RedissonConfig.createConfig(properties, false); + SentinelServersConfig sentinelConfig = sentinelConfig(config); + assertThat(sentinelConfig.isCheckSentinelsList()).isFalse(); } From 0efc91b73fc4f3d31ff862a63ec7b065119dc27e Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:22:36 +0800 Subject: [PATCH 79/81] test(helm): add reproducible upgrade smoke coverage Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- .github/workflows/pr-helm-chart.yml | 25 +++ README.md | 2 +- charts/skillhub/README.md | 20 +++ charts/skillhub/templates/validate.yaml | 7 +- .../skillhub/tests/configuration-contracts.sh | 19 ++ .../skillhub/tests/install-upgrade-smoke.sh | 169 ++++++++++++++++++ charts/skillhub/values.schema.json | 4 +- charts/skillhub/values.yaml | 15 ++ 8 files changed, 255 insertions(+), 6 deletions(-) create mode 100755 charts/skillhub/tests/install-upgrade-smoke.sh 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 From 36fb6bf4526142da84ad36ec718016e4a08ec666 Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:22:36 +0800 Subject: [PATCH 80/81] test(helm): cover deployment integration scenarios Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- .github/workflows/pr-helm-chart.yml | 14 +- charts/skillhub/README.md | 20 ++- .../skillhub/tests/configuration-contracts.sh | 10 +- .../skillhub/tests/install-upgrade-smoke.sh | 163 +++++++++++++++++- charts/skillhub/values.yaml | 10 +- 5 files changed, 191 insertions(+), 26 deletions(-) diff --git a/.github/workflows/pr-helm-chart.yml b/.github/workflows/pr-helm-chart.yml index 3e87c9bf..0f26fb97 100644 --- a/.github/workflows/pr-helm-chart.yml +++ b/.github/workflows/pr-helm-chart.yml @@ -176,15 +176,23 @@ jobs: fi - name: Schema validation (kubeconform) - uses: docker://ghcr.io/yannh/kubeconform:latest + uses: docker://ghcr.io/yannh/kubeconform@sha256:faffaf43f95aa6425306e1ab8d6fcad72acb9049158f38e574c085ea1ec0f64e # v0.8.0 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 + name: Install and Upgrade Smoke (${{ matrix.scenario }}) if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + scenario: + - default + - sentinel + - s3 + - ingress-tls steps: - name: Check out repository @@ -204,4 +212,6 @@ jobs: wait: 120s - name: Run install and upgrade smoke + env: + HELM_SMOKE_SCENARIO: ${{ matrix.scenario }} run: bash charts/skillhub/tests/install-upgrade-smoke.sh diff --git a/charts/skillhub/README.md b/charts/skillhub/README.md index f4063034..29c41818 100644 --- a/charts/skillhub/README.md +++ b/charts/skillhub/README.md @@ -39,6 +39,7 @@ redis: ``` ```bash +helm dependency build ./charts/skillhub kubectl create namespace skillhub helm -n skillhub upgrade -i skillhub ./charts/skillhub \ @@ -57,12 +58,16 @@ helm -n skillhub upgrade -i skillhub ./charts/skillhub \ 合并或发布前,可在一个空的测试 Kubernetes 集群中运行可重复的安装/升级 smoke: ```bash -bash charts/skillhub/tests/install-upgrade-smoke.sh +for scenario in default sentinel s3 ingress-tls; do + HELM_SMOKE_SCENARIO="$scenario" \ + bash charts/skillhub/tests/install-upgrade-smoke.sh +done ``` 脚本验证 `install -> Ready -> HTTP health -> upgrade -> Ready`,并确认 Secret 数据、 -PVC UID 与绑定 PV 在升级前后保持不变。默认清理自己创建的 namespace;设置 -`KEEP_HELM_SMOKE=true` 可保留现场用于排查。 +PVC UID 与绑定 PV 在升级前后保持不变。四个场景分别覆盖默认依赖、Redis +Sentinel、实际 MinIO S3 连接,以及由 Kubernetes API 接受的 TLS Ingress 路由。 +默认清理自己创建的 namespace;设置 `KEEP_HELM_SMOKE=true` 可保留现场用于排查。 ### 高可用模式 @@ -144,10 +149,11 @@ redis: 可预测密码,而是在任何随机密码缺失时终止渲染并指出具体配置项。敏感值应放在 受保护的 values、External Secrets、Sealed Secrets 或密钥注入插件中。 -内置 PostgreSQL、Redis、Sentinel 及 metrics exporter 镜像默认使用不可变 digest, -避免 Bitnami 子 Chart 的 `latest` 默认值造成不可复现的安装和回滚。覆盖私有镜像 -仓库或 tag 时,必须同时把对应的 `image.digest` 设为空,或改成私有仓库中该镜像 -的真实 digest;digest 非空时会优先于 tag。 +内置 PostgreSQL、Redis、Sentinel 及 metrics exporter 镜像默认使用不可变的 +多架构 manifest digest,避免 Bitnami 子 Chart 的 `latest` 默认值造成不可复现的 +安装和回滚,同时保留 amd64/arm64 支持。覆盖私有镜像仓库或 tag 时,必须同时把 +对应的 `image.digest` 设为空,或改成私有仓库中该镜像的真实 digest;digest +非空时会优先于 tag。 ## 配置参考 diff --git a/charts/skillhub/tests/configuration-contracts.sh b/charts/skillhub/tests/configuration-contracts.sh index 61d75756..17b77853 100755 --- a/charts/skillhub/tests/configuration-contracts.sh +++ b/charts/skillhub/tests/configuration-contracts.sh @@ -27,10 +27,10 @@ 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" +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 @@ -114,7 +114,7 @@ grep -A1 -F 'name: SKILLHUB_REDIS_SENTINEL_CHECK_SENTINELS_LIST' "$TMP_DIR/senti 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" +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 diff --git a/charts/skillhub/tests/install-upgrade-smoke.sh b/charts/skillhub/tests/install-upgrade-smoke.sh index c7d3145e..fd461d56 100755 --- a/charts/skillhub/tests/install-upgrade-smoke.sh +++ b/charts/skillhub/tests/install-upgrade-smoke.sh @@ -3,13 +3,15 @@ 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} +SCENARIO=${HELM_SMOKE_SCENARIO:-default} +NAMESPACE=${HELM_SMOKE_NAMESPACE:-skillhub-helm-smoke-$SCENARIO} 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 +HELM_SCENARIO_ARGS=() fail() { echo "ERROR: $*" >&2 @@ -23,7 +25,6 @@ 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 @@ -55,12 +56,153 @@ cleanup() { } trap cleanup EXIT +setup_scenario() { + case "$SCENARIO" in + default) + ;; + sentinel) + HELM_SCENARIO_ARGS+=( + --set redis.architecture=replication + --set redis.sentinel.enabled=true + ) + ;; + s3) + kubectl apply --namespace "$NAMESPACE" -f - <<'YAML' +apiVersion: apps/v1 +kind: Deployment +metadata: + name: minio +spec: + replicas: 1 + selector: + matchLabels: + app: minio + template: + metadata: + labels: + app: minio + spec: + containers: + - name: minio + image: docker.io/minio/minio@sha256:14cea493d9a34af32f524e538b8346cf79f3321eff8e708c1e2960462bd8936e + args: + - server + - /data + env: + - name: MINIO_ROOT_USER + value: smoke-access-key + - name: MINIO_ROOT_PASSWORD + value: smoke-secret-key + ports: + - name: api + containerPort: 9000 + readinessProbe: + httpGet: + path: /minio/health/ready + port: api + periodSeconds: 2 +--- +apiVersion: v1 +kind: Service +metadata: + name: minio +spec: + selector: + app: minio + ports: + - name: api + port: 9000 + targetPort: api +YAML + kubectl rollout status deployment/minio \ + --namespace "$NAMESPACE" \ + --timeout=5m + HELM_SCENARIO_ARGS+=( + --set s3.enabled=true + --set-string s3.endpoint=http://minio:9000 + --set-string s3.accessKey=smoke-access-key + --set-string s3.secretKey=smoke-secret-key + --set s3.autoCreateBucket=true + ) + ;; + ingress-tls) + command -v openssl >/dev/null 2>&1 || fail "openssl is required for ingress-tls" + openssl req -x509 -newkey rsa:2048 -nodes \ + -keyout "$TMP_DIR/tls.key" \ + -out "$TMP_DIR/tls.crt" \ + -days 1 \ + -subj /CN=skillhub-smoke.local \ + -addext subjectAltName=DNS:skillhub-smoke.local >/dev/null 2>&1 + kubectl create secret tls skillhub-smoke-tls \ + --namespace "$NAMESPACE" \ + --cert "$TMP_DIR/tls.crt" \ + --key "$TMP_DIR/tls.key" + HELM_SCENARIO_ARGS+=( + --set ingress.enabled=true + --set-json 'ingress.hosts=[{"host":"skillhub-smoke.local","paths":[{"path":"/","pathType":"Prefix"}]}]' + --set-json 'ingress.tls=[{"hosts":["skillhub-smoke.local"],"secretName":"skillhub-smoke-tls"}]' + ) + ;; + *) + fail "unknown HELM_SMOKE_SCENARIO: $SCENARIO" + ;; + esac +} + +assert_scenario_contract() { + case "$SCENARIO" in + default) + ;; + sentinel) + kubectl get deployment "$RELEASE-server" --namespace "$NAMESPACE" -o json \ + | jq -e ' + [.spec.template.spec.containers[] + | select(.name == "server") + | .env[] + | select(.name == "SKILLHUB_REDIS_SENTINEL_CHECK_SENTINELS_LIST") + | .value] == ["false"] + ' >/dev/null \ + || fail "Sentinel scenario did not apply the Kubernetes-only address-check override" + ;; + s3) + local storage_provider + storage_provider=$(kubectl get configmap "$RELEASE-config" \ + --namespace "$NAMESPACE" -o json | jq -r '.data["skillhub-storage-provider"]') + [[ "$storage_provider" == "s3" ]] || fail "S3 scenario did not configure S3 storage" + ;; + ingress-tls) + kubectl get ingress "$RELEASE" --namespace "$NAMESPACE" -o json \ + | jq -e --arg server "$RELEASE-server" ' + .spec.tls[0].secretName == "skillhub-smoke-tls" + and ( + [.spec.rules[].http.paths[] + | select( + .path == "/api" + or .path == "/oauth2" + or .path == "/login/oauth2" + or .path == "/.well-known" + ) + | .backend.service.name] + | length == 4 and all(. == $server) + ) + ' >/dev/null \ + || fail "TLS Ingress does not route every reserved path directly to the server" + local cookie_secure + cookie_secure=$(kubectl get configmap "$RELEASE-config" \ + --namespace "$NAMESPACE" -o json | jq -r '.data["session-cookie-secure"]') + [[ "$cookie_secure" == "true" ]] || fail "TLS Ingress did not enable secure session cookies" + ;; + esac +} + probe_service() { local service=$1 local service_port=$2 local local_port=$3 local path=$4 + local expected_status=${5:-200} local log_file="$TMP_DIR/${service}.port-forward.log" + local status stop_port_forward kubectl port-forward \ @@ -70,7 +212,9 @@ probe_service() { 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 + status=$(curl --silent --output /dev/null --write-out '%{http_code}' \ + "http://127.0.0.1:$local_port$path" 2>/dev/null || true) + if [[ "$status" == "$expected_status" ]]; then stop_port_forward return 0 fi @@ -81,7 +225,7 @@ probe_service() { done cat "$log_file" >&2 - fail "$service$path did not become healthy" + fail "$service$path did not return HTTP $expected_status" } snapshot_secrets() { @@ -113,7 +257,7 @@ assert_ready_and_healthy() { 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-web" 80 18080 /api/v1/auth/me 401 probe_service "$RELEASE-scanner" 8000 18082 /health local restarts @@ -123,17 +267,21 @@ assert_ready_and_healthy() { } helm dependency build "$CHART_DIR" +kubectl create namespace "$NAMESPACE" +OWNS_NAMESPACE=true +setup_scenario helm install "$RELEASE" "$CHART_DIR" \ --namespace "$NAMESPACE" \ - --create-namespace \ --values "$TEST_VALUES" \ --set-string fullnameOverride="$RELEASE" \ --set-string publicBaseUrl=http://skillhub-smoke.local \ + "${HELM_SCENARIO_ARGS[@]}" \ --wait \ --timeout "$TIMEOUT" assert_ready_and_healthy +assert_scenario_contract snapshot_secrets "$TMP_DIR/secrets-before" snapshot_pvcs "$TMP_DIR/pvcs-before" revision_before=$(helm history "$RELEASE" --namespace "$NAMESPACE" -o json \ @@ -148,6 +296,7 @@ helm upgrade "$RELEASE" "$CHART_DIR" \ --timeout "$TIMEOUT" assert_ready_and_healthy +assert_scenario_contract snapshot_secrets "$TMP_DIR/secrets-after" snapshot_pvcs "$TMP_DIR/pvcs-after" revision_after=$(helm history "$RELEASE" --namespace "$NAMESPACE" -o json \ @@ -166,4 +315,4 @@ public_base_url=$(kubectl get configmap "$RELEASE-config" \ [[ "$public_base_url" == "https://skillhub-smoke.local" ]] \ || fail "publicBaseUrl was not applied by the upgrade" -echo "Helm install/upgrade smoke passed" +echo "Helm install/upgrade smoke passed for scenario: $SCENARIO" diff --git a/charts/skillhub/values.yaml b/charts/skillhub/values.yaml index c3e70769..e89a7d4c 100644 --- a/charts/skillhub/values.yaml +++ b/charts/skillhub/values.yaml @@ -108,7 +108,7 @@ postgresql: # Bitnami's current chart defaults to a mutable latest tag. Pin the image # digest so installs and rollbacks remain reproducible. image: - digest: "sha256:52e4eae10959a7ef4810f2d0c9ce656e9337090fd2427d8d15d2e1335ba4f8fb" + digest: "sha256:db2312d9b243afa8c3b3f5496e478d17d0dff9791d06f3b93b9567abd86ae92f" auth: postgresPassword: "" @@ -171,7 +171,7 @@ postgresql: metrics: enabled: true image: - digest: "sha256:fbd5fbc3f350c793efef777b0710f17019e8dd7812fc7c7396b13aa5130f3bd9" + digest: "sha256:53ab72a1b940d7637e91619f1000da9ebef14bc7dad74321a78731d65c79f55b" serviceMonitor: enabled: false @@ -193,7 +193,7 @@ redis: # Keep the bundled Redis runtime immutable for repeatable upgrades. image: - digest: "sha256:5ff436b8a0dbe3808f03c7b682865cd30ddb1f08c1a8d17970ebf475f5689722" + digest: "sha256:08863c2c3f4e051fb6139b38fa223e9c13be5033326a59bead182860d899bf98" auth: enabled: true @@ -240,7 +240,7 @@ redis: enabled: false masterSet: mymaster image: - digest: "sha256:667bfef3e22ce9910bdcf304b1b7c659cfc15390a2d473187eb3caac2537d0e6" + digest: "sha256:ae75dd69c192a632bdeb21baa6721080be5b12347e52add922036398b47631da" service: ports: sentinel: 26379 @@ -250,7 +250,7 @@ redis: metrics: enabled: true image: - digest: "sha256:0468a9a14ebd96953f967f9f3e8673c3eb1ad1dd54d379fef15c3f3ee714c99e" + digest: "sha256:fb1dae6add1e1104989d086d9407f7d65f58968550aa5fddea20637a758c0773" serviceMonitor: enabled: false From 87cb05a09627f6fdcd67835df3edb01c239e755b Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:22:36 +0800 Subject: [PATCH 81/81] test(ci): cover Helm workflows in security checks Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- .github/workflows/pr-scripts.yml | 2 ++ scripts/tests/workflow-security-test.sh | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/.github/workflows/pr-scripts.yml b/.github/workflows/pr-scripts.yml index 082ce102..6b46fde7 100644 --- a/.github/workflows/pr-scripts.yml +++ b/.github/workflows/pr-scripts.yml @@ -12,7 +12,9 @@ on: - 'web/nginx.conf.template' - '.github/workflows/pr-cli.yml' - '.github/workflows/pr-e2e.yml' + - '.github/workflows/pr-helm-chart.yml' - '.github/workflows/pr-tests.yml' + - '.github/workflows/publish-chart.yml' - '.github/workflows/security.yml' - '.github/workflows/pr-scripts.yml' - '**/*.py' diff --git a/scripts/tests/workflow-security-test.sh b/scripts/tests/workflow-security-test.sh index ec1f70ce..a13c7c62 100755 --- a/scripts/tests/workflow-security-test.sh +++ b/scripts/tests/workflow-security-test.sh @@ -24,6 +24,7 @@ assert_pr_workflow_hardened() { assert_pr_workflow_hardened "$REPO_ROOT/.github/workflows/pr-cli.yml" assert_pr_workflow_hardened "$REPO_ROOT/.github/workflows/pr-e2e.yml" +assert_pr_workflow_hardened "$REPO_ROOT/.github/workflows/pr-helm-chart.yml" assert_pr_workflow_hardened "$REPO_ROOT/.github/workflows/pr-tests.yml" assert_pr_workflow_hardened "$PR_SCRIPTS_WORKFLOW" assert_pr_workflow_hardened "$SECURITY_WORKFLOW" @@ -54,8 +55,12 @@ grep -Fq '.github/workflows/pr-cli.yml' "$PR_SCRIPTS_WORKFLOW" \ || fail "pr-scripts must run when PR CLI workflow changes" grep -Fq '.github/workflows/pr-e2e.yml' "$PR_SCRIPTS_WORKFLOW" \ || fail "pr-scripts must run when PR E2E workflow changes" +grep -Fq '.github/workflows/pr-helm-chart.yml' "$PR_SCRIPTS_WORKFLOW" \ + || fail "pr-scripts must run when PR Helm Chart workflow changes" grep -Fq '.github/workflows/pr-tests.yml' "$PR_SCRIPTS_WORKFLOW" \ || fail "pr-scripts must run when PR Tests workflow changes" +grep -Fq '.github/workflows/publish-chart.yml' "$PR_SCRIPTS_WORKFLOW" \ + || fail "pr-scripts must run when Chart publish workflow changes" grep -Fq "'**/*.py'" "$PR_SCRIPTS_WORKFLOW" \ || fail "pr-scripts must run when Python source changes" grep -Fq '.env.release.example' "$PR_SCRIPTS_WORKFLOW" \