diff --git a/charts/aisix-cp/Chart.yaml b/charts/aisix-cp/Chart.yaml index c1dcff9..8721dec 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.5.0 -appVersion: "1.5.0" +version: 1.6.0 +appVersion: "1.6.0" maintainers: - name: API7 diff --git a/charts/aisix-cp/README.md b/charts/aisix-cp/README.md index 678d7ed..c872cf7 100644 --- a/charts/aisix-cp/README.md +++ b/charts/aisix-cp/README.md @@ -1,6 +1,6 @@ # aisix-cp -![Version: 1.5.0](https://img.shields.io/badge/Version-1.5.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 1.5.0](https://img.shields.io/badge/AppVersion-1.5.0-informational?style=flat-square) +![Version: 1.6.0](https://img.shields.io/badge/Version-1.6.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 1.6.0](https://img.shields.io/badge/AppVersion-1.6.0-informational?style=flat-square) Helm chart for AISIX control plane (cp-api, dp-manager, dashboard) @@ -9,13 +9,13 @@ budgets, caching, guardrails, and observability behind an OpenAI-compatible API. This chart installs the **control plane** — the console and management services that configure the gateways and collect what they report. -It installs three services and, by default, a PostgreSQL database: +It installs two services and, by default, a PostgreSQL database: -* `cp-api` — the Admin API, and the externally reachable entry point. It also - reverse-proxies the dashboard, so the console and the API share one origin. +* `cp-api` — the Admin API and the console, and the externally reachable entry + point. It serves the dashboard itself, so the console and the API share one + origin. * `dp-manager` — issues gateway certificates over mutual TLS and delivers configuration to the gateways. -* `dashboard` — the Next.js console, reached through `cp-api`. * PostgreSQL — the shared datastore. The chart deploys a bundled single instance by default, or connects to an [external database](#external-postgresql). @@ -44,13 +44,12 @@ to gateway hosts. * OpenSSL, to generate the secrets below * Secrets, which have no defaults you can deploy with. **The chart fails the render** while one is missing or still holds its placeholder, so an install - that supplies none of them stops before anything reaches the cluster. Two are + that supplies none of them stops before anything reaches the cluster. One is needed in every mode: | Value | Generate with | | --- | --- | | `secrets.masterKey` | `openssl rand -base64 32` | - | `secrets.betterAuthSecret` | `openssl rand -base64 48` | The database credentials depend on which database you use. With the bundled PostgreSQL — `postgresql.builtin: true`, the default — set both of these, @@ -76,8 +75,8 @@ to gateway hosts. private key of the certificate authority that issued your gateway certificates. Neither is recoverable from a database backup without it, so an upgrade or a reinstall that supplies a *new* key leaves the existing rows -unreadable. Store it, its `secrets.masterKeyID`, and `secrets.betterAuthSecret` -the way you store a database backup — see +unreadable. Store it and its `secrets.masterKeyID` the way you store a database +backup — see [Backup and Recovery](https://docs.api7.ai/ai-gateway/on-premises/backup-and-recovery). ## Install @@ -89,7 +88,6 @@ upgrade with, and it is the only copy of the master key. cat > cp-values.yaml <`, so a default install runs the control +An empty `api.image.tag` or `dpm.image.tag` resolves to the chart's +`appVersion`, and an empty `api.dpImage` makes `cp-api` hand out +`docker.io/api7/aisix:`, so a default install runs the control plane and hands out the gateway image from the same release as the chart. ## Values @@ -396,13 +399,23 @@ plane and hands out the gateway image from the same release as the chart. | Key | Type | Default | Description | |-----|------|---------|-------------| | api.affinity | object | `{}` | Affinity rules for the cp-api pod | +| api.allowUnsupportedUpgrade | bool | `false` | Let cp-api migrate a database last run by a release older than this one supports upgrading from. cp-api refuses such an upgrade at startup and names the release to pass through first; set this only after reading that message and taking a database backup, then set it back. | +| api.audit.retentionDays | int | `730` | Days an audit event is kept before it is deleted. 0 keeps audit events forever | +| api.bearerSessions | bool | `false` | Hand the session token to a cross-site console client (a frontend-only PR preview) and accept it back in an Authorization header. Leave off for a normal install: it exposes the session token to page scripts. | +| api.bootstrapAdmin.email | string | `"admin@example.com"` | Email of the first administrator | +| api.bootstrapAdmin.existingSecret | string | `""` | Name of an existing Secret holding the initial password instead of the password value above | +| api.bootstrapAdmin.existingSecretKey | string | `"bootstrap-admin-password"` | Key inside bootstrapAdmin.existingSecret holding the password | +| api.bootstrapAdmin.password | string | `""` | Initial password (12 to 128 characters), stored in the chart's Secret. Ignored when existingSecret is set. Empty generates one (see above) | | api.corsAllowedOrigins | list | `[]` | 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 naming at least three labels (`https://*-api7ai.vercel.app`) — a PR preview's host carries the branch name, so an exact list would mean redeploying cp-api for every pull request. Read the suffix as collision-resistance, NOT as a guarantee: it is a byte suffix, and on a shared host like `.vercel.app` another account can claim a name ending the same way, so a grant that needs a real boundary belongs on a domain you control. Every entry must be spelled the way a BROWSER serializes an origin, and the chart refuses one that is not, naming the spelling to use — a bare `*`, a path, query, fragment or userinfo, a trailing dot, a non-https origin (loopback may use http), an internationalized host given as anything but punycode, a numeric last label that is not a dotted quad, an IPv6 address in any spelling but the one it is sent in, the scheme's default port written out, a port with a leading zero, and a port above 65535. cp-api applies the same rules at startup, so a value this chart accepts is one it will start with. | -| api.dpImage | string | `""` | Gateway image the console hands out in the install snippets it generates for a new data plane | +| api.deploymentMode | string | `"on-premises"` | How this control plane is deployed: `on-premises` or `saas`. `on-premises` serves one company's organizations: nobody signs up on their own (accounts come from invitations), organizations cannot be created, billing is off, and cp-api's outbound calls may reach private addresses unless an outbound value below says otherwise. `saas` is the multi-tenant hosted service — open sign-up, organization creation and billing — and requires twoDSN.enabled | +| api.dpImage | string | `""` | Gateway image the console hands out in the install snippets it generates for a new data plane. Empty uses cp-api's own default: the gateway of the same release, docker.io/api7/aisix:, or ghcr.io/api7/aisix:dev on a build that is not a release | | api.dpmgrBaseURL | string | `""` | 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 presents — so an IP address works here, not only a DNS name. When dpm.service.type is NodePort, use https://:. | -| api.extraEnvVars | list | `[]` | Extra environment for cp-api. This is also where AISIX_ALLOW_UNSUPPORTED_UPGRADE=1 goes when an upgrade has to proceed from a release older than this one supports upgrading from — cp-api refuses that upgrade at startup and names the version it needs. | +| api.extraEnvVars | list | `[]` | Extra environment for cp-api: system-level settings only (for example TZ). Every cp-api setting has its own value in this section; an env var set here for one of them still overrides the rendered config file, which is how values set this way before those keys existed keep working. Overriding a name the chart itself sets is only a compatibility path for existing deployments. For a name chart 1.5.0 already set, the chart keeps its own entry ahead of yours, so the name appears twice and Helm 4's server-side apply rejects the install. New installs set the value in this file instead. | +| api.guardrailTestConnectionAllowInsecure | bool | `nil` | Whether the guardrail "test connection" probe may reach plain http, private addresses and non-Azure hosts (an internal or Private Link endpoint). Unset follows deploymentMode: allowed on-premises, blocked on saas | | api.image.pullPolicy | string | `"IfNotPresent"` | Image pull policy | | api.image.repository | string | `"docker.io/api7/aisix-cp-api"` | cp-api image repository | | api.image.tag | string | `""` | Image tag | +| api.mcpSpecAllowPrivateURLs | bool | `nil` | Whether an MCP server's OpenAPI spec_url may be fetched from a private / internal address. Unset follows deploymentMode: allowed on-premises, blocked on saas, where pasting the spec content still works | | api.metrics.enabled | bool | `true` | 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. | | api.metrics.port | int | `9090` | Port the metrics listener binds inside the container | | api.metrics.service.annotations | object | `{}` | Extra annotations for the metrics Service, e.g. scrape hints for a Prometheus that discovers by annotation rather than ServiceMonitor | @@ -415,12 +428,13 @@ plane and hands out the gateway image from the same release as the chart. | api.metrics.serviceMonitor.relabelings | list | `[]` | Scrape-time relabeling rules | | api.metrics.serviceMonitor.scrapeTimeout | string | `""` | Scrape timeout. Empty leaves the Prometheus default | | api.nodeSelector | object | `{}` | Node selector for the cp-api pod | -| api.notifyAllowPrivateURLs | bool | `false` | Allow notification channels (budget alert webhooks / Slack) to point at private / internal addresses. Blocked by default (SSRF guard); enable only for On-Premises deployments whose webhook receivers live on an intranet the cp-api pod can route to. | -| api.oauthEnabled | bool | `false` | Offer Google / GitHub sign-in on the console's login page. Leave off unless the deployment carries OAuth client credentials | -| api.playgroundAllowPrivateIPs | bool | `false` | Allow the dashboard playground to reach LLM endpoints on private / internal networks. cp-api blocks private IPs by default (SSRF guard); enable this only for On-Premises deployments whose models live on an internal network the cp-api pod can route to. | +| api.notifyAllowPrivateURLs | bool | `nil` | Whether notification channels (budget alert webhooks / Slack) may deliver to private / internal addresses. Unset follows deploymentMode: allowed on-premises, blocked on saas | +| api.playgroundAllowPrivateIPs | bool | `nil` | Whether the dashboard playground may call LLM endpoints on private / internal addresses. Unset follows deploymentMode: allowed on-premises, blocked on saas (SSRF guard). Set true or false to override | | api.podSecurityContext.runAsNonRoot | bool | `true` | Refuse to run the cp-api pod as root | | api.podSecurityContext.seccompProfile.type | string | `"RuntimeDefault"` | Seccomp profile for the cp-api pod | -| api.publicBaseURL | string | `"http://localhost:8080"` | Publicly reachable cp-api URL. Better Auth validates the session JWT issuer against it, so a value that does not match the address the browser uses breaks sign-in. When api.service.type is NodePort, this must name the API NodePort endpoint (or the TLS reverse proxy in front). | +| api.pricesyncSnapshotPath | string | `""` | Offline pricing: seed the catalog once at startup from this models.dev api.json capture (plain or .gz) and never fetch. The cp-api image ships one at /etc/aisix/models-dev-snapshot.json.gz. Empty selects online sync, which loads that bundled snapshot when a sync fails while the catalog is still empty (an install without internet access), keeps an existing catalog, and refreshes every model a later sync returns | +| api.pricesyncURL | string | `""` | Model-pricing catalog URL cp-api syncs from (the models.dev api.json format). Empty uses the public models.dev endpoint; point it at an internal mirror when cp-api has no internet access. Ignored when pricesyncSnapshotPath is set | +| api.publicBaseURL | string | `"http://localhost:8080"` | Publicly reachable cp-api URL. Console sign-in trusts this origin (and its localhost/127.0.0.1 twin), so a value that does not match the address the browser uses breaks sign-in unless that address is in api.trustedOrigins. When api.service.type is NodePort, this must name the API NodePort endpoint (or the TLS reverse proxy in front). | | api.replicaCount | int | `1` | Number of cp-api replicas | | api.resources.limits.cpu | string | `"1"` | cp-api CPU limit | | api.resources.limits.memory | string | `"512Mi"` | cp-api memory limit | @@ -432,9 +446,20 @@ plane and hands out the gateway image from the same release as the chart. | api.service.nodePort | string | `""` | Optional fixed NodePort. Used only when type is NodePort; leave empty to let Kubernetes allocate a port dynamically. Direct NodePort access is plain HTTP, so use a trusted private network or a TLS reverse proxy. | | api.service.port | int | `8080` | cp-api Service port. This port carries both the Admin API and the Dashboard | | api.service.type | string | `"ClusterIP"` | cp-api Service type. ClusterIP keeps it inside the cluster; use NodePort or an Ingress to reach the console from outside | +| api.smtp.existingSecret | string | `""` | Name of an existing Secret holding the SMTP password instead of the password value above | +| api.smtp.existingSecretKey | string | `"smtp-password"` | Key inside smtp.existingSecret holding the password | +| api.smtp.from | string | `""` | Sender address of every email, e.g. `AISIX `. Required when host is set | +| api.smtp.host | string | `""` | SMTP relay host name. Empty disables email | +| api.smtp.invitationLimitPerHour | int | `50` | Most invitation emails one inviter account may send per rolling hour, across every organization; creating and resending an invitation both count, and one over the limit is refused (HTTP 429) with nothing created. Counted only while email is on. Keep it in place on a deployment with open public sign-up: anyone can create an organization there, and would otherwise send unlimited mail through your relay | +| api.smtp.password | string | `""` | SMTP password, stored in the chart's Secret. Ignored when existingSecret is set | +| api.smtp.port | int | `0` | SMTP relay port. 0 uses the default for the TLS mode: 587 for starttls, 465 for smtps, 25 for none | +| api.smtp.tls | string | `"starttls"` | How the connection is protected: `starttls` (plain connection upgraded with STARTTLS; refused if the relay does not offer it), `smtps` (TLS from the first byte) or `none` (never encrypted). The relay's certificate is always verified against the system roots | +| api.smtp.username | string | `""` | Username for SMTP authentication. Set together with a password (password or existingSecret), or leave both empty to send without authenticating. With tls `none` the credentials cross the network unencrypted | | api.tolerations | list | `[]` | Tolerations for the cp-api pod | +| api.trustedOrigins | list | `[]` | Extra origins the console accepts sign-in from, as bare origins (https://host[:port]) or `https://*` patterns. The api.publicBaseURL origin and its loopback twin are always trusted; add another hostname of this console here, or the local address of a port-forward that differs from api.publicBaseURL. An entry cp-api cannot read as an origin is logged and ignored. | +| api.trustedProxies | list | `[]` | Reverse proxies (CIDRs, e.g. `10.0.0.0/8`) in front of cp-api whose X-Forwarded-For cp-api believes. Console sign-in and password reset rate limiting, and the client address recorded on a session, then use the first address from the right that is not one of these proxies. Empty — the default — uses the address of whatever connected to cp-api, so behind an ingress every client shares the ingress's rate-limit budget. List only proxies you run: a range listed here can name any client address it likes. | | dpm.affinity | object | `{}` | Affinity rules for the dp-manager pod | -| dpm.extraEnvVars | list | `[]` | Extra environment for dp-manager | +| dpm.extraEnvVars | list | `[]` | Extra environment for dp-manager: system-level settings only (for example TZ). An env var set here for a dp-manager setting still overrides the rendered config file. Overriding a name the chart itself sets is only a compatibility path for existing deployments. For a name chart 1.5.0 already set, the chart keeps its own entry ahead of yours, so the name appears twice and Helm 4's server-side apply rejects the install. New installs set the value in this file instead. | | dpm.image.pullPolicy | string | `"IfNotPresent"` | Image pull policy | | dpm.image.repository | string | `"docker.io/api7/aisix-cp-dpm"` | dp-manager image repository | | dpm.image.tag | string | `""` | Image tag | @@ -459,7 +484,9 @@ plane and hands out the gateway image from the same release as the chart. | externalDatabase.host | string | `""` | PostgreSQL host | | externalDatabase.password | string | `""` | If existingSecret is empty, this password is used directly. Use a URL-safe value: it is embedded in a `postgres://` DSN | | externalDatabase.port | int | `5432` | PostgreSQL port | -| externalDatabase.sslmode | string | `"disable"` | libpq sslmode for the connection, e.g. `require` or `verify-full` for a managed database reached over a network you do not control | +| externalDatabase.sslRootCert.existingSecret | string | `""` | Name of an existing Secret holding the PEM CA bundle that issued the database server's certificate. Mounted read-only into cp-api and dp-manager and used as libpq `sslrootcert` for every database connection they open. Empty leaves verification to the system trust store | +| externalDatabase.sslRootCert.key | string | `"ca.crt"` | Key in that Secret holding the CA bundle | +| externalDatabase.sslmode | string | `"disable"` | libpq sslmode for the connection, e.g. `require` or `verify-full` for a managed database reached over a network you do not control. `verify-ca` and `verify-full` check the server certificate against the image's system trust store unless `sslRootCert` supplies the CA, which a database whose certificate is issued by a private CA (most managed databases) needs. With `sslRootCert` set, `require` verifies the CA too (libpq semantics) | | externalDatabase.username | string | `"aisix"` | Database role the control plane connects as. It has to be able to run the schema migrations cp-api applies at startup | | global.imagePullSecrets | list | `[]` | Image pull secrets applied to every pod created by this chart | | global.storageClass | string | `""` | StorageClass for the built-in PostgreSQL volume. Empty uses the cluster's default StorageClass | @@ -476,9 +503,9 @@ plane and hands out the gateway image from the same release as the chart. | postgresql.image.tag | string | `"15.4.0-debian-11-r45"` | PostgreSQL image tag | | postgresql.primary.persistence.size | string | `"8Gi"` | Size of the PostgreSQL data volume. It holds every configured resource plus the request telemetry the console reports on | | postgresql.primary.service.ports.postgresql | int | `5432` | PostgreSQL Service port | -| secrets.betterAuthSecret | string | `"CHANGE_ME_GENERATE_WITH_openssl_rand_-base64_48"` | REQUIRED. HMAC signing secret for Better Auth sessions. Generate with `openssl rand -base64 48`. Changing it signs every signed-in user out | | secrets.masterKey | string | `"CHANGE_ME_GENERATE_WITH_openssl_rand_-base64_32"` | REQUIRED. Base64-encoded 32-byte AES-256 key that envelope-encrypts the stored upstream provider credentials. Generate with `openssl rand -base64 32`. It is the only thing that can decrypt what was stored under it, so a lost key means re-entering every provider key | | secrets.masterKeyID | string | `"env:default"` | Identifier recorded alongside every value encrypted with the current master key, so a future key rotation can tell the generations apart | +| secrets.retiredMasterKeys | object | `{}` | Decrypt-only master keys during a master-key rotation, as `: ` — the id the key was recorded under (lowercase letters, digits, ":" and "-") and the key itself. Rotate by moving the current masterKey / masterKeyID here and setting new ones; remove an entry once nothing is encrypted under it any more | | serviceAccount.annotations | object | `{}` | Annotations for the ServiceAccount, e.g. a cloud IAM role binding | | serviceAccount.create | bool | `true` | Create a ServiceAccount for the control-plane pods | | serviceAccount.name | string | `""` | Name of the ServiceAccount. Empty derives one from the release name; set it to use a ServiceAccount you manage yourself (create: false) | @@ -486,26 +513,8 @@ plane and hands out the gateway image from the same release as the chart. | twoDSN.existingSecret | string | `""` | Optionally source the serving password from an existing Secret instead of servingPassword above. Key defaults to "serving-password". | | twoDSN.existingSecretKey | string | `"serving-password"` | Key inside twoDSN.existingSecret holding the serving password | | twoDSN.servingPassword | string | `""` | Password cp-api assigns to the cp_api_app serving role and uses in the serving DSN. Required when enabled unless existingSecret is set. Use a URL-safe value, from `openssl rand -hex 24`: it is embedded in a `postgres://` DSN, so `+`, `/` and `=` corrupt URL parsing | -| ui.affinity | object | `{}` | Affinity rules for the dashboard pod | -| ui.defaultLocale | string | `"en"` | Fixed dashboard UI language for this deployment. Supported: "en", "zh". There is no in-UI language switcher; the whole console renders in this locale (default English). Read server-side at request time. | -| ui.extraEnvVars | list | `[]` | Extra environment for the dashboard | -| ui.extraVolumeMounts | list | `[]` | Additional mounts for the dashboard container. Use readOnly for CA certificates. | -| ui.extraVolumes | list | `[]` | Additional volumes for the dashboard Pod (for example, a private database CA). | -| ui.image.pullPolicy | string | `"IfNotPresent"` | Image pull policy | -| ui.image.repository | string | `"docker.io/api7/aisix-cp-ui"` | Dashboard image repository | -| ui.image.tag | string | `""` | Image tag | -| ui.nodeSelector | object | `{}` | Node selector for the dashboard pod | -| ui.podSecurityContext.runAsNonRoot | bool | `true` | Refuse to run the dashboard pod as root | -| ui.podSecurityContext.seccompProfile.type | string | `"RuntimeDefault"` | Seccomp profile for the dashboard pod | -| ui.replicaCount | int | `1` | Number of dashboard replicas | -| ui.resources.limits.cpu | string | `"500m"` | Dashboard CPU limit | -| ui.resources.limits.memory | string | `"256Mi"` | Dashboard memory limit | -| ui.resources.requests.cpu | string | `"50m"` | Dashboard CPU request | -| ui.resources.requests.memory | string | `"64Mi"` | Dashboard memory request | -| ui.securityContext.allowPrivilegeEscalation | bool | `false` | Forbid privilege escalation in the dashboard container | -| ui.securityContext.capabilities.drop | list | `["ALL"]` | Linux capabilities dropped from the dashboard container | -| ui.securityContext.readOnlyRootFilesystem | bool | `true` | Mount the dashboard root filesystem read-only | -| ui.service.nodePort | string | `""` | Optional fixed NodePort. Used only when type is NodePort; leave empty to let Kubernetes allocate a port dynamically. This plain-HTTP endpoint is not a standalone Dashboard entry point: use it only behind a same-origin reverse proxy that sends Dashboard pages here and /api/* to cp-api. The proxy should terminate TLS unless it is on a trusted network. | -| ui.service.port | int | `3000` | Dashboard Service port, which cp-api proxies browser traffic to | -| ui.service.type | string | `"ClusterIP"` | Dashboard Service type | -| ui.tolerations | list | `[]` | Tolerations for the dashboard pod | +| ui.defaultLocale | string | `"en"` | Fixed language for this deployment. Supported: "en", "zh". There is no in-UI language switcher; the whole console renders in this locale (default English), and the emails cp-api sends are written in it too. | +| ui.extraEnvVars | list | `[]` | Kept for installs upgraded from chart 1.5.0, which ran a separate dashboard. Of what that release was told to set here, AISIX_TRUSTED_ORIGINS and AISIX_AUTH_BEARER_SESSIONS are carried to cp-api (unless api.extraEnvVars sets the same name); nothing else is read. Set api.extraEnvVars instead. | +| ui.service.nodePort | string | `""` | Optional fixed NodePort. Used only when type is NodePort; leave empty to let Kubernetes allocate a port dynamically. This plain-HTTP endpoint reaches cp-api exactly as the api Service does; terminate TLS in front of it unless it is on a trusted network. | +| ui.service.port | int | `3000` | Port of the dashboard Service kept from chart 1.5.0; it forwards to cp-api | +| ui.service.type | string | `"ClusterIP"` | Type of the dashboard Service kept from chart 1.5.0 | diff --git a/charts/aisix-cp/README.md.gotmpl b/charts/aisix-cp/README.md.gotmpl index f06644b..fdbac1c 100644 --- a/charts/aisix-cp/README.md.gotmpl +++ b/charts/aisix-cp/README.md.gotmpl @@ -9,13 +9,13 @@ budgets, caching, guardrails, and observability behind an OpenAI-compatible API. This chart installs the **control plane** — the console and management services that configure the gateways and collect what they report. -It installs three services and, by default, a PostgreSQL database: +It installs two services and, by default, a PostgreSQL database: -* `cp-api` — the Admin API, and the externally reachable entry point. It also - reverse-proxies the dashboard, so the console and the API share one origin. +* `cp-api` — the Admin API and the console, and the externally reachable entry + point. It serves the dashboard itself, so the console and the API share one + origin. * `dp-manager` — issues gateway certificates over mutual TLS and delivers configuration to the gateways. -* `dashboard` — the Next.js console, reached through `cp-api`. * PostgreSQL — the shared datastore. The chart deploys a bundled single instance by default, or connects to an [external database](#external-postgresql). @@ -36,13 +36,12 @@ to gateway hosts. * OpenSSL, to generate the secrets below * Secrets, which have no defaults you can deploy with. **The chart fails the render** while one is missing or still holds its placeholder, so an install - that supplies none of them stops before anything reaches the cluster. Two are + that supplies none of them stops before anything reaches the cluster. One is needed in every mode: | Value | Generate with | | --- | --- | | `secrets.masterKey` | `openssl rand -base64 32` | - | `secrets.betterAuthSecret` | `openssl rand -base64 48` | The database credentials depend on which database you use. With the bundled PostgreSQL — `postgresql.builtin: true`, the default — set both of these, @@ -68,8 +67,8 @@ to gateway hosts. private key of the certificate authority that issued your gateway certificates. Neither is recoverable from a database backup without it, so an upgrade or a reinstall that supplies a *new* key leaves the existing rows -unreadable. Store it, its `secrets.masterKeyID`, and `secrets.betterAuthSecret` -the way you store a database backup — see +unreadable. Store it and its `secrets.masterKeyID` the way you store a database +backup — see [Backup and Recovery](https://docs.api7.ai/ai-gateway/on-premises/backup-and-recovery). ## Install @@ -81,7 +80,6 @@ upgrade with, and it is the only copy of the master key. cat > cp-values.yaml <`, so a default install runs the control +An empty `api.image.tag` or `dpm.image.tag` resolves to the chart's +`appVersion`, and an empty `api.dpImage` makes `cp-api` hand out +`docker.io/api7/aisix:`, so a default install runs the control plane and hands out the gateway image from the same release as the chart. {{ template "chart.valuesSection" . }} diff --git a/charts/aisix-cp/templates/NOTES.txt b/charts/aisix-cp/templates/NOTES.txt index ff5bfb9..f642299 100644 --- a/charts/aisix-cp/templates/NOTES.txt +++ b/charts/aisix-cp/templates/NOTES.txt @@ -1,12 +1,11 @@ -AISIX control plane has been deployed. +{{ if eq (.Values.api.deploymentMode | default "on-premises") "saas" }}AISIX Cloud{{ else }}AISIX Enterprise{{ end }} control plane has been deployed. Components: - - cp-api: {{ include "aisix-cp.fullname" . }}-api:{{ .Values.api.service.port }} + - cp-api: {{ include "aisix-cp.fullname" . }}-api:{{ .Values.api.service.port }} (API and dashboard) - dp-manager: {{ include "aisix-cp.fullname" . }}-dpm:{{ .Values.dpm.service.port }} - - dashboard: {{ include "aisix-cp.fullname" . }}-ui:{{ .Values.ui.service.port }} - PostgreSQL: {{ include "aisix-cp.pgHost" . }}:{{ include "aisix-cp.pgPort" . }} -Access the dashboard via cp-api (reverse proxy): +cp-api serves the dashboard. Access it through the api Service: kubectl port-forward svc/{{ include "aisix-cp.fullname" . }}-api {{ .Values.api.service.port }}:{{ .Values.api.service.port }} -n {{ .Release.Namespace }} {{- $base := trimSuffix "/" (lower .Values.api.publicBaseURL) }} {{- if or (eq $base (printf "http://localhost:%v" .Values.api.service.port)) (eq $base (printf "http://127.0.0.1:%v" .Values.api.service.port)) }} @@ -14,22 +13,21 @@ Access the dashboard via cp-api (reverse proxy): Sign-in is checked against the trusted origins: the api.publicBaseURL origin, its loopback twin — localhost and 127.0.0.1 are interchangeable — and anything - in AISIX_TRUSTED_ORIGINS. Reaching the dashboard at some OTHER address (an + in api.trustedOrigins. Reaching the dashboard at some OTHER address (an Ingress, a LoadBalancer, a different port) is refused at sign-in with an "address not allowed" error. To allow one, either point api.publicBaseURL at it and - upgrade the release, or add it to AISIX_TRUSTED_ORIGINS via ui.extraEnvVars — + upgrade the release, or add it to api.trustedOrigins — that one is additive, so use it when the console is reached through more than one hostname. {{- else }} Then open {{ .Values.api.publicBaseURL }} Sign-in is checked against the trusted origins: the api.publicBaseURL origin, - its loopback twin, and anything in AISIX_TRUSTED_ORIGINS. The port-forward + its loopback twin, and anything in api.trustedOrigins. The port-forward above publishes the dashboard on http://localhost:{{ .Values.api.service.port }}, which is not that origin — so unless you have already listed it in - AISIX_TRUSTED_ORIGINS, the page loads and sign-in is refused. To allow it, - add http://localhost:{{ .Values.api.service.port }} to AISIX_TRUSTED_ORIGINS - via ui.extraEnvVars. + api.trustedOrigins, the page loads and sign-in is refused. To allow it, + add http://localhost:{{ .Values.api.service.port }} to api.trustedOrigins. {{- if or (hasPrefix "http://localhost:" $base) (hasPrefix "http://127.0.0.1:" $base) }} Or forward the port api.publicBaseURL names instead — local port first, Service port second: @@ -38,6 +36,27 @@ Access the dashboard via cp-api (reverse proxy): {{- end }} {{- end }} +{{- $bootstrap := .Values.api.bootstrapAdmin | default dict }} +{{- if and (ne (.Values.api.deploymentMode | default "on-premises") "saas") (or $bootstrap.email .Release.IsInstall) }} + +First administrator: +{{- if $bootstrap.email }} + On a database with no users yet, cp-api creates the organization Default + with {{ $bootstrap.email }} as its owner. The console asks for a new + password at the first sign-in, before anything else. +{{- if $bootstrap.existingSecret }} + The initial password is in the Secret {{ $bootstrap.existingSecret }}, key {{ $bootstrap.existingSecretKey | default "bootstrap-admin-password" }}. +{{- else }} + Read the initial password with: + kubectl get secret {{ include "aisix-cp.secretName" . }} -n {{ .Release.Namespace }} -o jsonpath='{.data.bootstrap-admin-password}' | base64 -d; echo +{{- end }} +{{- else }} + Nobody can sign in yet: accounts are created by invitation, and + api.bootstrapAdmin.email is empty. Set it and upgrade the release to + create the first administrator. +{{- end }} +{{- end }} + Connect a data-plane (managed mode): {{- if eq .Values.dpm.service.type "NodePort" }} DPM is exposed via NodePort. diff --git a/charts/aisix-cp/templates/_cors.tpl b/charts/aisix-cp/templates/_cors.tpl new file mode 100644 index 0000000..c12b92d --- /dev/null +++ b/charts/aisix-cp/templates/_cors.tpl @@ -0,0 +1,217 @@ +{{/* +Refuse api.corsAllowedOrigins entries cp-api would refuse at startup. +Context: the list itself. Emits nothing. +*/}} +{{- define "aisix-cp.validateCorsOrigins" -}} +{{- /* 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 }} +{{- /* Every leading separator goes, not one of each: cp-api + trims the whole run (`strings.TrimLeft(suffix, "-.")`) + before counting labels, so `https://*..a.b` counted three + labels here and two there — rendered, then refused at + startup. */ -}} +{{- $body := regexReplaceAll "^[-.]+" $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 + exactly one: the URL standard's serializer. This block + reproduces it, so the render refuses precisely the entries + cp-api refuses at startup. + + A zero hextet is NOT simply forbidden. The serializer + compresses the first-longest run of at least TWO zero + hextets and writes a lone one out, so `1:0:2:3:4:5:6:7` and + `1::1:0` are the canonical spellings of their addresses — + and the rule that refused every written-out `0` rejected + 1409 addresses a browser sends exactly as typed. */ -}} +{{- $inner := trimSuffix "]" (trimPrefix "[" $hostOnly) }} +{{- if regexMatch "(^|:)0[0-9a-f]" $inner }} +{{- fail (printf "api.corsAllowedOrigins entry %q: an IPv6 origin must be written the way a browser sends it — drop the leading zeros from each hextet." .) }} +{{- end }} +{{- /* 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." .) }} +{{- 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. */ -}} +{{- if contains "::" $inner }} +{{- 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." .) }} +{{- end }} +{{- /* The address is well formed here, so expand it to its eight + hextets and re-serialize it the way the URL standard does: + the first-longest run of two or more zero hextets becomes + `::`, everything else is written out. Any spelling other + than that one is an entry no Origin header can equal. + + An IPv4-mapped address keeps its hex spelling through this, + which is what a browser sends: `[::ffff:c0a8:1]`, never + `[::ffff:192.168.0.1]`. */ -}} +{{- $halves := splitList "::" $inner }} +{{- $lead := without (splitList ":" (index $halves 0)) "" }} +{{- $tail := list }} +{{- if contains "::" $inner }} +{{- $tail = without (splitList ":" (index $halves 1)) "" }} +{{- end }} +{{- $hextets := $lead }} +{{- range until (int (sub 8 (add (len $lead) (len $tail)))) }} +{{- $hextets = append $hextets "0" }} +{{- end }} +{{- range $tail }} +{{- $hextets = append $hextets . }} +{{- end }} +{{- /* First-longest run of two or more zero hextets. */ -}} +{{- $compress := -1 }} +{{- $best := 1 }} +{{- $run := 0 }} +{{- $runStart := 0 }} +{{- range $i, $h := $hextets }} +{{- if eq $h "0" }} +{{- if eq $run 0 }}{{- $runStart = $i }}{{- end }} +{{- $run = add1 $run }} +{{- if gt $run $best }}{{- $best = $run }}{{- $compress = $runStart }}{{- end }} +{{- else }} +{{- $run = 0 }} +{{- end }} +{{- end }} +{{- $canonical := "" }} +{{- $skipZeros := false }} +{{- range $i, $h := $hextets }} +{{- if and $skipZeros (eq $h "0") }} +{{- else }} +{{- $skipZeros = false }} +{{- if eq $i $compress }} +{{- if eq $i 0 }} +{{- $canonical = printf "%s::" $canonical }} +{{- else }} +{{- $canonical = printf "%s:" $canonical }} +{{- end }} +{{- $skipZeros = true }} +{{- else }} +{{- $canonical = printf "%s%s" $canonical $h }} +{{- if ne $i 7 }}{{- $canonical = printf "%s:" $canonical }}{{- end }} +{{- end }} +{{- end }} +{{- end }} +{{- if ne $canonical $inner }} +{{- fail (printf "api.corsAllowedOrigins entry %q: an IPv6 origin must be written the way a browser sends it, which for this address is `[%s]`." $entry $canonical) }} +{{- 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 }} +{{- end }} diff --git a/charts/aisix-cp/templates/_env.tpl b/charts/aisix-cp/templates/_env.tpl new file mode 100644 index 0000000..2a678f1 --- /dev/null +++ b/charts/aisix-cp/templates/_env.tpl @@ -0,0 +1,72 @@ +{{/* +A container's env list. Arguments: + chart the entries the chart renders now (a rendered YAML list) + legacy the entries chart 1.5.0 rendered ("aisix-cp.legacyEnv.") + extra the component's extraEnvVars, emitted last, verbatim and in order + +When extraEnvVars sets a name the chart also renders, what happens to the +chart's entry depends on whether 1.5.0 rendered that name: + +- It did: the chart still emits an entry before the user's — its current one, + or, for a name that moved into the config file, the 1.5.0 entry. A 1.5.0 + install carries that name twice, and an upgrade that renders it fewer times + loses the user's override (see _legacy-env.tpl). +- It did not: the chart's entry is omitted. A duplicate env name breaks + `helm rollback` followed by an env-changing upgrade and is rejected by + Helm 4's server-side apply. Unless something between the two, or the + override itself, expands `$(NAME)`: the kubelet resolves a reference only against entries defined + earlier, so it has always resolved to the chart's value, and the entry + stays so that it still does. + +Either way the user's later entry is the one that takes effect, exactly as +before. +*/}} +{{- define "aisix-cp.env" -}} +{{- $chart := .chart | fromYamlArray }} +{{- $extra := .extra | default list }} +{{- $legacy := .legacy | fromYamlArray }} +{{- $legacyNames := dict }} +{{- range $legacy }} +{{- $_ := set $legacyNames .name true }} +{{- end }} +{{- $chartNames := dict }} +{{- range $chart }} +{{- $_ := set $chartNames .name true }} +{{- end }} +{{- $userNames := dict }} +{{- range $extra }} +{{- $_ := set $userNames .name true }} +{{- end }} +{{- $moved := list }} +{{- range $legacy }} +{{- if and (hasKey $userNames .name) (not (hasKey $chartNames .name)) }} +{{- $moved = append $moved . }} +{{- end }} +{{- end }} +{{- $all := concat $chart $moved $extra }} +{{- $out := list }} +{{- range $i, $entry := $chart }} +{{- $keep := true }} +{{- if and (hasKey $userNames $entry.name) (not (hasKey $legacyNames $entry.name)) }} +{{- $override := 0 }} +{{- range $j, $e := $all }} +{{- if and (eq $override 0) (gt $j $i) (eq $e.name $entry.name) }} +{{- $override = $j }} +{{- end }} +{{- end }} +{{- $keep = false }} +{{- $ref := printf "$(%s)" $entry.name }} +{{- range slice $all (add1 $i) (add1 $override) }} +{{- if contains $ref (toString (.value | default "")) }} +{{- $keep = true }} +{{- end }} +{{- end }} +{{- end }} +{{- if $keep }} +{{- $out = append $out $entry }} +{{- end }} +{{- end }} +{{- with concat $out $moved $extra }} +{{- toYaml . }} +{{- end }} +{{- end }} diff --git a/charts/aisix-cp/templates/_helpers.tpl b/charts/aisix-cp/templates/_helpers.tpl index da93b7f..a0309dd 100644 --- a/charts/aisix-cp/templates/_helpers.tpl +++ b/charts/aisix-cp/templates/_helpers.tpl @@ -111,20 +111,91 @@ PostgreSQL SSL mode. {{- end }} {{/* -Database URL constructed from postgresql config. -Uses $(PGPASSWORD) env var substitution for runtime secret injection. +Database URL WITHOUT its password, for the rendered config files. The +password never enters a ConfigMap: each binary reads it from its +*_DATABASE_PASSWORD env var (wired from the database Secret) and splices it +into this DSN at startup. */}} {{- define "aisix-cp.databaseURL" -}} -postgres://{{ include "aisix-cp.pgUser" . }}:$(PGPASSWORD)@{{ include "aisix-cp.pgHost" . }}:{{ include "aisix-cp.pgPort" . }}/{{ include "aisix-cp.pgDatabase" . }}?sslmode={{ include "aisix-cp.pgSSLMode" . }} +postgres://{{ include "aisix-cp.pgUser" . }}@{{ include "aisix-cp.pgHost" . }}:{{ include "aisix-cp.pgPort" . }}/{{ include "aisix-cp.pgDatabase" . }}?sslmode={{ include "aisix-cp.pgSSLMode" . }} {{- end }} {{/* Serving database URL for two-DSN mode: same host/port/db, but connecting -as the non-superuser cp_api_app role. Uses $(SERVING_PGPASSWORD) env-var -substitution for runtime secret injection. +as the non-superuser cp_api_app role. No password, as above. */}} {{- define "aisix-cp.servingDatabaseURL" -}} -postgres://cp_api_app:$(SERVING_PGPASSWORD)@{{ include "aisix-cp.pgHost" . }}:{{ include "aisix-cp.pgPort" . }}/{{ include "aisix-cp.pgDatabase" . }}?sslmode={{ include "aisix-cp.pgSSLMode" . }} +postgres://cp_api_app@{{ include "aisix-cp.pgHost" . }}:{{ include "aisix-cp.pgPort" . }}/{{ include "aisix-cp.pgDatabase" . }}?sslmode={{ include "aisix-cp.pgSSLMode" . }} +{{- end }} + +{{/* +Where each component's rendered config file is mounted. Not under +/etc/aisix, which in the cp-api image holds the baked pricing snapshot. +*/}} +{{- define "aisix-cp.configDir" -}} +/etc/aisix-cp +{{- end }} + +{{/* +Where the external database's CA bundle (externalDatabase.sslRootCert) is +mounted. +*/}} +{{- define "aisix-cp.databaseCADir" -}} +/etc/aisix-cp-database-ca +{{- end }} + +{{/* +The CA bundle's path inside the pod, or empty when none is configured. `dig` +because values from a chart that predates the key (`helm upgrade +--reuse-values`) lack it. +*/}} +{{- define "aisix-cp.databaseSSLRootCert" -}} +{{- if and (not .Values.postgresql.builtin) (dig "sslRootCert" "existingSecret" "" .Values.externalDatabase) -}} +{{ include "aisix-cp.databaseCADir" . }}/ca.crt +{{- end }} +{{- end }} + +{{/* +The CA bundle's volumeMount and volume, for the cp-api and dp-manager pods. +*/}} +{{- define "aisix-cp.databaseCAVolumeMount" -}} +{{- if include "aisix-cp.databaseSSLRootCert" . }} +- name: database-ca + mountPath: {{ include "aisix-cp.databaseCADir" . }} + readOnly: true +{{- end }} +{{- end }} + +{{- define "aisix-cp.databaseCAVolume" -}} +{{- if include "aisix-cp.databaseSSLRootCert" . }} +- name: database-ca + secret: + secretName: {{ .Values.externalDatabase.sslRootCert.existingSecret }} + items: + - key: {{ .Values.externalDatabase.sslRootCert.key | default "ca.crt" }} + path: ca.crt +{{- end }} +{{- end }} + +{{/* +Validated secrets.retiredMasterKeys as a list of dicts: +id (the MasterKeyID), envSuffix (the id as a RETIRED_* env-var suffix — +":" becomes "_", which the binaries translate back) and secretKey (the key +in the chart Secret). +*/}} +{{- define "aisix-cp.retiredMasterKeys" -}} +{{- $out := list }} +{{- range $id, $value := .Values.secrets.retiredMasterKeys }} +{{- if not (regexMatch "^[a-z0-9:-]+$" $id) }} +{{- fail (printf "secrets.retiredMasterKeys: %q is not a master key id — ids use lowercase letters, digits, \":\" and \"-\" (e.g. env:2026-q1)" $id) }} +{{- end }} +{{- if not $value }} +{{- fail (printf "secrets.retiredMasterKeys.%s is empty: set the base64 key it used, or remove the entry" $id) }} +{{- end }} +{{- $suffix := replace ":" "_" $id }} +{{- $out = append $out (dict "id" $id "envSuffix" $suffix "secretKey" (printf "retired-master-key-%s" $suffix)) }} +{{- end }} +{{- toJson $out }} {{- end }} {{/* @@ -230,3 +301,38 @@ Reuses the same PG image shipped by the chart so no extra pull is needed. sleep 2 done {{- end }} + +{{/* +Whether the chart's own Secret carries the first administrator's password +(key bootstrap-admin-password): the bootstrap is on, it is not read from +api.bootstrapAdmin.existingSecret, and either a password was given or the +chart generates one (on-premises only — a SaaS control plane ignores the +bootstrap). +*/}} +{{- define "aisix-cp.bootstrapAdminPasswordManaged" -}} +{{- $b := .Values.api.bootstrapAdmin | default dict -}} +{{- if and $b.email (not $b.existingSecret) (or $b.password (ne (.Values.api.deploymentMode | default "on-premises") "saas")) -}} +true +{{- end -}} +{{- end }} + +{{/* +The first administrator's password as the chart stores it: the one given, +else the one this release's Secret already holds (so helm upgrade keeps +it), else a new random one. `lookup` answers nothing under `helm template` +and GitOps renderers, which therefore generate a new one on every render — +those deployments set api.bootstrapAdmin.password or existingSecret. +*/}} +{{- define "aisix-cp.bootstrapAdminPassword" -}} +{{- $b := .Values.api.bootstrapAdmin | default dict -}} +{{- if $b.password -}} +{{- $b.password -}} +{{- else -}} +{{- $existing := lookup "v1" "Secret" .Release.Namespace (include "aisix-cp.secretName" .) -}} +{{- if and $existing $existing.data (hasKey $existing.data "bootstrap-admin-password") -}} +{{- index $existing.data "bootstrap-admin-password" | b64dec -}} +{{- else -}} +{{- randAlphaNum 24 -}} +{{- end -}} +{{- end -}} +{{- end }} diff --git a/charts/aisix-cp/templates/_legacy-env.tpl b/charts/aisix-cp/templates/_legacy-env.tpl new file mode 100644 index 0000000..06c4d8d --- /dev/null +++ b/charts/aisix-cp/templates/_legacy-env.tpl @@ -0,0 +1,99 @@ +{{/* +The env block each Deployment rendered in chart 1.5.0, under the same +conditions and with the same values — the last release that passed +application settings as env. Nothing renders these lists directly: the +"aisix-cp.env" helper reads their names, and emits one of these entries only +when the component's extraEnvVars sets that name and the current chart no +longer does. + +That is what keeps a 1.5.0 install's override across the upgrade. Its +Deployment carries the name twice (the chart's entry, then the user's), and +when the upgraded manifest carries it fewer times, Helm 3's three-way patch +deletes both — the override is lost until a second upgrade puts it back. + +Freeze these: they describe a released chart, not the current one. +AISIX_CLOUD_DP_IMAGE is rendered always, because 1.5.0 defaulted api.dpImage +to an image where this chart defaults it to empty; the default here is the +one 1.5.0 carried. At the release-time sync to the public chart it takes the +public 1.5.0 default, docker.io/api7/aisix:1.5.0. +*/}} +{{- define "aisix-cp.legacyEnv.api" -}} +- name: PGPASSWORD +{{- if .Values.twoDSN.enabled }} +- name: SERVING_PGPASSWORD +{{- 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.legacyDatabaseURL" . }} +- name: AISIX_CLOUD_DATABASE_URL + value: postgres://cp_api_app:$(SERVING_PGPASSWORD)@{{ include "aisix-cp.pgHost" . }}:{{ include "aisix-cp.pgPort" . }}/{{ include "aisix-cp.pgDatabase" . }}?sslmode={{ include "aisix-cp.pgSSLMode" . }} +{{- else }} +- name: AISIX_CLOUD_DATABASE_URL + value: {{ include "aisix-cp.legacyDatabaseURL" . }} +{{- end }} +- name: AISIX_CLOUD_PUBLIC_BASE_URL + value: {{ .Values.api.publicBaseURL | quote }} +{{- with .Values.api.corsAllowedOrigins }} +- name: AISIX_CLOUD_CORS_ALLOWED_ORIGINS + value: {{ join "," . | quote }} +{{- end }} +- name: AISIX_CLOUD_MASTER_KEY +- name: AISIX_CLOUD_MASTER_KEY_ID + value: {{ .Values.secrets.masterKeyID | quote }} +- name: AISIX_CLOUD_DASHBOARD_URL + value: "http://{{ include "aisix-cp.fullname" . }}-ui:{{ .Values.ui.service.port }}" +{{- if .Values.api.dpmgrBaseURL }} +- name: AISIX_CLOUD_DPMGR_BASE_URL + value: {{ .Values.api.dpmgrBaseURL | quote }} +{{- end }} +- name: AISIX_CLOUD_OAUTH_ENABLED + value: {{ .Values.api.oauthEnabled | quote }} +{{- if .Values.api.playgroundAllowPrivateIPs }} +- name: AISIX_PLAYGROUND_ALLOW_PRIVATE_IPS + value: "1" +{{- end }} +{{- if .Values.api.notifyAllowPrivateURLs }} +- name: AISIX_CLOUD_NOTIFY_ALLOW_PRIVATE_URLS + value: "true" +{{- end }} +- name: AISIX_CLOUD_DP_IMAGE + value: {{ .Values.api.dpImage | default "docker.io/api7/aisix:1.5.0" | quote }} +{{- end }} + +{{- define "aisix-cp.legacyEnv.dpm" -}} +- name: PGPASSWORD +- name: AISIX_DPMGR_LISTEN + value: ":7944" +- name: AISIX_DPMGR_HEALTH_LISTEN + value: {{ .Values.dpm.service.healthListen | quote }} +{{- if .Values.api.dpmgrBaseURL }} +- name: AISIX_DPMGR_BASE_URL + value: {{ .Values.api.dpmgrBaseURL | quote }} +{{- end }} +- name: AISIX_DPMGR_DATABASE_URL + value: {{ include "aisix-cp.legacyDatabaseURL" . }} +- name: AISIX_DPMGR_MASTER_KEY +- name: AISIX_DPMGR_MASTER_KEY_ID + value: {{ .Values.secrets.masterKeyID | quote }} +{{- end }} + +{{- define "aisix-cp.legacyEnv.ui" -}} +- name: PGPASSWORD +- name: BETTER_AUTH_URL + value: {{ .Values.api.publicBaseURL | quote }} +- name: BETTER_AUTH_SECRET +- name: AISIX_CLOUD_DATABASE_URL + value: {{ include "aisix-cp.legacyDatabaseURL" . }} +- name: AISIX_DASHBOARD_LOCALE + value: {{ .Values.ui.defaultLocale | quote }} +{{- end }} + +{{- define "aisix-cp.legacyDatabaseURL" -}} +postgres://{{ include "aisix-cp.pgUser" . }}:$(PGPASSWORD)@{{ include "aisix-cp.pgHost" . }}:{{ include "aisix-cp.pgPort" . }}/{{ include "aisix-cp.pgDatabase" . }}?sslmode={{ include "aisix-cp.pgSSLMode" . }} +{{- end }} diff --git a/charts/aisix-cp/templates/api-configmap.yaml b/charts/aisix-cp/templates/api-configmap.yaml new file mode 100644 index 0000000..ad0eca7 --- /dev/null +++ b/charts/aisix-cp/templates/api-configmap.yaml @@ -0,0 +1,119 @@ +{{- /* +cp-api's application configuration, read through AISIX_CLOUD_CONFIG_FILE. +Every key comes from an explicit value. Keys added after 1.5.0 carry a +`default` here because `helm upgrade --reuse-values` hands the templates the +PREVIOUS chart's values, which do not have them. Secrets are NOT here — they reach +the pod as secretKeyRef env (see api-deployment.yaml). An env var of the +same setting still overrides this file, which is how a value set through +api.extraEnvVars before this file existed keeps its effect. +*/ -}} +{{- include "aisix-cp.validateCorsOrigins" .Values.api.corsAllowedOrigins }} +{{- $mode := .Values.api.deploymentMode | default "on-premises" }} +{{- if not (has $mode (list "on-premises" "saas")) }} + {{- fail (printf "api.deploymentMode must be on-premises or saas, got %q" $mode) }} +{{- end }} +{{- if and (eq $mode "saas") (not .Values.twoDSN.enabled) }} + {{- fail "api.deploymentMode saas requires twoDSN.enabled: a SaaS control plane serves tenants through the non-privileged cp_api_app role" }} +{{- end }} +{{- /* A relay set through api.extraEnvVars (the pre-file way) still counts. */ -}} +{{- $smtpHostEnv := false }} +{{- range .Values.api.extraEnvVars }} + {{- if and (eq .name "AISIX_CLOUD_SMTP_HOST") (or .valueFrom (trim (toString (.value | default "")))) }} + {{- $smtpHostEnv = true }} + {{- end }} +{{- end }} +{{- if and (eq $mode "saas") (not (dig "smtp" "host" "" .Values.api | trim)) (not $smtpHostEnv) }} + {{- fail "api.deploymentMode saas requires api.smtp.host: a SaaS control plane emails every new account a verification link and needs an SMTP relay to send it" }} +{{- end }} +{{- $billing := .Values.api.billing | default dict }} +{{- $billingProvider := $billing.provider | default "" | trim }} +{{- if and (ne $mode "saas") (or $billing.usagePush (and $billingProvider (ne $billingProvider "mock"))) }} + {{- fail "api.billing.provider (other than mock) and api.billing.usagePush need api.deploymentMode saas: an on-premises control plane has no billing" }} +{{- end }} +{{- $bootstrap := .Values.api.bootstrapAdmin | default dict }} +{{- if and (not $bootstrap.email) (or $bootstrap.password $bootstrap.existingSecret) }} + {{- fail "api.bootstrapAdmin.password and api.bootstrapAdmin.existingSecret need api.bootstrapAdmin.email: without it no first administrator is created" }} +{{- end }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "aisix-cp.fullname" . }}-api-config + labels: + {{- include "aisix-cp.labels" . | nindent 4 }} + {{- include "aisix-cp.selectorLabels" (dict "root" . "component" "api") | nindent 4 }} +data: + config.yaml: | + deployment: + mode: {{ $mode | quote }} + bootstrap: + admin_email: {{ $bootstrap.email | default "" | quote }} + server: + listen: ":8080" + public_base_url: {{ .Values.api.publicBaseURL | quote }} + cors_allowed_origins: {{ .Values.api.corsAllowedOrigins | default list | toJson }} + metrics: + {{- /* Empty binds nothing: cp-api has no metrics listener unless this names one. */}} + listen: {{ ternary (printf ":%v" .Values.api.metrics.port) "" .Values.api.metrics.enabled | quote }} + database: + {{- if .Values.twoDSN.enabled }} + url: {{ include "aisix-cp.servingDatabaseURL" . | quote }} + admin_url: {{ include "aisix-cp.databaseURL" . | quote }} + {{- else }} + url: {{ include "aisix-cp.databaseURL" . | quote }} + {{- end }} + ssl_root_cert: {{ include "aisix-cp.databaseSSLRootCert" . | quote }} + allow_unsupported_upgrade: {{ .Values.api.allowUnsupportedUpgrade | default false }} + master_key: + id: {{ .Values.secrets.masterKeyID | quote }} + auth: + trusted_origins: {{ .Values.api.trustedOrigins | default list | toJson }} + bearer_sessions: {{ .Values.api.bearerSessions | default false }} + trusted_proxies: {{ .Values.api.trustedProxies | default list | toJson }} + smtp: + host: {{ dig "smtp" "host" "" .Values.api | quote }} + port: {{ dig "smtp" "port" 0 .Values.api | int }} + tls: {{ dig "smtp" "tls" "starttls" .Values.api | quote }} + from: {{ dig "smtp" "from" "" .Values.api | quote }} + username: {{ dig "smtp" "username" "" .Values.api | quote }} + invitation_limit_per_hour: {{ dig "smtp" "invitationLimitPerHour" 50 .Values.api | int }} + audit: + retention_days: {{ dig "audit" "retentionDays" 730 .Values.api | int }} + organization: + require_invite_code: {{ dig "organization" "requireInviteCode" true .Values.api }} + domain_verification: + dns_server: {{ dig "domainVerification" "dnsServer" "" .Values.api | quote }} + i18n: + {{- /* The console reads it from GET /api/config/public; emails use it too. */}} + default_locale: {{ .Values.ui.defaultLocale | default "en" | quote }} + data_plane: + manager_url: {{ .Values.api.dpmgrBaseURL | quote }} + {{- with .Values.api.dpImage }} + image: {{ . | quote }} + {{- end }} + pricesync: + url: {{ .Values.api.pricesyncURL | default "" | quote }} + snapshot_path: {{ .Values.api.pricesyncSnapshotPath | default "" | quote }} + {{- /* Unset values stay out of the file, which is what lets + deployment.mode decide them. */}} + {{- $outbound := dict + "notify_allow_private_urls" .Values.api.notifyAllowPrivateURLs + "playground_allow_private_ips" .Values.api.playgroundAllowPrivateIPs + "mcp_spec_allow_private_urls" .Values.api.mcpSpecAllowPrivateURLs + "guardrail_test_connection_allow_insecure" .Values.api.guardrailTestConnectionAllowInsecure }} + {{- $set := dict }} + {{- range $k, $v := $outbound }} + {{- if not (kindIs "invalid" $v) }} + {{- $_ := set $set $k $v }} + {{- end }} + {{- end }} + {{- if $set }} + outbound: {{ $set | toJson }} + {{- end }} + {{- $billingSet := dict }} + {{- with $billingProvider }}{{ $_ := set $billingSet "provider" . }}{{ end }} + {{- if $billing.usagePush }}{{ $_ := set $billingSet "usage_push" true }}{{ end }} + {{- with $billing.stripePortalConfigID }}{{ $_ := set $billingSet "stripe_portal_config_id" . }}{{ end }} + {{- with $billing.lagoEndpoint }}{{ $_ := set $billingSet "lago_endpoint" . }}{{ end }} + {{- if $billingSet }} + billing: {{ $billingSet | toJson }} + {{- end }} diff --git a/charts/aisix-cp/templates/api-deployment.yaml b/charts/aisix-cp/templates/api-deployment.yaml index 745eca8..41a8e31 100644 --- a/charts/aisix-cp/templates/api-deployment.yaml +++ b/charts/aisix-cp/templates/api-deployment.yaml @@ -15,6 +15,8 @@ spec: labels: {{- include "aisix-cp.labels" . | nindent 8 }} {{- include "aisix-cp.selectorLabels" (dict "root" . "component" "api") | nindent 8 }} + annotations: + checksum/config: {{ include (print $.Template.BasePath "/api-configmap.yaml") . | sha256sum }} spec: {{- include "aisix-cp.imagePullSecrets" . | nindent 6 }} serviceAccountName: {{ include "aisix-cp.serviceAccountName" . }} @@ -44,278 +46,7 @@ spec: protocol: TCP {{- end }} env: - - name: PGPASSWORD - valueFrom: - secretKeyRef: - name: {{ include "aisix-cp.pgSecretName" . }} - key: {{ include "aisix-cp.pgPasswordSecretKey" . }} - {{- if .Values.twoDSN.enabled }} - - name: SERVING_PGPASSWORD - valueFrom: - secretKeyRef: - name: {{ include "aisix-cp.servingPasswordSecretName" . }} - key: {{ include "aisix-cp.servingPasswordSecretKey" . }} - {{- 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" . }} - - name: AISIX_CLOUD_DATABASE_URL - value: {{ include "aisix-cp.servingDatabaseURL" . }} - {{- else }} - - name: AISIX_CLOUD_DATABASE_URL - value: {{ include "aisix-cp.databaseURL" . }} - {{- 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 }} - {{- /* Every leading separator goes, not one of each: cp-api - trims the whole run (`strings.TrimLeft(suffix, "-.")`) - before counting labels, so `https://*..a.b` counted three - labels here and two there — rendered, then refused at - startup. */ -}} - {{- $body := regexReplaceAll "^[-.]+" $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 - exactly one: the URL standard's serializer. This block - reproduces it, so the render refuses precisely the entries - cp-api refuses at startup. - - A zero hextet is NOT simply forbidden. The serializer - compresses the first-longest run of at least TWO zero - hextets and writes a lone one out, so `1:0:2:3:4:5:6:7` and - `1::1:0` are the canonical spellings of their addresses — - and the rule that refused every written-out `0` rejected - 1409 addresses a browser sends exactly as typed. */ -}} - {{- $inner := trimSuffix "]" (trimPrefix "[" $hostOnly) }} - {{- if regexMatch "(^|:)0[0-9a-f]" $inner }} - {{- fail (printf "api.corsAllowedOrigins entry %q: an IPv6 origin must be written the way a browser sends it — drop the leading zeros from each hextet." .) }} - {{- end }} - {{- /* 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." .) }} - {{- 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. */ -}} - {{- if contains "::" $inner }} - {{- 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." .) }} - {{- end }} - {{- /* The address is well formed here, so expand it to its eight - hextets and re-serialize it the way the URL standard does: - the first-longest run of two or more zero hextets becomes - `::`, everything else is written out. Any spelling other - than that one is an entry no Origin header can equal. - - An IPv4-mapped address keeps its hex spelling through this, - which is what a browser sends: `[::ffff:c0a8:1]`, never - `[::ffff:192.168.0.1]`. */ -}} - {{- $halves := splitList "::" $inner }} - {{- $lead := without (splitList ":" (index $halves 0)) "" }} - {{- $tail := list }} - {{- if contains "::" $inner }} - {{- $tail = without (splitList ":" (index $halves 1)) "" }} - {{- end }} - {{- $hextets := $lead }} - {{- range until (int (sub 8 (add (len $lead) (len $tail)))) }} - {{- $hextets = append $hextets "0" }} - {{- end }} - {{- range $tail }} - {{- $hextets = append $hextets . }} - {{- end }} - {{- /* First-longest run of two or more zero hextets. */ -}} - {{- $compress := -1 }} - {{- $best := 1 }} - {{- $run := 0 }} - {{- $runStart := 0 }} - {{- range $i, $h := $hextets }} - {{- if eq $h "0" }} - {{- if eq $run 0 }}{{- $runStart = $i }}{{- end }} - {{- $run = add1 $run }} - {{- if gt $run $best }}{{- $best = $run }}{{- $compress = $runStart }}{{- end }} - {{- else }} - {{- $run = 0 }} - {{- end }} - {{- end }} - {{- $canonical := "" }} - {{- $skipZeros := false }} - {{- range $i, $h := $hextets }} - {{- if and $skipZeros (eq $h "0") }} - {{- else }} - {{- $skipZeros = false }} - {{- if eq $i $compress }} - {{- if eq $i 0 }} - {{- $canonical = printf "%s::" $canonical }} - {{- else }} - {{- $canonical = printf "%s:" $canonical }} - {{- end }} - {{- $skipZeros = true }} - {{- else }} - {{- $canonical = printf "%s%s" $canonical $h }} - {{- if ne $i 7 }}{{- $canonical = printf "%s:" $canonical }}{{- end }} - {{- end }} - {{- end }} - {{- end }} - {{- if ne $canonical $inner }} - {{- fail (printf "api.corsAllowedOrigins entry %q: an IPv6 origin must be written the way a browser sends it, which for this address is `[%s]`." $entry $canonical) }} - {{- 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: - name: {{ include "aisix-cp.secretName" . }} - key: master-key - - name: AISIX_CLOUD_MASTER_KEY_ID - value: {{ .Values.secrets.masterKeyID | quote }} - - name: AISIX_CLOUD_DASHBOARD_URL - value: "http://{{ include "aisix-cp.fullname" . }}-ui:{{ .Values.ui.service.port }}" - {{- if .Values.api.dpmgrBaseURL }} - - name: AISIX_CLOUD_DPMGR_BASE_URL - value: {{ .Values.api.dpmgrBaseURL | quote }} - {{- end }} - - name: AISIX_CLOUD_OAUTH_ENABLED - value: {{ .Values.api.oauthEnabled | quote }} - {{- if .Values.api.playgroundAllowPrivateIPs }} - - name: AISIX_PLAYGROUND_ALLOW_PRIVATE_IPS - value: "1" - {{- end }} - {{- if .Values.api.notifyAllowPrivateURLs }} - - name: AISIX_CLOUD_NOTIFY_ALLOW_PRIVATE_URLS - value: "true" - {{- end }} - - name: AISIX_CLOUD_DP_IMAGE - value: {{ .Values.api.dpImage | default (printf "docker.io/api7/aisix:%s" .Chart.AppVersion) | quote }} - {{- with .Values.api.extraEnvVars }} - {{- toYaml . | nindent 12 }} - {{- end }} + {{- include "aisix-cp.env" (dict "chart" (include "aisix-cp.apiEnv" .) "legacy" (include "aisix-cp.legacyEnv.api" .) "extra" (concat (include "aisix-cp.apiEnvFromUI" . | fromYamlArray) (.Values.api.extraEnvVars | default list))) | nindent 12 }} # cp-api runs the schema migration (db.OpenAndMigrate) before # it binds :8080, so /healthz does not answer for the whole # migration. On an upgrade against a populated database that @@ -357,6 +88,20 @@ spec: resources: {{- toYaml . | nindent 12 }} {{- end }} + volumeMounts: + - name: config + mountPath: {{ include "aisix-cp.configDir" . }} + readOnly: true + {{- with include "aisix-cp.databaseCAVolumeMount" . | trim }} + {{- . | nindent 12 }} + {{- end }} + volumes: + - name: config + configMap: + name: {{ include "aisix-cp.fullname" . }}-api-config + {{- with include "aisix-cp.databaseCAVolume" . | trim }} + {{- . | nindent 8 }} + {{- end }} {{- with .Values.api.nodeSelector }} nodeSelector: {{- toYaml . | nindent 8 }} @@ -369,3 +114,119 @@ spec: affinity: {{- toYaml . | nindent 8 }} {{- end }} + +{{- define "aisix-cp.apiEnv" -}} +- name: AISIX_CLOUD_CONFIG_FILE + value: {{ include "aisix-cp.configDir" . }}/config.yaml +{{- /* Secrets: never in the ConfigMap. The database passwords are + spliced by cp-api into the password-less DSNs of its config + file. */}} +- name: AISIX_CLOUD_DATABASE_PASSWORD + valueFrom: + secretKeyRef: + {{- if .Values.twoDSN.enabled }} + name: {{ include "aisix-cp.servingPasswordSecretName" . }} + key: {{ include "aisix-cp.servingPasswordSecretKey" . }} + {{- else }} + name: {{ include "aisix-cp.pgSecretName" . }} + key: {{ include "aisix-cp.pgPasswordSecretKey" . }} + {{- end }} +{{- if .Values.twoDSN.enabled }} +- name: AISIX_CLOUD_ADMIN_DATABASE_PASSWORD + valueFrom: + secretKeyRef: + name: {{ include "aisix-cp.pgSecretName" . }} + key: {{ include "aisix-cp.pgPasswordSecretKey" . }} +{{- end }} +- name: AISIX_CLOUD_MASTER_KEY + valueFrom: + secretKeyRef: + name: {{ include "aisix-cp.secretName" . }} + key: master-key +{{- $smtp := .Values.api.smtp | default dict }} +{{- if or $smtp.existingSecret $smtp.password }} +- name: AISIX_CLOUD_SMTP_PASSWORD + valueFrom: + secretKeyRef: + {{- if $smtp.existingSecret }} + name: {{ $smtp.existingSecret }} + key: {{ $smtp.existingSecretKey | default "smtp-password" }} + {{- else }} + name: {{ include "aisix-cp.secretName" . }} + key: smtp-password + {{- end }} +{{- end }} +{{- $billing := .Values.api.billing | default dict }} +{{- $billingKeys := $billing.existingSecretKeys | default dict }} +{{- range list (list "AISIX_STRIPE_SECRET_KEY" "stripeSecretKey" "stripe-secret-key") (list "AISIX_STRIPE_WEBHOOK_SECRET" "stripeWebhookSecret" "stripe-webhook-secret") (list "AISIX_LAGO_API_KEY" "lagoAPIKey" "lago-api-key") }} +{{- $env := index . 0 }}{{ $value := index . 1 }}{{ $chartKey := index . 2 }} +{{- if $billing.existingSecret }} +{{- $key := hasKey $billingKeys $value | ternary (index $billingKeys $value) $chartKey }} +{{- if $key }} +- name: {{ $env }} + valueFrom: + secretKeyRef: + name: {{ $billing.existingSecret }} + key: {{ $key }} +{{- end }} +{{- else if index $billing $value }} +- name: {{ $env }} + valueFrom: + secretKeyRef: + name: {{ include "aisix-cp.secretName" $ }} + key: {{ $chartKey }} +{{- end }} +{{- end }} +{{- $bootstrap := .Values.api.bootstrapAdmin | default dict }} +{{- if or $bootstrap.existingSecret (include "aisix-cp.bootstrapAdminPasswordManaged" .) }} +- name: AISIX_CLOUD_BOOTSTRAP_ADMIN_PASSWORD + valueFrom: + secretKeyRef: + {{- if $bootstrap.existingSecret }} + name: {{ $bootstrap.existingSecret }} + key: {{ $bootstrap.existingSecretKey | default "bootstrap-admin-password" }} + {{- else }} + name: {{ include "aisix-cp.secretName" . }} + key: bootstrap-admin-password + {{- end }} +{{- end }} +{{- range (include "aisix-cp.retiredMasterKeys" . | fromJsonArray) }} +- name: AISIX_CLOUD_MASTER_KEY_RETIRED_{{ .envSuffix }} + valueFrom: + secretKeyRef: + name: {{ include "aisix-cp.secretName" $ }} + key: {{ .secretKey }} +{{- end }} +{{- /* Kept for extraEnvVars written against the pre-config-file + chart, which could reference $(PGPASSWORD) / + $(SERVING_PGPASSWORD) in a DSN of their own. */}} +- name: PGPASSWORD + valueFrom: + secretKeyRef: + name: {{ include "aisix-cp.pgSecretName" . }} + key: {{ include "aisix-cp.pgPasswordSecretKey" . }} +{{- if .Values.twoDSN.enabled }} +- name: SERVING_PGPASSWORD + valueFrom: + secretKeyRef: + name: {{ include "aisix-cp.servingPasswordSecretName" . }} + key: {{ include "aisix-cp.servingPasswordSecretKey" . }} +{{- end }} +{{- end }} + +{{- /* +COMPAT-SINCE: 1.5.0 #1756 — console sign-in moved from the dashboard to cp-api, and a 1.5.0 install was told (NOTES.txt) to put AISIX_TRUSTED_ORIGINS in ui.extraEnvVars (a preview upstream AISIX_AUTH_BEARER_SESSIONS); carry those to cp-api, ahead of api.extraEnvVars and only when that does not set the same name, so an upgraded install keeps its extra sign-in origins. Remove once no supported install can still carry 1.5.0 values. +*/}} +{{- define "aisix-cp.apiEnvFromUI" -}} +{{- $apiNames := list }} +{{- range .Values.api.extraEnvVars }} +{{- $apiNames = append $apiNames .name }} +{{- end }} +{{- $out := list }} +{{- range .Values.ui.extraEnvVars }} +{{- if and (has .name (list "AISIX_TRUSTED_ORIGINS" "AISIX_AUTH_BEARER_SESSIONS")) (not (has .name $apiNames)) }} +{{- $out = append $out . }} +{{- end }} +{{- end }} +{{- toYaml $out }} +{{- end }} diff --git a/charts/aisix-cp/templates/dpm-configmap.yaml b/charts/aisix-cp/templates/dpm-configmap.yaml new file mode 100644 index 0000000..040c66f --- /dev/null +++ b/charts/aisix-cp/templates/dpm-configmap.yaml @@ -0,0 +1,27 @@ +{{- /* +dp-manager's application configuration, read through +AISIX_DPMGR_CONFIG_FILE. Secrets are NOT here — see dpm-deployment.yaml. +*/ -}} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "aisix-cp.fullname" . }}-dpm-config + labels: + {{- include "aisix-cp.labels" . | nindent 4 }} + {{- include "aisix-cp.selectorLabels" (dict "root" . "component" "dpm") | nindent 4 }} +data: + config.yaml: | + server: + listen: ":7944" + {{- /* Omitted when empty, which leaves dp-manager's own :7946 + default bound, exactly as the empty env var this replaced did; + the probes switch to TCP on the mTLS port either way. */}} + {{- with .Values.dpm.service.healthListen }} + health_listen: {{ . | quote }} + {{- end }} + base_url: {{ .Values.api.dpmgrBaseURL | quote }} + database: + url: {{ include "aisix-cp.databaseURL" . | quote }} + ssl_root_cert: {{ include "aisix-cp.databaseSSLRootCert" . | quote }} + master_key: + id: {{ .Values.secrets.masterKeyID | quote }} diff --git a/charts/aisix-cp/templates/dpm-deployment.yaml b/charts/aisix-cp/templates/dpm-deployment.yaml index dba5999..e25b608 100644 --- a/charts/aisix-cp/templates/dpm-deployment.yaml +++ b/charts/aisix-cp/templates/dpm-deployment.yaml @@ -15,6 +15,8 @@ spec: labels: {{- include "aisix-cp.labels" . | nindent 8 }} {{- include "aisix-cp.selectorLabels" (dict "root" . "component" "dpm") | nindent 8 }} + annotations: + checksum/config: {{ include (print $.Template.BasePath "/dpm-configmap.yaml") . | sha256sum }} spec: {{- include "aisix-cp.imagePullSecrets" . | nindent 6 }} serviceAccountName: {{ include "aisix-cp.serviceAccountName" . }} @@ -44,34 +46,7 @@ spec: protocol: TCP {{- end }} env: - - name: PGPASSWORD - valueFrom: - secretKeyRef: - name: {{ include "aisix-cp.pgSecretName" . }} - key: {{ include "aisix-cp.pgPasswordSecretKey" . }} - - name: AISIX_DPMGR_LISTEN - value: ":7944" - - name: AISIX_DPMGR_HEALTH_LISTEN - value: {{ .Values.dpm.service.healthListen | quote }} - {{- if .Values.api.dpmgrBaseURL }} - # Same value cp-api bakes into the install snippet: dp-manager - # seeds its TLS server cert SANs from this host, so a DP that - # dials the CP by IP can complete the handshake (#1216). - - name: AISIX_DPMGR_BASE_URL - value: {{ .Values.api.dpmgrBaseURL | quote }} - {{- end }} - - name: AISIX_DPMGR_DATABASE_URL - value: {{ include "aisix-cp.databaseURL" . }} - - name: AISIX_DPMGR_MASTER_KEY - valueFrom: - secretKeyRef: - name: {{ include "aisix-cp.secretName" . }} - key: master-key - - name: AISIX_DPMGR_MASTER_KEY_ID - value: {{ .Values.secrets.masterKeyID | quote }} - {{- with .Values.dpm.extraEnvVars }} - {{- toYaml . | nindent 12 }} - {{- end }} + {{- include "aisix-cp.env" (dict "chart" (include "aisix-cp.dpmEnv" .) "legacy" (include "aisix-cp.legacyEnv.dpm" .) "extra" .Values.dpm.extraEnvVars) | nindent 12 }} # Probe the plain-HTTP /healthz (poller liveness), not the # mTLS port: a kubelet cannot present a client cert, and a # TCP check on 7944 stays green even if the outbox poller is @@ -131,6 +106,20 @@ spec: resources: {{- toYaml . | nindent 12 }} {{- end }} + volumeMounts: + - name: config + mountPath: {{ include "aisix-cp.configDir" . }} + readOnly: true + {{- with include "aisix-cp.databaseCAVolumeMount" . | trim }} + {{- . | nindent 12 }} + {{- end }} + volumes: + - name: config + configMap: + name: {{ include "aisix-cp.fullname" . }}-dpm-config + {{- with include "aisix-cp.databaseCAVolume" . | trim }} + {{- . | nindent 8 }} + {{- end }} {{- with .Values.dpm.nodeSelector }} nodeSelector: {{- toYaml . | nindent 8 }} @@ -143,3 +132,32 @@ spec: affinity: {{- toYaml . | nindent 8 }} {{- end }} + +{{- define "aisix-cp.dpmEnv" -}} +- name: AISIX_DPMGR_CONFIG_FILE + value: {{ include "aisix-cp.configDir" . }}/config.yaml +{{- /* Secrets: never in the ConfigMap. */}} +- name: AISIX_DPMGR_DATABASE_PASSWORD + valueFrom: + secretKeyRef: + name: {{ include "aisix-cp.pgSecretName" . }} + key: {{ include "aisix-cp.pgPasswordSecretKey" . }} +- name: AISIX_DPMGR_MASTER_KEY + valueFrom: + secretKeyRef: + name: {{ include "aisix-cp.secretName" . }} + key: master-key +{{- range (include "aisix-cp.retiredMasterKeys" . | fromJsonArray) }} +- name: AISIX_DPMGR_MASTER_KEY_RETIRED_{{ .envSuffix }} + valueFrom: + secretKeyRef: + name: {{ include "aisix-cp.secretName" $ }} + key: {{ .secretKey }} +{{- end }} +{{- /* Kept for extraEnvVars that reference $(PGPASSWORD). */}} +- name: PGPASSWORD + valueFrom: + secretKeyRef: + name: {{ include "aisix-cp.pgSecretName" . }} + key: {{ include "aisix-cp.pgPasswordSecretKey" . }} +{{- end }} diff --git a/charts/aisix-cp/templates/secret.yaml b/charts/aisix-cp/templates/secret.yaml index 8532da6..29ca1ca 100644 --- a/charts/aisix-cp/templates/secret.yaml +++ b/charts/aisix-cp/templates/secret.yaml @@ -2,13 +2,13 @@ Secrets rendered from explicit values. No auto-generation — values must be set explicitly to guarantee stability across re-deploys where helm lookup is unavailable during server-side rendering (ArgoCD, Flux, etc.). +The one exception is the first administrator's initial password, which is +generated when none is given and kept across upgrades with lookup; it is +used once, and must be changed at the first sign-in. */}} {{- if or (eq .Values.secrets.masterKey "") (hasPrefix "CHANGE_ME" .Values.secrets.masterKey) }} {{- fail "secrets.masterKey must be set to a real base64-encoded 32-byte key (generate with: openssl rand -base64 32)" }} {{- end }} -{{- if or (eq .Values.secrets.betterAuthSecret "") (hasPrefix "CHANGE_ME" .Values.secrets.betterAuthSecret) }} - {{- fail "secrets.betterAuthSecret must be set to a real secret (generate with: openssl rand -base64 48)" }} -{{- end }} {{/* Hold builtin PostgreSQL credentials to the same standard as the application secrets above: reject the documented placeholder so an @@ -44,7 +44,24 @@ metadata: type: Opaque data: master-key: {{ .Values.secrets.masterKey | b64enc }} - better-auth-secret: {{ .Values.secrets.betterAuthSecret | b64enc }} {{- if and .Values.twoDSN.enabled (not .Values.twoDSN.existingSecret) }} serving-password: {{ .Values.twoDSN.servingPassword | b64enc }} {{- end }} + {{- range (include "aisix-cp.retiredMasterKeys" . | fromJsonArray) }} + {{ .secretKey }}: {{ index $.Values.secrets.retiredMasterKeys .id | b64enc }} + {{- end }} + {{- $smtp := .Values.api.smtp | default dict }} + {{- if and $smtp.password (not $smtp.existingSecret) }} + smtp-password: {{ $smtp.password | b64enc }} + {{- end }} + {{- $billing := .Values.api.billing | default dict }} + {{- if not $billing.existingSecret }} + {{- range $value, $key := dict "stripeSecretKey" "stripe-secret-key" "stripeWebhookSecret" "stripe-webhook-secret" "lagoAPIKey" "lago-api-key" }} + {{- with index $billing $value }} + {{ $key }}: {{ . | b64enc }} + {{- end }} + {{- end }} + {{- end }} + {{- if include "aisix-cp.bootstrapAdminPasswordManaged" . }} + bootstrap-admin-password: {{ include "aisix-cp.bootstrapAdminPassword" . | b64enc }} + {{- end }} diff --git a/charts/aisix-cp/templates/ui-deployment.yaml b/charts/aisix-cp/templates/ui-deployment.yaml deleted file mode 100644 index 904392a..0000000 --- a/charts/aisix-cp/templates/ui-deployment.yaml +++ /dev/null @@ -1,101 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: {{ include "aisix-cp.fullname" . }}-ui - labels: - {{- include "aisix-cp.labels" . | nindent 4 }} - {{- include "aisix-cp.selectorLabels" (dict "root" . "component" "ui") | nindent 4 }} -spec: - replicas: {{ .Values.ui.replicaCount }} - selector: - matchLabels: - {{- include "aisix-cp.selectorLabels" (dict "root" . "component" "ui") | nindent 6 }} - template: - metadata: - labels: - {{- include "aisix-cp.labels" . | nindent 8 }} - {{- include "aisix-cp.selectorLabels" (dict "root" . "component" "ui") | nindent 8 }} - spec: - {{- include "aisix-cp.imagePullSecrets" . | nindent 6 }} - serviceAccountName: {{ include "aisix-cp.serviceAccountName" . }} - {{- with .Values.ui.podSecurityContext }} - securityContext: - {{- toYaml . | nindent 8 }} - {{- end }} - containers: - - name: ui - image: "{{ .Values.ui.image.repository }}:{{ .Values.ui.image.tag | default .Chart.AppVersion }}" - imagePullPolicy: {{ .Values.ui.image.pullPolicy }} - {{- with .Values.ui.securityContext }} - securityContext: - {{- toYaml . | nindent 12 }} - {{- end }} - ports: - - name: http - containerPort: 3000 - protocol: TCP - env: - - name: PGPASSWORD - valueFrom: - secretKeyRef: - name: {{ include "aisix-cp.pgSecretName" . }} - key: {{ include "aisix-cp.pgPasswordSecretKey" . }} - - name: BETTER_AUTH_URL - value: {{ .Values.api.publicBaseURL | quote }} - - name: BETTER_AUTH_SECRET - valueFrom: - secretKeyRef: - name: {{ include "aisix-cp.secretName" . }} - key: better-auth-secret - - name: AISIX_CLOUD_DATABASE_URL - value: {{ include "aisix-cp.databaseURL" . }} - - name: AISIX_DASHBOARD_LOCALE - value: {{ .Values.ui.defaultLocale | quote }} - {{- with .Values.ui.extraEnvVars }} - {{- toYaml . | nindent 12 }} - {{- end }} - livenessProbe: - httpGet: - path: / - port: http - initialDelaySeconds: 10 - periodSeconds: 10 - readinessProbe: - httpGet: - path: / - port: http - initialDelaySeconds: 5 - periodSeconds: 5 - {{- with .Values.ui.resources }} - resources: - {{- toYaml . | nindent 12 }} - {{- end }} - volumeMounts: - # Next.js standalone writes its image/fetch cache under - # .next/cache. With readOnlyRootFilesystem and no writable - # mount here, every cache write fails and a failed write can - # permanently wedge the /_next/image in-flight dedup entry - # for a variant (requests then hang until the LB times out). - - name: next-cache - mountPath: /app/.next/cache - {{- with .Values.ui.extraVolumeMounts }} - {{- toYaml . | nindent 12 }} - {{- end }} - volumes: - - name: next-cache - emptyDir: {} - {{- with .Values.ui.extraVolumes }} - {{- toYaml . | nindent 8 }} - {{- end }} - {{- with .Values.ui.nodeSelector }} - nodeSelector: - {{- toYaml . | nindent 8 }} - {{- end }} - {{- with .Values.ui.tolerations }} - tolerations: - {{- toYaml . | nindent 8 }} - {{- end }} - {{- with .Values.ui.affinity }} - affinity: - {{- toYaml . | nindent 8 }} - {{- end }} diff --git a/charts/aisix-cp/templates/ui-service.yaml b/charts/aisix-cp/templates/ui-service.yaml index f101c51..f421139 100644 --- a/charts/aisix-cp/templates/ui-service.yaml +++ b/charts/aisix-cp/templates/ui-service.yaml @@ -1,3 +1,6 @@ +{{- /* +COMPAT-SINCE: 1.5.0 #1758 — chart 1.5.0 ran the dashboard behind this Service, and offered it as a NodePort for a same-origin proxy that sends pages here and /api/* to cp-api. cp-api serves the dashboard itself now, so the Service keeps its name, type, port and nodePort but selects the api pods; such a proxy keeps working unchanged. Remove once no supported install can still carry 1.5.0 values. +*/ -}} apiVersion: v1 kind: Service metadata: @@ -16,4 +19,4 @@ spec: nodePort: {{ .Values.ui.service.nodePort }} {{- end }} selector: - {{- include "aisix-cp.selectorLabels" (dict "root" . "component" "ui") | nindent 4 }} + {{- include "aisix-cp.selectorLabels" (dict "root" . "component" "api") | nindent 4 }} diff --git a/charts/aisix-cp/values.yaml b/charts/aisix-cp/values.yaml index 05c2b15..0a94c1d 100644 --- a/charts/aisix-cp/values.yaml +++ b/charts/aisix-cp/values.yaml @@ -10,6 +10,11 @@ global: ## ## cp-api serves the Admin API and reverse-proxies the Dashboard, so it is the ## one entry point a browser talks to. +## +## Every application setting of cp-api, dp-manager and the dashboard is a +## value below. The chart renders them into one config file per component +## (a ConfigMap, mounted read-only; a change rolls the pods), and secrets +## reach the pods from Kubernetes Secrets, never through a ConfigMap. api: # -- Number of cp-api replicas replicaCount: 1 @@ -106,14 +111,20 @@ api: tolerations: [] # -- Affinity rules for the cp-api pod affinity: {} - # -- Extra environment for cp-api. This is also where - # AISIX_ALLOW_UNSUPPORTED_UPGRADE=1 goes when an upgrade has to proceed - # from a release older than this one supports upgrading from — cp-api - # refuses that upgrade at startup and names the version it needs. + # -- Extra environment for cp-api: system-level settings only (for example + # TZ). Every cp-api setting has its own value in this section; an env var + # set here for one of them still overrides the rendered config file, which + # is how values set this way before those keys existed keep working. + # Overriding a name the chart itself sets is only a compatibility path for + # existing deployments. For a name chart 1.5.0 already set, the chart keeps + # its own entry ahead of yours, so the name appears twice and Helm 4's + # server-side apply rejects the install. New installs set the value in this + # file instead. extraEnvVars: [] - # -- Publicly reachable cp-api URL. Better Auth validates the session JWT - # issuer against it, so a value that does not match the address the browser - # uses breaks sign-in. When api.service.type is NodePort, this must name the + # -- Publicly reachable cp-api URL. Console sign-in trusts this origin (and + # its localhost/127.0.0.1 twin), so a value that does not match the address + # the browser uses breaks sign-in unless that address is in + # api.trustedOrigins. 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 @@ -145,22 +156,212 @@ api: # presents — so an IP address works here, not only a DNS name. When # dpm.service.type is NodePort, use https://:. dpmgrBaseURL: "" - # -- Offer Google / GitHub sign-in on the console's login page. Leave off - # unless the deployment carries OAuth client credentials - oauthEnabled: false # -- Gateway image the console hands out in the install snippets it - # generates for a new data plane - dpImage: "" # empty -> docker.io/api7/aisix: - # -- Allow the dashboard playground to reach LLM endpoints on private / - # internal networks. cp-api blocks private IPs by default (SSRF guard); - # enable this only for On-Premises deployments whose models live on an - # internal network the cp-api pod can route to. - playgroundAllowPrivateIPs: false - # -- Allow notification channels (budget alert webhooks / Slack) to point - # at private / internal addresses. Blocked by default (SSRF guard); - # enable only for On-Premises deployments whose webhook receivers live - # on an intranet the cp-api pod can route to. - notifyAllowPrivateURLs: false + # generates for a new data plane. Empty uses cp-api's own default: the + # gateway of the same release, docker.io/api7/aisix:, or + # ghcr.io/api7/aisix:dev on a build that is not a release + dpImage: "" + # -- How this control plane is deployed: `on-premises` or `saas`. + # `on-premises` serves one company's organizations: nobody signs up on + # their own (accounts come from invitations), organizations cannot be + # created, billing is off, and cp-api's outbound calls may reach private + # addresses unless an outbound value below says otherwise. `saas` is the + # multi-tenant hosted service — open sign-up, organization creation and + # billing — and requires twoDSN.enabled + deploymentMode: "on-premises" + # -- (bool) Whether the dashboard playground may call LLM endpoints on + # private / internal addresses. Unset follows deploymentMode: allowed + # on-premises, blocked on saas (SSRF guard). Set true or false to override + playgroundAllowPrivateIPs: null + # -- (bool) Whether notification channels (budget alert webhooks / Slack) + # may deliver to private / internal addresses. Unset follows + # deploymentMode: allowed on-premises, blocked on saas + notifyAllowPrivateURLs: null + # -- (bool) Whether an MCP server's OpenAPI spec_url may be fetched from a + # private / internal address. Unset follows deploymentMode: allowed + # on-premises, blocked on saas, where pasting the spec content still works + mcpSpecAllowPrivateURLs: null + # -- (bool) Whether the guardrail "test connection" probe may reach plain + # http, private addresses and non-Azure hosts (an internal or Private Link + # endpoint). Unset follows deploymentMode: allowed on-premises, blocked on + # saas + guardrailTestConnectionAllowInsecure: null + # -- Let cp-api migrate a database last run by a release older than this one + # supports upgrading from. cp-api refuses such an upgrade at startup and + # names the release to pass through first; set this only after reading that + # message and taking a database backup, then set it back. + allowUnsupportedUpgrade: false + # -- Extra origins the console accepts sign-in from, as bare origins + # (https://host[:port]) or `https://*` patterns. The + # api.publicBaseURL origin and its loopback twin are always trusted; add + # another hostname of this console here, or the local address of a + # port-forward that differs from api.publicBaseURL. An entry cp-api cannot + # read as an origin is logged and ignored. + trustedOrigins: [] + # -- Reverse proxies (CIDRs, e.g. `10.0.0.0/8`) in front of cp-api whose + # X-Forwarded-For cp-api believes. Console sign-in and password reset + # rate limiting, and the client address recorded on a session, + # then use the first address from + # the right that is not one of these proxies. Empty — the default — uses + # the address of whatever connected to cp-api, so behind an ingress every + # client shares the ingress's rate-limit budget. List only proxies you + # run: a range listed here can name any client address it likes. + trustedProxies: [] + # -- Hand the session token to a cross-site console client (a + # frontend-only PR preview) and accept it back in an Authorization header. + # Leave off for a normal install: it exposes the session token to page + # scripts. + bearerSessions: false + # -- Model-pricing catalog URL cp-api syncs from (the models.dev api.json + # format). Empty uses the public models.dev endpoint; point it at an + # internal mirror when cp-api has no internet access. Ignored when + # pricesyncSnapshotPath is set + pricesyncURL: "" + # -- Offline pricing: seed the catalog once at startup from this + # models.dev api.json capture (plain or .gz) and never fetch. The cp-api + # image ships one at /etc/aisix/models-dev-snapshot.json.gz. Empty selects + # online sync, which loads that bundled snapshot when a sync fails while + # the catalog is still empty (an install without internet access), keeps + # an existing catalog, and refreshes every model a later sync returns + pricesyncSnapshotPath: "" + + ## The first administrator of an on-premises install. When cp-api starts + ## on a database with no users yet, it creates the organization `Default` + ## (rename it later in the organization settings) with this account as its + ## owner, who must change the password at the first sign-in. It happens + ## once: changing these afterwards changes nothing. Restoring an + ## organization bundle onto a fresh install is done by this administrator + ## too. Set email to "" to create no administrator. Ignored when + ## deploymentMode is saas. + ## + ## With neither password nor existingSecret, the chart generates a random + ## password into its own Secret (key bootstrap-admin-password) and keeps it + ## across `helm upgrade` by reading that Secret back with `lookup`; the + ## install notes print the command that reads it. `helm template` and + ## GitOps renderers such as Argo CD cannot `lookup`, so they would generate + ## a new password on every render: those deployments must set password or + ## existingSecret explicitly. + bootstrapAdmin: + # -- Email of the first administrator + email: "admin@example.com" + # -- Initial password (12 to 128 characters), stored in the chart's + # Secret. Ignored when existingSecret is set. Empty generates one (see + # above) + password: "" + # -- Name of an existing Secret holding the initial password instead of + # the password value above + existingSecret: "" + # -- Key inside bootstrapAdmin.existingSecret holding the password + existingSecretKey: "bootstrap-admin-password" + + ## Billing — SaaS only. On-premises (api.deploymentMode: on-premises) has + ## no billing, and the chart refuses to render with a provider other than + ## `mock` or with usagePush on, as cp-api refuses to start. Every value + ## here is left out of cp-api's config file while it is empty. + # @ignored + billing: + # -- Billing provider: `stripe`, or `mock` for a provider that accepts + # every change without charging. Empty turns billing off + provider: "" + # -- Push metered usage to the Lago endpoint below + usagePush: false + # -- Stripe Billing customer-portal configuration id (`bpc_...`) + stripePortalConfigID: "" + # -- Lago API endpoint usage is pushed to + lagoEndpoint: "" + # -- Stripe secret API key, stored in the chart's Secret. Ignored when + # existingSecret is set + stripeSecretKey: "" + # -- Stripe webhook signing secret, stored in the chart's Secret. + # Ignored when existingSecret is set + stripeWebhookSecret: "" + # -- Lago API key, stored in the chart's Secret. Ignored when + # existingSecret is set + lagoAPIKey: "" + # -- Name of an existing Secret holding the three billing secrets + # instead of the values above + existingSecret: "" + # -- Keys inside billing.existingSecret. Set a key to "" when the + # Secret does not hold that value; every other key must exist + existingSecretKeys: + stripeSecretKey: "stripe-secret-key" + stripeWebhookSecret: "stripe-webhook-secret" + lagoAPIKey: "lago-api-key" + + ## Organization creation — SaaS only; on-premises creates no organizations. + # @ignored + organization: + # -- Every new organization needs a one-time invite code, including a + # second one by an existing owner. An operator issues codes inside the + # cp-api pod: `kubectl exec deploy/-aisix-cp-api -- cp-api + # invite-code create --note ` (also `list`, `revoke`). + # Signing up and joining an organization through a member invitation + # need no code. Set false to let anyone who signs up create + # organizations + requireInviteCode: true + + ## Organization domain verification — SaaS only. An organization owner + ## proves the organization controls a domain by publishing a DNS TXT + ## record; cp-api looks it up when the owner verifies and about once a + ## day after that. + # @ignored + domainVerification: + # -- DNS server (`host:port`) the TXT lookups are sent to. Empty uses + # the resolver the cp-api pod is configured with + dnsServer: "" + + ## Outbound email. cp-api sends invitation emails, password reset emails + ## and sign-up verification emails through this relay. Email is off until + ## `host` is set; the console then shows invitation links to pass on by + ## hand and asks users who forgot their + ## password to contact an administrator. `deploymentMode: saas` requires + ## `host`: every new account confirms its address by email before it can + ## sign in, and the chart refuses to render without a relay. + ## Deployments with open public sign-up should keep invitationLimitPerHour + ## in place: it is what stops any sign-up from mailing arbitrary addresses + ## through the relay. + smtp: + # -- SMTP relay host name. Empty disables email + host: "" + # -- SMTP relay port. 0 uses the default for the TLS mode: 587 for + # starttls, 465 for smtps, 25 for none + port: 0 + # -- How the connection is protected: `starttls` (plain connection + # upgraded with STARTTLS; refused if the relay does not offer it), + # `smtps` (TLS from the first byte) or `none` (never encrypted). The + # relay's certificate is always verified against the system roots + tls: "starttls" + # -- Sender address of every email, e.g. `AISIX `. + # Required when host is set + from: "" + # -- Username for SMTP authentication. Set together with a password + # (password or existingSecret), or leave both empty to send without + # authenticating. With tls `none` the credentials cross the network + # unencrypted + username: "" + # -- SMTP password, stored in the chart's Secret. Ignored when + # existingSecret is set + password: "" + # -- Name of an existing Secret holding the SMTP password instead of + # the password value above + existingSecret: "" + # -- Key inside smtp.existingSecret holding the password + existingSecretKey: "smtp-password" + # -- Most invitation emails one inviter account may send per rolling + # hour, across every organization; creating and resending an invitation + # both count, and one over the limit is refused (HTTP 429) with nothing + # created. Counted only while email is on. Keep it in place on a + # deployment with open public sign-up: anyone can create an organization + # there, and would otherwise send unlimited mail through your relay + invitationLimitPerHour: 50 + + ## Audit log retention. Audit events older than this are deleted by a + ## daily job; the audit log in the console and the Admin API only ever + ## shows what is kept. + audit: + # -- Days an audit event is kept before it is deleted. 0 keeps audit + # events forever + retentionDays: 730 ## @section dp-manager ## @@ -227,80 +428,46 @@ dpm: tolerations: [] # -- Affinity rules for the dp-manager pod affinity: {} - # -- Extra environment for dp-manager + # -- Extra environment for dp-manager: system-level settings only (for + # example TZ). An env var set here for a dp-manager setting still + # overrides the rendered config file. + # Overriding a name the chart itself sets is only a compatibility path for + # existing deployments. For a name chart 1.5.0 already set, the chart keeps + # its own entry ahead of yours, so the name appears twice and Helm 4's + # server-side apply rejects the install. New installs set the value in this + # file instead. extraEnvVars: [] -## @section dashboard (Next.js) +## @section dashboard ## -## The console's web tier. It is not a separate entry point: cp-api proxies -## browser traffic to it, so it stays a ClusterIP Service in a normal install. +## The console. cp-api serves it from its own image, so there is no separate +## dashboard Deployment: the console is reached wherever the api Service is. ui: - # -- Number of dashboard replicas - replicaCount: 1 - # -- Fixed dashboard UI language for this deployment. Supported: "en", "zh". - # There is no in-UI language switcher; the whole console renders in this - # locale (default English). Read server-side at request time. + # -- Fixed language for this deployment. Supported: "en", "zh". There is + # no in-UI language switcher; the whole console renders in this locale + # (default English), and the emails cp-api sends are written in it too. defaultLocale: "en" - image: - # -- Dashboard image repository - repository: docker.io/api7/aisix-cp-ui - # -- Image pull policy - pullPolicy: IfNotPresent - # -- Image tag - tag: "" # empty -> .Chart.AppVersion + ## Chart 1.5.0 ran the dashboard behind its own Service, and a same-origin + ## proxy may still send pages to it. The Service stays, under the same + ## name, and now reaches cp-api, which serves the console at every path — + ## so pointing such a proxy (or anything else) at the api Service instead + ## is equivalent. New installs do not need it. service: - # -- Dashboard Service type + # -- Type of the dashboard Service kept from chart 1.5.0 type: ClusterIP - # -- Dashboard Service port, which cp-api proxies browser traffic to + # -- Port of the dashboard Service kept from chart 1.5.0; it forwards to cp-api port: 3000 # -- Optional fixed NodePort. Used only when type is NodePort; leave empty # to let Kubernetes allocate a port dynamically. This plain-HTTP endpoint - # is not a standalone Dashboard entry point: use it only behind a - # same-origin reverse proxy that sends Dashboard pages here and /api/* to - # cp-api. The proxy should terminate TLS unless it is on a trusted network. + # reaches cp-api exactly as the api Service does; terminate TLS in front + # of it unless it is on a trusted network. nodePort: "" - resources: - requests: - # -- Dashboard CPU request - cpu: 50m - # -- Dashboard memory request - memory: 64Mi - limits: - # -- Dashboard CPU limit - cpu: 500m - # -- Dashboard memory limit - 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: - # -- Refuse to run the dashboard pod as root - runAsNonRoot: true - seccompProfile: - # -- Seccomp profile for the dashboard pod - type: RuntimeDefault - securityContext: - # -- Forbid privilege escalation in the dashboard container - allowPrivilegeEscalation: false - # -- Mount the dashboard root filesystem read-only - readOnlyRootFilesystem: true - capabilities: - # -- Linux capabilities dropped from the dashboard container - drop: - - ALL - # -- Node selector for the dashboard pod - nodeSelector: {} - # -- Tolerations for the dashboard pod - tolerations: [] - # -- Affinity rules for the dashboard pod - affinity: {} - # -- Extra environment for the dashboard + # -- Kept for installs upgraded from chart 1.5.0, which ran a separate + # dashboard. Of what that release was told to set here, AISIX_TRUSTED_ORIGINS + # and AISIX_AUTH_BEARER_SESSIONS are carried to cp-api (unless + # api.extraEnvVars sets the same name); nothing else is read. Set + # api.extraEnvVars instead. extraEnvVars: [] - # -- Additional volumes for the dashboard Pod (for example, a private database CA). - extraVolumes: [] - # -- Additional mounts for the dashboard container. Use readOnly for CA certificates. - extraVolumeMounts: [] ## @section ServiceAccount serviceAccount: @@ -314,11 +481,11 @@ serviceAccount: ## @section Secrets ## -## REQUIRED. The chart refuses to render while these carry their CHANGE_ME -## placeholders, so an install cannot come up with known secrets. Both values -## have to stay the same for the life of the deployment: the master key is the -## only thing that can decrypt the provider keys stored under it, and the Better -## Auth secret signs every session. Keep them somewhere you can restore from. +## REQUIRED. The chart refuses to render while the master key carries its +## CHANGE_ME placeholder, so an install cannot come up with a known secret. It +## has to stay the same for the life of the deployment: the master key is the +## only thing that can decrypt the provider keys stored under it. Keep it +## somewhere you can restore from. secrets: # -- REQUIRED. Base64-encoded 32-byte AES-256 key that envelope-encrypts the # stored upstream provider credentials. Generate with @@ -328,9 +495,12 @@ secrets: # -- Identifier recorded alongside every value encrypted with the current # master key, so a future key rotation can tell the generations apart masterKeyID: "env:default" - # -- REQUIRED. HMAC signing secret for Better Auth sessions. Generate with - # `openssl rand -base64 48`. Changing it signs every signed-in user out - betterAuthSecret: "CHANGE_ME_GENERATE_WITH_openssl_rand_-base64_48" + # -- Decrypt-only master keys during a master-key rotation, as + # `: ` — the id the key was recorded under + # (lowercase letters, digits, ":" and "-") and the key itself. Rotate by + # moving the current masterKey / masterKeyID here and setting new ones; + # remove an entry once nothing is encrypted under it any more + retiredMasterKeys: {} ## @section PostgreSQL ## @@ -427,8 +597,20 @@ externalDatabase: # URL-safe value: it is embedded in a `postgres://` DSN password: "" # -- libpq sslmode for the connection, e.g. `require` or `verify-full` for a - # managed database reached over a network you do not control + # managed database reached over a network you do not control. `verify-ca` and + # `verify-full` check the server certificate against the image's system trust + # store unless `sslRootCert` supplies the CA, which a database whose + # certificate is issued by a private CA (most managed databases) needs. With + # `sslRootCert` set, `require` verifies the CA too (libpq semantics) sslmode: disable + sslRootCert: + # -- Name of an existing Secret holding the PEM CA bundle that issued the + # database server's certificate. Mounted read-only into cp-api and + # dp-manager and used as libpq `sslrootcert` for every database connection + # they open. Empty leaves verification to the system trust store + existingSecret: "" + # -- Key in that Secret holding the CA bundle + key: ca.crt ## @section Two-DSN tenant isolation (SaaS / multi-tenant only) ## diff --git a/charts/aisix/Chart.yaml b/charts/aisix/Chart.yaml index 75f2ab2..0a7ec0d 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.5.0 -appVersion: "1.5.0" +version: 1.6.0 +appVersion: "1.6.0" keywords: - ai-gateway diff --git a/charts/aisix/README.md b/charts/aisix/README.md index 6c0dedb..8b84cc0 100644 --- a/charts/aisix/README.md +++ b/charts/aisix/README.md @@ -1,6 +1,6 @@ # aisix -![Version: 1.5.0](https://img.shields.io/badge/Version-1.5.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 1.5.0](https://img.shields.io/badge/AppVersion-1.5.0-informational?style=flat-square) +![Version: 1.6.0](https://img.shields.io/badge/Version-1.6.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 1.6.0](https://img.shields.io/badge/AppVersion-1.6.0-informational?style=flat-square) Helm chart for the AISIX AI gateway data plane @@ -335,6 +335,10 @@ keda: threshold: "100" ``` +A streamed request is counted in `aisix_llm_requests_total` when the stream +ends, not when it starts, so during a burst of long streams this signal lags by +roughly one stream length. The steady-state rate is unaffected. + `autoscaling` and `keda` are mutually exclusive — enabling both fails the render rather than letting two controllers fight over the replica count. @@ -560,6 +564,7 @@ rejects. New installs set the value in its own key in `values.yaml` or under | config.observability.metrics.prometheus.enabled | bool | `true` | Serve Prometheus metrics on the metrics listener (`containerPorts.metrics`) | | config.observability.metrics.prometheus.path | string | `"/metrics"` | Metrics path | | config.observability.service_name | string | `"aisix"` | Service name reported in telemetry | +| config.observability.usage_event.request_headers | list | `[]` | Request headers whose values every usage event records (lowercase names; credential headers and duplicates are refused at startup) | | config.proxy.real_ip.header | string | `"x-forwarded-for"` | Header carrying the client IP | | config.proxy.real_ip.recursive | bool | `false` | Walk the header right to left past every trusted proxy | | config.proxy.real_ip.trusted_proxies | list | `[]` | CIDRs (or bare IPs) of proxies trusted to set the client-IP header | @@ -616,7 +621,7 @@ rejects. New installs set the value in its own key in `values.yaml` or under | keda.minReplicas | int | `2` | Lower replica bound | | keda.pollingInterval | int | `15` | How often KEDA evaluates the triggers, in seconds | | keda.restoreToOriginalReplicaCount | bool | `false` | Restore the original replica count when the ScaledObject is deleted | -| keda.triggers | list | `[]` | KEDA triggers. Required when `keda.enabled` is true. For example: `[{type: prometheus, metadata: {serverAddress: "http://prometheus:9090", query: "sum(rate(aisix_llm_requests_total[2m]))", threshold: "100"}}]` | +| keda.triggers | list | `[]` | KEDA triggers. Required when `keda.enabled` is true. For example: `[{type: prometheus, metadata: {serverAddress: "http://prometheus:9090", query: "sum(rate(aisix_llm_requests_total[2m]))", threshold: "100"}}]`. Streamed requests are counted when the stream ends, so during a burst of long streams this signal lags by roughly one stream length; the steady-state rate is unaffected | | listeners | list | `[]` | Proxy listeners, one entry per port. Empty keeps the single plain-HTTP listener described by `containerPorts.proxy` and `service.port` — see "Serve HTTPS and plain HTTP together" above | | livenessProbe.enabled | bool | `true` | | | livenessProbe.failureThreshold | int | `3` | | diff --git a/charts/aisix/README.md.gotmpl b/charts/aisix/README.md.gotmpl index 26e04b8..e8c20e5 100644 --- a/charts/aisix/README.md.gotmpl +++ b/charts/aisix/README.md.gotmpl @@ -329,6 +329,10 @@ keda: threshold: "100" ``` +A streamed request is counted in `aisix_llm_requests_total` when the stream +ends, not when it starts, so during a burst of long streams this signal lags by +roughly one stream length. The steady-state rate is unaffected. + `autoscaling` and `keda` are mutually exclusive — enabling both fails the render rather than letting two controllers fight over the replica count. diff --git a/charts/aisix/ci/config-values.yaml b/charts/aisix/ci/config-values.yaml index 41c620c..3099f45 100644 --- a/charts/aisix/ci/config-values.yaml +++ b/charts/aisix/ci/config-values.yaml @@ -18,6 +18,8 @@ config: heap_profiling: auto_dump: thresholds: [0.7, 0.95] + usage_event: + request_headers: [x-sub-user, x-department] proxy: real_ip: trusted_proxies: ["10.0.0.0/8", "127.0.0.1/32"] diff --git a/charts/aisix/values.yaml b/charts/aisix/values.yaml index 5873ab6..72f21de 100644 --- a/charts/aisix/values.yaml +++ b/charts/aisix/values.yaml @@ -300,6 +300,9 @@ config: dir: /var/lib/aisix/heap # -- Newest profiles kept keep: 5 + usage_event: + # -- Request headers whose values every usage event records (lowercase names; credential headers and duplicates are refused at startup) + request_headers: [] cache: # -- Legacy cache backend switch, `memory` or `redis`. `redis` requires # `cache.redis`. Which cache serves a request is chosen per cache policy @@ -444,7 +447,10 @@ keda: annotations: {} # -- KEDA triggers. Required when `keda.enabled` is true. For example: # `[{type: prometheus, metadata: {serverAddress: "http://prometheus:9090", - # query: "sum(rate(aisix_llm_requests_total[2m]))", threshold: "100"}}]` + # query: "sum(rate(aisix_llm_requests_total[2m]))", threshold: "100"}}]`. + # Streamed requests are counted when the stream ends, so during a burst of + # long streams this signal lags by roughly one stream length; the + # steady-state rate is unaffected triggers: [] # -- `behavior` for the HPA KEDA creates. Empty uses the Kubernetes defaults behavior: {}