diff --git a/.github/scripts/aisix-render-checks.sh b/.github/scripts/aisix-render-checks.sh index d0c10f8..488829c 100644 --- a/.github/scripts/aisix-render-checks.sh +++ b/.github/scripts/aisix-render-checks.sh @@ -82,6 +82,22 @@ check "config values land in the file, list-typed ones included" \ check "standalone mode renders no etcd or managed section" \ query '"etcd" not in cfg and "managed" not in cfg and cfg["admin"] == {"enabled": False} and cfg["resources_file"]' \ -f "$chart/ci/standalone-values.yaml" +admin_on=(-f "$chart/ci/standalone-values.yaml" --set admin.enabled=true --set 'admin.keys={k1,k2}') +check "admin disabled by default: admin.enabled false in the file, no admin port, env, Service or Secret" \ + query 'cfg["admin"] == {"enabled": False} and not any(p["name"] == "admin" for p in pod["containers"][0]["ports"]) and "AISIX_ADMIN__ADMIN_KEYS" not in env and not any(d["metadata"]["name"].endswith("-admin") for d in docs)' \ + -f "$chart/ci/standalone-values.yaml" +check "admin enabled: file binds the port, keys come from the chart Secret as env, ClusterIP Service on the admin port" \ + query 'cfg["admin"] == {"enabled": True, "addr": "0.0.0.0:3001"} and {"name": "admin", "containerPort": 3001, "protocol": "TCP"} in pod["containers"][0]["ports"] and env["AISIX_ADMIN__ADMIN_KEYS"]["valueFrom"]["secretKeyRef"] == {"name": "ci-aisix-admin", "key": "admin-keys"} and next(d for d in docs if d["kind"] == "Secret" and d["metadata"]["name"] == "ci-aisix-admin")["stringData"] == {"admin-keys": "k1,k2"} and next(d for d in docs if d["kind"] == "Service" and d["metadata"]["name"] == "ci-aisix-admin")["spec"]["type"] == "ClusterIP" and "k1" not in cm["data"]["config.yaml"] and all(p["name"] != "admin" for d in docs if d["kind"] == "Service" and d["metadata"]["name"] == "ci-aisix" for p in d["spec"]["ports"])' \ + "${admin_on[@]}" +check "admin with existingSecret reads that Secret and renders none of its own" \ + query 'env["AISIX_ADMIN__ADMIN_KEYS"]["valueFrom"]["secretKeyRef"] == {"name": "my-admin", "key": "keys"} and not any(d["kind"] == "Secret" and d["metadata"]["name"].endswith("-admin") for d in docs)' \ + -f "$chart/ci/standalone-values.yaml" --set admin.enabled=true --set admin.existingSecret=my-admin --set admin.existingSecretKey=keys +check "admin enabled without keys is refused" \ + refuses "admin.enabled requires admin keys" -f "$chart/ci/standalone-values.yaml" --set admin.enabled=true +check "admin enabled with a control plane is refused" \ + refuses "has no Admin API" --set admin.enabled=true --set 'admin.keys={k1}' +check "an admin key containing a comma is refused" \ + refuses "cannot contain a comma" -f "$chart/ci/standalone-values.yaml" --set admin.enabled=true --set 'admin.keys[0]=a\,b' check "a chart-owned key under config is refused" \ refuses "use containerPorts.proxy" --set config.proxy.addr=0.0.0.0:1 check "a credential under config is refused" \ diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index ed7ba3d..512b457 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -169,6 +169,39 @@ jobs: kubectl delete namespace "$ns" ' + - name: Test aisix chart with the Admin API + run: | + kubectl cluster-info + docker run --rm --interactive --network host \ + --name ct-aisix-admin \ + --volume $HOME/.kube/config:/root/.kube/config \ + --volume $PWD:/workdir \ + --workdir /workdir \ + quay.io/helmpack/chart-testing:v3.10.1 sh -c ' + set -e + ns=aisix-admin + kubectl create namespace "$ns" + if ! helm install aisix charts/aisix --namespace "$ns" \ + --values charts/aisix/ci/standalone-values.yaml \ + --set admin.enabled=true --set "admin.keys={ci-admin-key}" \ + --wait --timeout 5m; then + kubectl -n "$ns" get pods -o wide || true + kubectl -n "$ns" describe pods || true + kubectl -n "$ns" logs -l app.kubernetes.io/name=aisix --tail=200 || true + exit 1 + fi + # Reached through the admin Service: 401 without a key, 200 with one. + kubectl -n "$ns" run curl-admin --rm --attach --restart=Never \ + --image=curlimages/curl:8.11.1 --command -- sh -c " + code=\$(curl -s -o /dev/null -w %{http_code} http://aisix-admin:3001/admin/v1/models) + echo without key: \$code + test \$code = 401 + curl -fsS -o /dev/null -H \"Authorization: Bearer ci-admin-key\" http://aisix-admin:3001/admin/v1/models + echo with key: 200" + helm uninstall aisix --namespace "$ns" + kubectl delete namespace "$ns" + ' + - name: Setup Go uses: actions/setup-go@v5 with: diff --git a/charts/aisix/README.md b/charts/aisix/README.md index 7e64851..6c0dedb 100644 --- a/charts/aisix/README.md +++ b/charts/aisix/README.md @@ -87,8 +87,9 @@ Nothing under `controlPlane` is read, no certificate bundle is needed, and the gateway reads every resource — provider keys, models, caller API keys, guardrails, MCP servers, rate-limit policies — from one `resources.yaml`. The chart renders a startup configuration pointing at it, mounts it read-only at -`/etc/aisix/resources/resources.yaml`, and leaves the admin API unbound, so the -file is the only way resources are declared. +`/etc/aisix/resources/resources.yaml`. The file is the only way resources are +declared: the Admin API, off by default, only reads them (see +[Admin API](#admin-api)). Supply the file through exactly one of `standalone.resources`, `standalone.existingSecret`, or `standalone.existingConfigMap`; setting none or @@ -184,9 +185,46 @@ kubectl rollout restart deploy/-aisix -n ### What is not available Standalone mode has no control plane, so there is no console, no usage or budget -reporting, and no per-environment configuration distribution. The admin API is -left unbound as well: it is read-only against a file source, and binding it would -require admin keys the chart does not manage. +reporting, and no per-environment configuration distribution. + +### Admin API + +The gateway's Admin API is off by default. With `admin.enabled: true` the chart +binds it on `containerPorts.admin` (3001) and publishes it on its own ClusterIP +Service, `-admin` (`aisix-admin` for a release named `aisix`) — never +on the proxy Service. Against the resources file it is read-only: `/admin/v1/*` +lists and gets what the gateway loaded, including model status, and every +request there needs one of the admin keys as `Authorization: Bearer ` or +`x-api-key: `. The same listener serves the Playground, +`POST /playground/chat/completions`, which takes a caller API key exactly like +the proxy. + +The admin keys come from `admin.keys`, rendered into a chart-managed Secret, or +from a Secret you manage, named by `admin.existingSecret` (key +`admin.existingSecretKey`, default `admin-keys`; several keys comma-separated). +Either way they reach the gateway as the `AISIX_ADMIN__ADMIN_KEYS` environment +variable from that Secret and are never written to the config ConfigMap. +Enabling the API without keys fails the render, and so does enabling it with +`controlPlane.enabled: true`: a gateway connected to a control plane has no +Admin API. + +```yaml +controlPlane: + enabled: false +admin: + enabled: true + existingSecret: aisix-admin-keys +``` + +```sh +kubectl create secret generic aisix-admin-keys -n aisix --from-literal=admin-keys="$(openssl rand -hex 24)" +kubectl port-forward -n aisix svc/aisix-admin 3001:3001 +curl -H "Authorization: Bearer " http://127.0.0.1:3001/admin/v1/models +``` + +A release that already binds the Admin API through `AISIX_ADMIN__*` variables in +`extraEnvVars` keeps working unchanged, since the environment overrides the +config file; moving those settings to `admin` also gives the API its Service. ## Termination and draining @@ -482,6 +520,12 @@ rejects. New installs set the value in its own key in `values.yaml` or under | Key | Type | Default | Description | |-----|------|---------|-------------| +| admin.enabled | bool | `false` | Bind the Admin API on `containerPorts.admin` and create the admin Service. Requires `controlPlane.enabled=false` and admin keys from `keys` or `existingSecret` | +| admin.existingSecret | string | `""` | Read the admin keys from an existing Secret instead, so they stay out of your values file. The value is one key, or several separated by commas | +| admin.existingSecretKey | string | `"admin-keys"` | Secret key holding the admin keys | +| admin.keys | list | `[]` | Admin keys, rendered into a chart-managed Secret and passed to the gateway from it. A key cannot contain a comma | +| admin.service.annotations | object | `{}` | Extra annotations for the admin Service | +| admin.service.port | int | `3001` | Admin Service port | | affinity | object | `{}` | Affinity rules for the gateway pods | | autoscaling.behavior | object | `{}` | `spec.behavior` for the HPA. Empty uses the Kubernetes defaults (immediate scale-up, 5-minute scale-down stabilization). A gateway that carries long streaming responses usually wants a gentler scale-down, e.g. `scaleDown: {policies: [{type: Pods, value: 1, periodSeconds: 60}]}` | | autoscaling.enabled | bool | `false` | Create a HorizontalPodAutoscaler for the gateway Deployment | @@ -541,6 +585,7 @@ rejects. New installs set the value in its own key in `values.yaml` or under | config.upstream.tls.client_key_file | string | `nil` | Client key for mutual TLS to upstreams | | config.upstream.tls.verify | bool | `true` | Verify upstream certificates. false accepts any certificate — test use only | | configSecrets | object | `{}` | Configuration keys that hold a credential, each read from a Secret you supply and handed to the gateway as an environment variable (which overrides the config file), never written to the ConfigMap. Keyed by the configuration path: `cache.redis.url`, `cache.redis.username`, `cache.redis.password`, `ratelimit.redis.username` or `ratelimit.redis.password`. For example `{cache.redis.password: {secretName: redis-auth, key: password}}` | +| containerPorts.admin | int | `3001` | Port the Admin API listener binds inside the container. Bound only when `admin.enabled` is true | | containerPorts.metrics | int | `9090` | Port the Prometheus metrics listener binds inside the container | | containerPorts.proxy | int | `3000` | Port the proxy listener binds inside the container. Nothing binds it when `listeners` is set — that list then carries every proxy port, and the gateway keeps requiring this address only to ignore it. The image carries the `CAP_NET_BIND_SERVICE` file capability, so a privileged port works without running as root — see `securityContext` below | | controlPlane.baseURL | string | `""` | Data-plane manager mTLS endpoint the gateway connects out to, e.g. `https://dpm.example.com:7944`. Required. | diff --git a/charts/aisix/README.md.gotmpl b/charts/aisix/README.md.gotmpl index c984174..26e04b8 100644 --- a/charts/aisix/README.md.gotmpl +++ b/charts/aisix/README.md.gotmpl @@ -81,8 +81,9 @@ Nothing under `controlPlane` is read, no certificate bundle is needed, and the gateway reads every resource — provider keys, models, caller API keys, guardrails, MCP servers, rate-limit policies — from one `resources.yaml`. The chart renders a startup configuration pointing at it, mounts it read-only at -`/etc/aisix/resources/resources.yaml`, and leaves the admin API unbound, so the -file is the only way resources are declared. +`/etc/aisix/resources/resources.yaml`. The file is the only way resources are +declared: the Admin API, off by default, only reads them (see +[Admin API](#admin-api)). Supply the file through exactly one of `standalone.resources`, `standalone.existingSecret`, or `standalone.existingConfigMap`; setting none or @@ -178,9 +179,46 @@ kubectl rollout restart deploy/-aisix -n ### What is not available Standalone mode has no control plane, so there is no console, no usage or budget -reporting, and no per-environment configuration distribution. The admin API is -left unbound as well: it is read-only against a file source, and binding it would -require admin keys the chart does not manage. +reporting, and no per-environment configuration distribution. + +### Admin API + +The gateway's Admin API is off by default. With `admin.enabled: true` the chart +binds it on `containerPorts.admin` (3001) and publishes it on its own ClusterIP +Service, `-admin` (`aisix-admin` for a release named `aisix`) — never +on the proxy Service. Against the resources file it is read-only: `/admin/v1/*` +lists and gets what the gateway loaded, including model status, and every +request there needs one of the admin keys as `Authorization: Bearer ` or +`x-api-key: `. The same listener serves the Playground, +`POST /playground/chat/completions`, which takes a caller API key exactly like +the proxy. + +The admin keys come from `admin.keys`, rendered into a chart-managed Secret, or +from a Secret you manage, named by `admin.existingSecret` (key +`admin.existingSecretKey`, default `admin-keys`; several keys comma-separated). +Either way they reach the gateway as the `AISIX_ADMIN__ADMIN_KEYS` environment +variable from that Secret and are never written to the config ConfigMap. +Enabling the API without keys fails the render, and so does enabling it with +`controlPlane.enabled: true`: a gateway connected to a control plane has no +Admin API. + +```yaml +controlPlane: + enabled: false +admin: + enabled: true + existingSecret: aisix-admin-keys +``` + +```sh +kubectl create secret generic aisix-admin-keys -n aisix --from-literal=admin-keys="$(openssl rand -hex 24)" +kubectl port-forward -n aisix svc/aisix-admin 3001:3001 +curl -H "Authorization: Bearer " http://127.0.0.1:3001/admin/v1/models +``` + +A release that already binds the Admin API through `AISIX_ADMIN__*` variables in +`extraEnvVars` keeps working unchanged, since the environment overrides the +config file; moving those settings to `admin` also gives the API its Service. ## Termination and draining diff --git a/charts/aisix/config-policy.yaml b/charts/aisix/config-policy.yaml index fef43ca..66f219c 100644 --- a/charts/aisix/config-policy.yaml +++ b/charts/aisix/config-policy.yaml @@ -7,7 +7,7 @@ # render with this message. owned: resources_file: "set by the chart in standalone mode: supply the file through standalone.resources, standalone.existingSecret or standalone.existingConfigMap" - admin: "the admin listener is not deployed by this chart" + admin: "use admin.enabled, admin.keys or admin.existingSecret, and containerPorts.admin" etcd.endpoints: "set by the chart from the control-plane connection" etcd.prefix: "set by the chart from the control-plane connection" etcd.env_id: "not configurable through this chart" diff --git a/charts/aisix/templates/_helpers.tpl b/charts/aisix/templates/_helpers.tpl index 876bc34..cd5e95a 100644 --- a/charts/aisix/templates/_helpers.tpl +++ b/charts/aisix/templates/_helpers.tpl @@ -266,8 +266,13 @@ rejected by "aisix.validateValues" before this runs. {{- $_ := unset $cfg "etcd" }} {{- $_ := unset $cfg "managed" }} {{- $_ := set $cfg "resources_file" (include "aisix.standaloneResourcesPath" .) }} +{{- if include "aisix.adminEnabled" . }} +{{- /* The keys arrive as AISIX_ADMIN__ADMIN_KEYS from a Secret, never in this file. */}} +{{- $_ := set $cfg "admin" (dict "enabled" true "addr" (printf "0.0.0.0:%d" (int .Values.containerPorts.admin))) }} +{{- else }} {{- $_ := set $cfg "admin" (dict "enabled" false) }} {{- end }} +{{- end }} {{- include "aisix.dropNulls" $cfg }} {{- toYaml $cfg }} {{- end }} @@ -306,6 +311,9 @@ gave it where the chart no longer sets it. {{- if and (eq .Values.rateLimit.backend "redis") (or .Values.rateLimit.redis.url .Values.rateLimit.redis.existingSecret) }} {{- $chart = append $chart (dict "name" "AISIX_RATELIMIT__REDIS__URL" "valueFrom" (dict "secretKeyRef" (dict "name" (include "aisix.redisSecretName" .) "key" (include "aisix.redisSecretKey" .)))) }} {{- end }} +{{- if include "aisix.adminEnabled" . }} +{{- $chart = append $chart (dict "name" "AISIX_ADMIN__ADMIN_KEYS" "valueFrom" (dict "secretKeyRef" (dict "name" (include "aisix.adminSecretName" .) "key" (include "aisix.adminSecretKey" .)))) }} +{{- end }} {{- range $path, $ref := (.Values.configSecrets | default dict) }} {{- $chart = append $chart (dict "name" (include "aisix.configSecretEnvName" $path) "valueFrom" (dict "secretKeyRef" (dict "name" $ref.secretName "key" $ref.key))) }} {{- end }} @@ -373,6 +381,22 @@ AISIX_ plus the path upper-cased with `.` as `__`. AISIX_{{ . | upper | replace "." "__" }} {{- end }} +{{/* +"aisix.adminEnabled" is non-empty when the chart deploys the Admin API. +`admin` may be absent under `helm upgrade --reuse-values` from an older chart. +*/}} +{{- define "aisix.adminEnabled" -}} +{{- if (.Values.admin | default dict).enabled }}true{{ end }} +{{- end }} + +{{- define "aisix.adminSecretName" -}} +{{- .Values.admin.existingSecret | default (printf "%s-admin" (include "aisix.fullname" .)) }} +{{- end }} + +{{- define "aisix.adminSecretKey" -}} +{{- if .Values.admin.existingSecret }}{{ .Values.admin.existingSecretKey | default "admin-keys" }}{{ else }}admin-keys{{ end }} +{{- end }} + {{/* Reject value combinations that render successfully but cannot run. */}} @@ -395,6 +419,19 @@ Reject value combinations that render successfully but cannot run. {{- fail "controlPlane.enabled=false requires exactly one resource source: standalone.resources, standalone.existingSecret, or standalone.existingConfigMap" }} {{- end }} {{- end }} +{{- if include "aisix.adminEnabled" . }} +{{- if .Values.controlPlane.enabled }} +{{- fail "admin.enabled requires controlPlane.enabled=false: a gateway connected to a control plane has no Admin API" }} +{{- end }} +{{- if not (or .Values.admin.existingSecret .Values.admin.keys) }} +{{- fail "admin.enabled requires admin keys: set admin.keys, or admin.existingSecret holding them" }} +{{- end }} +{{- range .Values.admin.keys }} +{{- if or (not .) (contains "," (toString .)) }} +{{- fail "admin.keys entries must be non-empty and cannot contain a comma: the gateway reads the list comma-separated" }} +{{- end }} +{{- end }} +{{- end }} {{- if and .Values.autoscaling.enabled .Values.keda.enabled }} {{- fail "autoscaling.enabled and keda.enabled are mutually exclusive: two controllers writing spec.replicas fight over the replica count" }} {{- end }} diff --git a/charts/aisix/templates/deployment.yaml b/charts/aisix/templates/deployment.yaml index 0e01bfa..cf40fd1 100644 --- a/charts/aisix/templates/deployment.yaml +++ b/charts/aisix/templates/deployment.yaml @@ -77,6 +77,11 @@ spec: - name: metrics containerPort: {{ .Values.containerPorts.metrics }} protocol: TCP + {{- if include "aisix.adminEnabled" . }} + - name: admin + containerPort: {{ .Values.containerPorts.admin }} + protocol: TCP + {{- end }} env: {{- include "aisix.env" . | nindent 12 }} {{- if .Values.startupProbe.enabled }} diff --git a/charts/aisix/templates/secret.yaml b/charts/aisix/templates/secret.yaml index a8e951f..e89bbe9 100644 --- a/charts/aisix/templates/secret.yaml +++ b/charts/aisix/templates/secret.yaml @@ -44,3 +44,16 @@ type: Opaque stringData: redis-url: {{ .Values.rateLimit.redis.url | quote }} {{- end }} +{{- if and (include "aisix.adminEnabled" .) (not .Values.admin.existingSecret) }} +--- +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "aisix.adminSecretName" . }} + labels: + {{- include "aisix.labels" . | nindent 4 }} + {{- include "aisix.selectorLabels" . | nindent 4 }} +type: Opaque +stringData: + admin-keys: {{ join "," .Values.admin.keys | quote }} +{{- end }} diff --git a/charts/aisix/templates/service-admin.yaml b/charts/aisix/templates/service-admin.yaml new file mode 100644 index 0000000..0cc222b --- /dev/null +++ b/charts/aisix/templates/service-admin.yaml @@ -0,0 +1,22 @@ +{{- if include "aisix.adminEnabled" . }} +apiVersion: v1 +kind: Service +metadata: + name: {{ include "aisix.fullname" . }}-admin + labels: + {{- include "aisix.labels" . | nindent 4 }} + {{- include "aisix.selectorLabels" . | nindent 4 }} + {{- with .Values.admin.service.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + type: ClusterIP + ports: + - name: admin + port: {{ .Values.admin.service.port }} + targetPort: admin + protocol: TCP + selector: + {{- include "aisix.selectorLabels" . | nindent 4 }} +{{- end }} diff --git a/charts/aisix/values.yaml b/charts/aisix/values.yaml index 6b89e40..5873ab6 100644 --- a/charts/aisix/values.yaml +++ b/charts/aisix/values.yaml @@ -93,6 +93,9 @@ containerPorts: proxy: 3000 # -- Port the Prometheus metrics listener binds inside the container metrics: 9090 + # -- Port the Admin API listener binds inside the container. Bound only when + # `admin.enabled` is true + admin: 3001 ## Several proxy listeners at once — for example HTTPS and plain HTTP side by ## side, each on its own port. Empty (the default) keeps the single plain-HTTP @@ -163,6 +166,33 @@ metrics: # -- Metric relabeling rules metricRelabelings: [] +## The gateway's Admin API, standalone mode only (a gateway connected to a +## control plane never binds it). Against the resources file it is read-only: +## `/admin/v1/*` lists and gets what the gateway loaded — resources, model +## status — and needs one of the admin keys (`Authorization: Bearer ` or +## `x-api-key: `). The same listener serves the Playground +## (`POST /playground/chat/completions`, authenticated with a caller API key +## like the proxy). It is published on its own ClusterIP Service, +## `-admin`, never on the proxy Service. +admin: + # -- Bind the Admin API on `containerPorts.admin` and create the admin + # Service. Requires `controlPlane.enabled=false` and admin keys from `keys` + # or `existingSecret` + enabled: false + # -- Admin keys, rendered into a chart-managed Secret and passed to the + # gateway from it. A key cannot contain a comma + keys: [] + # -- Read the admin keys from an existing Secret instead, so they stay out of + # your values file. The value is one key, or several separated by commas + existingSecret: "" + # -- Secret key holding the admin keys + existingSecretKey: "admin-keys" + service: + # -- Admin Service port + port: 3001 + # -- Extra annotations for the admin Service + annotations: {} + ## Rate-limit counters are per-replica by default, so N replicas enforce N× ## every configured limit. Point the gateway at a shared Redis before running ## more than one replica — including any replica an autoscaler adds. @@ -192,7 +222,7 @@ rateLimit: ## metrics addresses (`containerPorts`), `proxy.listeners` / `proxy.tls` ## (`listeners`), `ratelimit.backend` and `ratelimit.redis.url` (`rateLimit`), ## the control-plane connection under `managed` (`controlPlane`), -## `resources_file` (`standalone`), and `admin` (not deployed by this chart). +## `resources_file` (`standalone`), and `admin` (`admin` and `containerPorts.admin`). ## Keys that hold a credential are never written to the ConfigMap either: set ## them through `configSecrets` below. ##