Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .github/scripts/aisix-render-checks.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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" \
Expand Down
33 changes: 33 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
55 changes: 50 additions & 5 deletions charts/aisix/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -184,9 +185,46 @@ kubectl rollout restart deploy/<release>-aisix -n <namespace>
### 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, `<fullname>-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 <key>` or
`x-api-key: <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 <key>" 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

Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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. |
Expand Down
48 changes: 43 additions & 5 deletions charts/aisix/README.md.gotmpl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -178,9 +179,46 @@ kubectl rollout restart deploy/<release>-aisix -n <namespace>
### 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, `<fullname>-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 <key>` or
`x-api-key: <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 <key>" 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

Expand Down
2 changes: 1 addition & 1 deletion charts/aisix/config-policy.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
37 changes: 37 additions & 0 deletions charts/aisix/templates/_helpers.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down Expand Up @@ -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 }}
Expand Down Expand Up @@ -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.
*/}}
Expand All @@ -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 }}
Expand Down
5 changes: 5 additions & 0 deletions charts/aisix/templates/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
13 changes: 13 additions & 0 deletions charts/aisix/templates/secret.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
22 changes: 22 additions & 0 deletions charts/aisix/templates/service-admin.yaml
Original file line number Diff line number Diff line change
@@ -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 }}
Loading
Loading