From 36358ae4e766a7539f14859d68ac3279ad35a078 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Fri, 18 Sep 2026 04:02:01 +0000 Subject: [PATCH 1/4] release(aisix): 1.3.0 Both charts move to version and appVersion 1.3.0, so `aisix-cp` pins `docker.io/api7/aisix-cp-{api,dpm,ui}:1.3.0` and `docker.io/api7/aisix:1.3.0`, and `aisix` pins `docker.io/api7/aisix:1.3.0`. Synced into `charts/aisix-cp` from the control plane's `helm/aisix-cp` at `v1.3.0`: - cp-api serves Prometheus metrics on its own listener, off the API port: the `api.metrics` values block, a `metrics` container port, `AISIX_CLOUD_METRICS_LISTEN`, a separate ClusterIP metrics Service (`api-service-metrics.yaml`) and an optional Prometheus Operator ServiceMonitor (`api-servicemonitor.yaml`). - `api.corsAllowedOrigins` lets a dashboard served from another origin call this API directly, rendered as `AISIX_CLOUD_CORS_ALLOWED_ORIGINS`. The template applies cp-api's own origin rules at render time, so a value cp-api would refuse at startup fails the install with the reason instead of crash-looping the pod. - The api, dpm and ui pods no longer pin `runAsUser`, `runAsGroup` or `fsGroup`, so a platform that assigns the UID itself (OpenShift's restricted-v2 SCC) can run them. The values file documents pinning them back, and turning off the bundled PostgreSQL subchart's own UID pins. - The cp-api startup-probe budget comment names the migration it was sized for. `charts/aisix` is authored here and already carries `proxy.listeners`, so it only takes the version bump. The public adaptations are preserved: `docker.io` registries, image tags derived from `.Chart.AppVersion`, the `api.dpImage` default of `docker.io/api7/aisix:`, and the chart READMEs. --- charts/aisix-cp/Chart.yaml | 4 +- charts/aisix-cp/README.md | 23 +-- charts/aisix-cp/templates/api-deployment.yaml | 134 +++++++++++++++++- .../templates/api-service-metrics.yaml | 22 +++ .../templates/api-servicemonitor.yaml | 35 +++++ charts/aisix-cp/values.yaml | 122 ++++++++++++++-- charts/aisix/Chart.yaml | 4 +- charts/aisix/README.md | 2 +- 8 files changed, 316 insertions(+), 30 deletions(-) create mode 100644 charts/aisix-cp/templates/api-service-metrics.yaml create mode 100644 charts/aisix-cp/templates/api-servicemonitor.yaml diff --git a/charts/aisix-cp/Chart.yaml b/charts/aisix-cp/Chart.yaml index 5e5e04f..8e81215 100644 --- a/charts/aisix-cp/Chart.yaml +++ b/charts/aisix-cp/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: aisix-cp description: Helm chart for AISIX control plane (cp-api, dp-manager, dashboard) type: application -version: 1.2.1 -appVersion: "1.2.0" +version: 1.3.0 +appVersion: "1.3.0" maintainers: - name: API7 diff --git a/charts/aisix-cp/README.md b/charts/aisix-cp/README.md index 3d0cc3a..1dadf24 100644 --- a/charts/aisix-cp/README.md +++ b/charts/aisix-cp/README.md @@ -1,6 +1,6 @@ # aisix-cp -![Version: 1.2.1](https://img.shields.io/badge/Version-1.2.1-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 1.2.0](https://img.shields.io/badge/AppVersion-1.2.0-informational?style=flat-square) +![Version: 1.3.0](https://img.shields.io/badge/Version-1.3.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 1.3.0](https://img.shields.io/badge/AppVersion-1.3.0-informational?style=flat-square) Helm chart for AISIX control plane (cp-api, dp-manager, dashboard) @@ -64,20 +64,29 @@ in your deployment source so subsequent GitOps syncs preserve the configuration. | Key | Type | Default | Description | |-----|------|---------|-------------| | api.affinity | object | `{}` | | +| api.corsAllowedOrigins | list | `[]` | | | api.dpImage | string | `""` | | | api.dpmgrBaseURL | string | `""` | | | api.extraEnvVars | list | `[]` | | | api.image.pullPolicy | string | `"IfNotPresent"` | | | api.image.repository | string | `"docker.io/api7/aisix-cp-api"` | | | api.image.tag | string | `""` | | +| api.metrics.enabled | bool | `true` | | +| api.metrics.port | int | `9090` | | +| api.metrics.service.annotations | object | `{}` | | +| api.metrics.service.port | int | `9090` | | +| api.metrics.serviceMonitor.enabled | bool | `false` | | +| api.metrics.serviceMonitor.interval | string | `"30s"` | | +| api.metrics.serviceMonitor.labels | object | `{}` | | +| api.metrics.serviceMonitor.metricRelabelings | list | `[]` | | +| api.metrics.serviceMonitor.namespace | string | `""` | | +| api.metrics.serviceMonitor.relabelings | list | `[]` | | +| api.metrics.serviceMonitor.scrapeTimeout | string | `""` | | | api.nodeSelector | object | `{}` | | | api.notifyAllowPrivateURLs | bool | `false` | | | api.oauthEnabled | bool | `false` | | | api.playgroundAllowPrivateIPs | bool | `false` | | -| api.podSecurityContext.fsGroup | int | `101` | | -| api.podSecurityContext.runAsGroup | int | `101` | | | api.podSecurityContext.runAsNonRoot | bool | `true` | | -| api.podSecurityContext.runAsUser | int | `10001` | | | api.podSecurityContext.seccompProfile.type | string | `"RuntimeDefault"` | | | api.publicBaseURL | string | `"http://localhost:8080"` | | | api.replicaCount | int | `1` | | @@ -98,10 +107,7 @@ in your deployment source so subsequent GitOps syncs preserve the configuration. | dpm.image.repository | string | `"docker.io/api7/aisix-cp-dpm"` | | | dpm.image.tag | string | `""` | | | dpm.nodeSelector | object | `{}` | | -| dpm.podSecurityContext.fsGroup | int | `101` | | -| dpm.podSecurityContext.runAsGroup | int | `101` | | | dpm.podSecurityContext.runAsNonRoot | bool | `true` | | -| dpm.podSecurityContext.runAsUser | int | `10001` | | | dpm.podSecurityContext.seccompProfile.type | string | `"RuntimeDefault"` | | | dpm.replicaCount | int | `1` | | | dpm.resources.limits.cpu | string | `"1"` | | @@ -157,10 +163,7 @@ in your deployment source so subsequent GitOps syncs preserve the configuration. | ui.image.repository | string | `"docker.io/api7/aisix-cp-ui"` | | | ui.image.tag | string | `""` | | | ui.nodeSelector | object | `{}` | | -| ui.podSecurityContext.fsGroup | int | `65533` | | -| ui.podSecurityContext.runAsGroup | int | `65533` | | | ui.podSecurityContext.runAsNonRoot | bool | `true` | | -| ui.podSecurityContext.runAsUser | int | `1001` | | | ui.podSecurityContext.seccompProfile.type | string | `"RuntimeDefault"` | | | ui.replicaCount | int | `1` | | | ui.resources.limits.cpu | string | `"500m"` | | diff --git a/charts/aisix-cp/templates/api-deployment.yaml b/charts/aisix-cp/templates/api-deployment.yaml index fe671c5..9d31809 100644 --- a/charts/aisix-cp/templates/api-deployment.yaml +++ b/charts/aisix-cp/templates/api-deployment.yaml @@ -38,6 +38,11 @@ spec: - name: http containerPort: 8080 protocol: TCP + {{- if .Values.api.metrics.enabled }} + - name: metrics + containerPort: {{ .Values.api.metrics.port }} + protocol: TCP + {{- end }} env: - name: PGPASSWORD valueFrom: @@ -53,6 +58,10 @@ spec: {{- end }} - name: AISIX_CLOUD_LISTEN value: ":8080" + {{- if .Values.api.metrics.enabled }} + - name: AISIX_CLOUD_METRICS_LISTEN + value: {{ printf ":%v" .Values.api.metrics.port | quote }} + {{- end }} {{- if .Values.twoDSN.enabled }} - name: AISIX_CLOUD_ADMIN_DATABASE_URL value: {{ include "aisix-cp.databaseURL" . }} @@ -64,6 +73,119 @@ spec: {{- end }} - name: AISIX_CLOUD_PUBLIC_BASE_URL value: {{ .Values.api.publicBaseURL | quote }} + {{- with .Values.api.corsAllowedOrigins }} + {{- /* cp-api refuses these at startup, which an operator meets as a + CrashLoopBackOff and a log line rather than as a failed + upgrade. Fail the render instead, where the message lands + next to the value that caused it. + + These rules are cors.ValidateOrigin's, and they are only + worth having if they are the SAME rules: a shape this + template waves through and cp-api then refuses turns the + crash loop it exists to prevent back on. Compared + lowercased, because that is what cp-api normalizes to + before deciding. */ -}} + {{- range . }} + {{- $entry := lower (trim .) }} + {{- if eq $entry "*" }} + {{- fail "api.corsAllowedOrigins must name explicit origins, not \"*\": it is a blanket grant, and cp-api refuses to start with one." }} + {{- end }} + {{- if hasPrefix "https://*" $entry }} + {{- /* A wildcard suffix has to begin at a label separator, or it + also matches a longer label: https://*api7ai.vercel.app + would admit evilapi7ai.vercel.app. */ -}} + {{- $suffix := trimPrefix "https://*" $entry }} + {{- if not (or (hasPrefix "-" $suffix) (hasPrefix "." $suffix)) }} + {{- fail (printf "api.corsAllowedOrigins entry %q: a wildcard suffix must start with \"-\" or \".\" so it cannot match a longer label." .) }} + {{- end }} + {{- if or (not (regexMatch "^[a-z0-9._-]+$" $suffix)) (hasSuffix "." $suffix) }} + {{- fail (printf "api.corsAllowedOrigins entry %q: a wildcard suffix must be a bare host suffix of ASCII letters, digits, dot, hyphen and underscore — a path, query, fragment, userinfo, port, trailing dot or non-ASCII name never matches a browser's Origin." .) }} + {{- end }} + {{- $body := trimPrefix "." (trimPrefix "-" $suffix) }} + {{- if lt (len (splitList "." $body)) 3 }} + {{- fail (printf "api.corsAllowedOrigins entry %q: a wildcard suffix must name at least three labels, or it covers a whole public suffix (https://*.vercel.app would admit every project on that host)." .) }} + {{- end }} + {{- else }} + {{- /* Exact origin. https is required, with the same loopback + exception cp-api makes so a port-forwarded dev console can + be listed. */ -}} + {{- $loopback := or (hasPrefix "http://localhost:" $entry) (hasPrefix "http://127.0.0.1:" $entry) (hasPrefix "http://[::1]:" $entry) (eq $entry "http://localhost") (eq $entry "http://127.0.0.1") (eq $entry "http://[::1]") }} + {{- if not (or (hasPrefix "https://" $entry) $loopback) }} + {{- fail (printf "api.corsAllowedOrigins entry %q must use https:// (loopback may use http)." .) }} + {{- end }} + {{- /* A positive shape check, not a blacklist of bad characters. + Blacklisting let `https://host:abc`, `https://`, an + unbalanced `[::1` and a percent-escape through — each of + which cp-api then refuses at startup, which is the crash + loop this whole block exists to convert into a message. + + Underscores and edge hyphens are admitted because a browser + admits them: refusing one here blocked an upgrade over a + value cp-api accepts. */ -}} + {{- $rest := trimPrefix "http://" (trimPrefix "https://" $entry) }} + {{- if not (regexMatch "^(\\[[0-9a-f:]+\\]|[a-z0-9_.-]+)(:[0-9]{1,5})?$" $rest) }} + {{- fail (printf "api.corsAllowedOrigins entry %q must be a bare origin like https://host[:port] — a path, query, fragment, userinfo, trailing dot, empty or non-numeric port, or a non-ASCII host (write its punycode form) never matches a browser's Origin header." .) }} + {{- end }} + {{- $hostOnly := regexReplaceAll ":[0-9]+$" $rest "" }} + {{- if hasSuffix "." $hostOnly }} + {{- fail (printf "api.corsAllowedOrigins entry %q must be a bare origin like https://host[:port] — a trailing dot never matches a browser's Origin header." .) }} + {{- end }} + {{- if hasPrefix "[" $hostOnly }} + {{- /* An IPv6 address has many spellings and a browser sends one. + This cannot canonicalize an address, but it can refuse the + spellings that differ from the canonical one by a written-out + or zero-padded hextet, which is every form anyone types by + hand. cp-api makes the exact check. */ -}} + {{- $inner := trimSuffix "]" (trimPrefix "[" $hostOnly) }} + {{- if or (regexMatch "(^|:)0{1,4}(:|$)" $inner) (regexMatch "(^|:)0[0-9a-f]" $inner) }} + {{- fail (printf "api.corsAllowedOrigins entry %q: an IPv6 origin must be written the way a browser sends it — compress the zero hextets and drop the leading zeros." .) }} + {{- end }} + {{- /* `::` stands for at least one omitted group, so a spelling + that also writes all eight is not an address at all. */ -}} + {{- $groups := without (splitList ":" $inner) "" }} + {{- if contains "::" $inner }} + {{- if gt (len $groups) 7 }} + {{- fail (printf "api.corsAllowedOrigins entry %q is not an IPv6 address — `::` stands for at least one omitted group." .) }} + {{- end }} + {{- else if ne (len $groups) 8 }} + {{- fail (printf "api.corsAllowedOrigins entry %q is not an IPv6 address — it needs eight groups, or `::` where they are omitted." .) }} + {{- end }} + {{- else }} + {{- /* A browser re-reads a host whose last label is numeric as an + IPv4 address and rewrites it: `https://2130706433` is sent as + `https://127.0.0.1`. Such an entry can only match if it + already is a dotted quad. */ -}} + {{- $lastLabel := last (splitList "." $hostOnly) }} + {{- if regexMatch "^(0[xX][0-9a-fA-F]*|[0-9]+)$" $lastLabel }} + {{- if not (regexMatch "^([1-9]?[0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])(\\.([1-9]?[0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])){3}$" $hostOnly) }} + {{- fail (printf "api.corsAllowedOrigins entry %q: a host whose last label is numeric is read as an IPv4 address and rewritten — write the dotted-quad form a browser sends." .) }} + {{- end }} + {{- end }} + {{- end }} + {{- /* A port has one spelling. Five digits is a shape, not a port: + a browser refuses a URL above 65535, strips leading zeros, + and omits the scheme's default port when it serializes an + origin — so `:0080` and `:443` on https parse fine here and + then match nothing. Port 0 is legal and simply dead. */ -}} + {{- $port := regexFind ":[0-9]+$" $rest }} + {{- if $port }} + {{- $digits := trimPrefix ":" $port }} + {{- $n := atoi $digits }} + {{- if gt $n 65535 }} + {{- fail (printf "api.corsAllowedOrigins entry %q: %s is not a port — a browser refuses a URL above 65535." . $port) }} + {{- end }} + {{- if ne (printf "%d" $n) $digits }} + {{- fail (printf "api.corsAllowedOrigins entry %q: a browser writes the port as %q, not %q." . (printf "%d" $n) $digits) }} + {{- end }} + {{- if or (and (hasPrefix "https://" $entry) (eq $n 443)) (and (hasPrefix "http://" $entry) (eq $n 80)) }} + {{- fail (printf "api.corsAllowedOrigins entry %q: a browser omits the scheme's default port — drop the %q." . $port) }} + {{- end }} + {{- end }} + {{- end }} + {{- end }} + - name: AISIX_CLOUD_CORS_ALLOWED_ORIGINS + value: {{ join "," . | quote }} + {{- end }} - name: AISIX_CLOUD_MASTER_KEY valueFrom: secretKeyRef: @@ -101,12 +223,12 @@ spec: # # 30 minutes, because a migration that REWRITES a table is # bounded by table size, not by a fixed bootstrap cost. The - # 0.9.0 widening of dpmgr_usage_events.request_id (uuid -> - # text) measured 6m21s on 10M rows — roughly what 30 days of - # retention holds at a sustained 4 req/s — and it scales - # linearly from there. The previous 5-minute budget would have - # turned that upgrade into the exact crash loop this probe - # exists to prevent. + # AISIX-Cloud#1288 widening of dpmgr_usage_events.request_id + # (uuid -> text) measured 6m21s on 10M rows — roughly what 30 + # days of retention holds at a sustained 4 req/s — and it + # scales linearly from there. The previous 5-minute budget + # would have turned that upgrade into the exact crash loop + # this probe exists to prevent. # # The cost is that a genuinely wedged cp-api goes unrestarted # for up to 30 minutes on FIRST start only; once the probe diff --git a/charts/aisix-cp/templates/api-service-metrics.yaml b/charts/aisix-cp/templates/api-service-metrics.yaml new file mode 100644 index 0000000..1405faa --- /dev/null +++ b/charts/aisix-cp/templates/api-service-metrics.yaml @@ -0,0 +1,22 @@ +{{- if .Values.api.metrics.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: {{ include "aisix-cp.fullname" . }}-api-metrics + labels: + {{- include "aisix-cp.labels" . | nindent 4 }} + {{- include "aisix-cp.selectorLabels" (dict "root" . "component" "api") | nindent 4 }} + {{- with .Values.api.metrics.service.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + type: ClusterIP + ports: + - name: metrics + port: {{ .Values.api.metrics.service.port }} + targetPort: metrics + protocol: TCP + selector: + {{- include "aisix-cp.selectorLabels" (dict "root" . "component" "api") | nindent 4 }} +{{- end }} diff --git a/charts/aisix-cp/templates/api-servicemonitor.yaml b/charts/aisix-cp/templates/api-servicemonitor.yaml new file mode 100644 index 0000000..efae377 --- /dev/null +++ b/charts/aisix-cp/templates/api-servicemonitor.yaml @@ -0,0 +1,35 @@ +{{- if and .Values.api.metrics.enabled .Values.api.metrics.serviceMonitor.enabled }} +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: {{ include "aisix-cp.fullname" . }}-api + namespace: {{ .Values.api.metrics.serviceMonitor.namespace | default .Release.Namespace }} + labels: + {{- include "aisix-cp.labels" . | nindent 4 }} + {{- include "aisix-cp.selectorLabels" (dict "root" . "component" "api") | nindent 4 }} + {{- with .Values.api.metrics.serviceMonitor.labels }} + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + namespaceSelector: + matchNames: + - {{ .Release.Namespace }} + selector: + matchLabels: + {{- include "aisix-cp.selectorLabels" (dict "root" . "component" "api") | nindent 6 }} + endpoints: + - port: metrics + path: /metrics + interval: {{ .Values.api.metrics.serviceMonitor.interval }} + {{- with .Values.api.metrics.serviceMonitor.scrapeTimeout }} + scrapeTimeout: {{ . }} + {{- end }} + {{- with .Values.api.metrics.serviceMonitor.relabelings }} + relabelings: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.api.metrics.serviceMonitor.metricRelabelings }} + metricRelabelings: + {{- toYaml . | nindent 8 }} + {{- end }} +{{- end }} diff --git a/charts/aisix-cp/values.yaml b/charts/aisix-cp/values.yaml index 3dd5486..ca3e561 100644 --- a/charts/aisix-cp/values.yaml +++ b/charts/aisix-cp/values.yaml @@ -17,6 +17,44 @@ api: ## to let Kubernetes allocate a port dynamically. Direct NodePort access ## is plain HTTP, so use a trusted private network or a TLS reverse proxy. nodePort: "" + ## Operator telemetry. Served on its OWN listener, never on the API port: + ## that one carries the Admin API and proxies the Dashboard and is the + ## externally reachable entry point, while these are internal operational + ## numbers (write volumes, resource cardinality). Nothing the Dashboard + ## shows comes from here. Metric names are the `aisix_cp_` family, so one + ## Prometheus can hold both planes and tell them apart by prefix. + metrics: + ## Serve Prometheus metrics from cp-api. On by default, matching the + ## gateway chart so both planes are scraped the same way; the port is + ## reachable only inside the cluster (ClusterIP). cp-api binds nothing + ## unless this sets the address, so turning it off leaves the port + ## genuinely unbound inside the pod rather than merely unexposed by + ## the Service. + enabled: true + ## Port the metrics listener binds inside the container + port: 9090 + service: + ## Metrics Service port, on a separate ClusterIP Service so scraping + ## never rides the API Service + port: 9090 + ## Extra annotations for the metrics Service, e.g. scrape hints for a + ## Prometheus that discovers by annotation rather than ServiceMonitor + annotations: {} + serviceMonitor: + ## Create a Prometheus Operator ServiceMonitor for the metrics Service + enabled: false + ## Namespace to create the ServiceMonitor in. Empty uses the release namespace + namespace: "" + ## Scrape interval + interval: 30s + ## Scrape timeout. Empty leaves the Prometheus default + scrapeTimeout: "" + ## Extra labels, e.g. the `release` label your Prometheus selects on + labels: {} + ## Scrape-time relabeling rules + relabelings: [] + ## Metric relabeling rules + metricRelabelings: [] resources: requests: cpu: 100m @@ -24,11 +62,12 @@ api: limits: cpu: "1" memory: 512Mi + ## No runAsUser / runAsGroup / fsGroup: the image runs under whatever UID + ## the platform assigns (OpenShift's restricted-v2 SCC picks a random one), + ## and nothing here is written to a volume that would need a matching owner. + ## Set those three keys to pin a fixed UID back. podSecurityContext: runAsNonRoot: true - runAsUser: 10001 - runAsGroup: 101 - fsGroup: 101 seccompProfile: type: RuntimeDefault securityContext: @@ -48,6 +87,48 @@ api: ## Publicly reachable cp-api URL. When api.service.type is NodePort, this ## must name the API NodePort endpoint (or the TLS reverse proxy in front). publicBaseURL: "http://localhost:8080" + ## Browser origins allowed to call cp-api cross-origin, as bare origins + ## (scheme://host[:port]). Empty — the default — writes no CORS headers + ## at all, which is what a normal install wants: cp-api serves the API + ## and proxies the dashboard, so the browser has only one origin and + ## nothing is ever cross-origin. + ## + ## Populate it only to let a dashboard served from somewhere else reach + ## this API directly, such as a frontend-only PR preview. + ## + ## An entry is a bare origin (https://host[:port]), or `https://*` plus a + ## suffix — a PR preview's host carries the branch name, so an exact list + ## would mean redeploying cp-api for every pull request: + ## + ## corsAllowedOrigins: + ## - https://*-api7ai.vercel.app + ## + ## Read the suffix as collision-resistance, NOT as a guarantee. It is a + ## byte suffix: "-" is legal inside a DNS label rather than a separator, + ## and a .vercel.app name is allocated first-come-first-served from a + ## project name its owner picks, so another account can claim one ending + ## the same way. A grant that needs a real boundary belongs on a domain + ## you control — point previews at one with Vercel's preview deployment + ## suffix and allowlist https://*.preview.. + ## + ## The governing rule: an entry must be spelled the way a BROWSER + ## serializes an origin. Anything that parses but serializes differently + ## could never equal an Origin header, so it is refused here with the + ## spelling to use rather than accepted as an entry that quietly matches + ## nothing. cp-api applies the same rules at startup, so a value this + ## chart accepts is one it will start with. + ## + ## Refused: bare "*", a suffix not starting with "-" or ".", a suffix + ## naming fewer than three labels (https://*.vercel.app would cover every + ## project on that host), anything carrying a path, query, fragment or + ## userinfo, a trailing dot, any non-https origin (loopback may use + ## http), an internationalized host given as anything but its punycode + ## form, a host whose last label is numeric without being a dotted quad + ## (a browser re-reads https://2130706433 as https://127.0.0.1), an IPv6 + ## address written in any spelling but the one it is sent in, the + ## scheme's default port written out (":443" on https), a port carrying + ## a leading zero, and a port above 65535. + corsAllowedOrigins: [] ## dp-manager /dp/* mTLS endpoint that data-plane hosts dial, as baked ## into the generated install snippets. Also passed to the dpm ## deployment, which seeds this host into the TLS server certificate it @@ -90,11 +171,12 @@ dpm: limits: cpu: "1" memory: 512Mi + ## No runAsUser / runAsGroup / fsGroup: the image runs under whatever UID + ## the platform assigns (OpenShift's restricted-v2 SCC picks a random one), + ## and nothing here is written to a volume that would need a matching owner. + ## Set those three keys to pin a fixed UID back. podSecurityContext: runAsNonRoot: true - runAsUser: 10001 - runAsGroup: 101 - fsGroup: 101 seccompProfile: type: RuntimeDefault securityContext: @@ -135,11 +217,12 @@ ui: limits: cpu: 500m memory: 256Mi + ## No runAsUser / runAsGroup / fsGroup: the image runs under whatever UID + ## the platform assigns (OpenShift's restricted-v2 SCC picks a random one). + ## The .next/cache emptyDir needs no fsGroup to be writable — the kubelet + ## creates it world-writable. Set those three keys to pin a fixed UID back. podSecurityContext: runAsNonRoot: true - runAsUser: 1001 - runAsGroup: 65533 - fsGroup: 65533 seccompProfile: type: RuntimeDefault securityContext: @@ -181,9 +264,30 @@ secrets: ## Set builtin=false and configure externalDatabase to use an existing PostgreSQL instance. ## API/DPM wait for PostgreSQL only in builtin mode. ## An external database must be ready before starting the control plane. +## +## The bundled Bitnami subchart pins a UID of its own — fsGroup 1001 in +## primary.podSecurityContext and runAsUser 1001 in +## primary.containerSecurityContext, both behind `enabled: true`. A subchart's +## values cannot be made conditional from here, so on a platform that assigns +## the UID itself (OpenShift's restricted-v2 SCC) turn those two off at install +## time: +## +## --set postgresql.primary.podSecurityContext.enabled=false \ +## --set postgresql.primary.containerSecurityContext.enabled=false +## +## Nothing else needs disabling. volumePermissions (the root chown init +## container) is already off by default, and the /dev/shm emptyDir needs no +## fsGroup — the kubelet mounts it world-writable. Turning shmVolume off would +## drop PostgreSQL back to the runtime's 64Mi /dev/shm and break parallel +## query with "could not resize shared memory segment". +## postgresql: builtin: true fullnameOverride: "" + ## Also used by the wait-for-pg init container on the api and dpm pods, which + ## carries no securityContext of its own: since the chart supplies no + ## runAsUser, `runAsNonRoot: true` is checked against THIS image's USER, and a + ## replacement that runs as root (or names its user) is refused at admission. image: registry: docker.io repository: api7/postgresql diff --git a/charts/aisix/Chart.yaml b/charts/aisix/Chart.yaml index b73fc82..64e3b99 100644 --- a/charts/aisix/Chart.yaml +++ b/charts/aisix/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: aisix description: Helm chart for the AISIX AI gateway data plane type: application -version: 1.2.0 -appVersion: "1.2.0" +version: 1.3.0 +appVersion: "1.3.0" keywords: - ai-gateway diff --git a/charts/aisix/README.md b/charts/aisix/README.md index de79241..2c5cce5 100644 --- a/charts/aisix/README.md +++ b/charts/aisix/README.md @@ -1,6 +1,6 @@ # aisix -![Version: 1.2.0](https://img.shields.io/badge/Version-1.2.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 1.2.0](https://img.shields.io/badge/AppVersion-1.2.0-informational?style=flat-square) +![Version: 1.3.0](https://img.shields.io/badge/Version-1.3.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 1.3.0](https://img.shields.io/badge/AppVersion-1.3.0-informational?style=flat-square) Helm chart for the AISIX AI gateway data plane From 590a801f107845f321e35d3a6ac9f497343211e4 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Fri, 18 Sep 2026 04:20:24 +0000 Subject: [PATCH 2/4] fix(aisix-cp): refuse malformed IPv6 CORS origins at render time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors AISIX-Cloud#1671 into the public chart, byte-identical to `helm/aisix-cp` there. The `api.corsAllowedOrigins` check counted colon-separated groups after `without … ""` had dropped the empty components a doubled or misplaced colon produces, so `https://[1::2::3]`, `https://[:::]`, `https://[1:::2]`, `https://[12345::1]`, `https://[1:2:3:4:5:6:7:8:]`, `https://[:1:2:3:4:5:6:7:8]`, `https://[1::2:]` and `https://[:1::2]` rendered. cp-api parses the host with net.ParseIP and refuses all eight, so it then refused to start — the crash loop this block exists to turn into a render-time message. The check now covers the whole of IPv6 validity over the alphabet the enclosing pattern admits: at most one `::`, every group one to four hex digits, and no colon on either end that is not part of the `::`. --- charts/aisix-cp/templates/api-deployment.yaml | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/charts/aisix-cp/templates/api-deployment.yaml b/charts/aisix-cp/templates/api-deployment.yaml index 9d31809..61d193f 100644 --- a/charts/aisix-cp/templates/api-deployment.yaml +++ b/charts/aisix-cp/templates/api-deployment.yaml @@ -140,9 +140,32 @@ spec: {{- if or (regexMatch "(^|:)0{1,4}(:|$)" $inner) (regexMatch "(^|:)0[0-9a-f]" $inner) }} {{- fail (printf "api.corsAllowedOrigins entry %q: an IPv6 origin must be written the way a browser sends it — compress the zero hextets and drop the leading zeros." .) }} {{- end }} + {{- /* Over this alphabet — the `$rest` pattern above admits only + hex digits and colons between the brackets — an address is + valid exactly when it carries at most one `::`, spells every + group as one to four hex digits, and begins and ends on a + group or on the `::` itself. Each rule below is one of those + clauses, and together they are the whole of validity: a + shape none of them refuses is one net.ParseIP accepts. + Counting groups alone left `1::2::3`, `:::`, `12345::1`, + `:1::2` and `1::2:` rendering, and cp-api then refused all + five at startup. */ -}} + {{- $groups := without (splitList ":" $inner) "" }} + {{- if or (contains ":::" $inner) (gt (len (splitList "::" $inner)) 2) }} + {{- fail (printf "api.corsAllowedOrigins entry %q is not an IPv6 address — `::` may appear once, and stands for the omitted groups." .) }} + {{- end }} + {{- range $groups }} + {{- if not (regexMatch "^[0-9a-f]{1,4}$" .) }} + {{- fail (printf "api.corsAllowedOrigins entry %q is not an IPv6 address — %q is not a group of one to four hex digits." $entry .) }} + {{- end }} + {{- end }} + {{- /* A colon on either end belongs to a `::`. On its own it is an + empty group, which `$groups` has already dropped. */ -}} + {{- if or (and (hasPrefix ":" $inner) (not (hasPrefix "::" $inner))) (and (hasSuffix ":" $inner) (not (hasSuffix "::" $inner))) }} + {{- fail (printf "api.corsAllowedOrigins entry %q is not an IPv6 address — a leading or trailing colon has no group in front of it." .) }} + {{- end }} {{- /* `::` stands for at least one omitted group, so a spelling that also writes all eight is not an address at all. */ -}} - {{- $groups := without (splitList ":" $inner) "" }} {{- if contains "::" $inner }} {{- if gt (len $groups) 7 }} {{- fail (printf "api.corsAllowedOrigins entry %q is not an IPv6 address — `::` stands for at least one omitted group." .) }} From 4bdbee984ea089507af2795d9cf68b750a693fb6 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Fri, 18 Sep 2026 04:32:09 +0000 Subject: [PATCH 3/4] fix(aisix-cp): correct the IPv6 comment in the CORS check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keeps the mirrored block byte-identical to `helm/aisix-cp` in the control plane after AISIX-Cloud#1671 was reworked. The comment had claimed the three clauses are the whole of IPv6 validity as measured by net.ParseIP, but the test cp-api applies is `ip.String() == host` — stricter than parsing — so the claim was complete for the wrong property. It now says what the clauses refuse and names the shared case list in the control plane as the thing that decides parity. --- charts/aisix-cp/templates/api-deployment.yaml | 30 ++++++++++++------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/charts/aisix-cp/templates/api-deployment.yaml b/charts/aisix-cp/templates/api-deployment.yaml index 61d193f..d853d08 100644 --- a/charts/aisix-cp/templates/api-deployment.yaml +++ b/charts/aisix-cp/templates/api-deployment.yaml @@ -140,16 +140,26 @@ spec: {{- if or (regexMatch "(^|:)0{1,4}(:|$)" $inner) (regexMatch "(^|:)0[0-9a-f]" $inner) }} {{- fail (printf "api.corsAllowedOrigins entry %q: an IPv6 origin must be written the way a browser sends it — compress the zero hextets and drop the leading zeros." .) }} {{- end }} - {{- /* Over this alphabet — the `$rest` pattern above admits only - hex digits and colons between the brackets — an address is - valid exactly when it carries at most one `::`, spells every - group as one to four hex digits, and begins and ends on a - group or on the `::` itself. Each rule below is one of those - clauses, and together they are the whole of validity: a - shape none of them refuses is one net.ParseIP accepts. - Counting groups alone left `1::2::3`, `:::`, `12345::1`, - `:1::2` and `1::2:` rendering, and cp-api then refused all - five at startup. */ -}} + {{- /* Counting groups alone is not enough to tell an address from + a typo: `without … ""` drops the empty components a doubled + or misplaced colon produces, so `1::2::3`, `:::`, `1:::2`, + `12345::1`, `1:2:3:4:5:6:7:8:`, `:1:2:3:4:5:6:7:8`, `1::2:` + and `:1::2` all reached the count as if well formed, and + cp-api refused every one of them at startup. + + The three rules below refuse a spelling that is not an + address at all: more than one `::`, a group that is not one + to four hex digits, and a colon on either end that is not + part of the `::`. + + They do NOT establish that what survives is a spelling + cp-api takes. That test is `ip.String() == host` — the + canonical form, which is stricter than parsing — and only + the shared case list decides whether the two agree. Add a + case to internal/cpapi/cors/testdata/origin-cases.txt; the + parity step in .github/workflows/ci-helm.yml runs the whole + list through this template and through cp-api and fails on + any entry they answer differently. */ -}} {{- $groups := without (splitList ":" $inner) "" }} {{- if or (contains ":::" $inner) (gt (len (splitList "::" $inner)) 2) }} {{- fail (printf "api.corsAllowedOrigins entry %q is not an IPv6 address — `::` may appear once, and stands for the omitted groups." .) }} From 5333fe9ff06af0b05a58645037b0c48f5188542a Mon Sep 17 00:00:00 2001 From: Jarvis Date: Fri, 18 Sep 2026 04:49:50 +0000 Subject: [PATCH 4/4] fix(aisix-cp): `::` omits at least two groups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the group-bound fix from AISIX-Cloud#1671, keeping the block byte-identical to `helm/aisix-cp` there. A canonical `::` compresses at least two zero groups — a single one is written out — so with `::` present the number of groups actually spelled is at most six, not seven. The chart rendered `https://[1:2:3:4:5:6::8]`, `https://[1::2:3:4:5:6:7]` and `https://[1:2:3:4:5:6:7::]`, all of which cp-api refuses at startup. --- charts/aisix-cp/templates/api-deployment.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/charts/aisix-cp/templates/api-deployment.yaml b/charts/aisix-cp/templates/api-deployment.yaml index d853d08..6cb2cbc 100644 --- a/charts/aisix-cp/templates/api-deployment.yaml +++ b/charts/aisix-cp/templates/api-deployment.yaml @@ -177,8 +177,8 @@ spec: {{- /* `::` stands for at least one omitted group, so a spelling that also writes all eight is not an address at all. */ -}} {{- if contains "::" $inner }} - {{- if gt (len $groups) 7 }} - {{- fail (printf "api.corsAllowedOrigins entry %q is not an IPv6 address — `::` stands for at least one omitted group." .) }} + {{- if gt (len $groups) 6 }} + {{- fail (printf "api.corsAllowedOrigins entry %q is not an IPv6 address — `::` stands for at least two omitted groups — a single zero group is written out." .) }} {{- end }} {{- else if ne (len $groups) 8 }} {{- fail (printf "api.corsAllowedOrigins entry %q is not an IPv6 address — it needs eight groups, or `::` where they are omitted." .) }}