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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions .github/scripts/aisix-config-boot.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
#!/usr/bin/env bash
# Boot the gateway image the chart deploys (its appVersion) on the config file
# the chart renders, in both modes, with charts/aisix/ci/config-values.yaml
# setting list-typed keys through `config`.
#
# Standalone must come up and answer /livez. Control-plane mode has no control
# plane to reach here, so it must get past loading the config file and fail
# only once it acts on the connection. Override the image with AISIX_IMAGE.
set -euo pipefail

chart=charts/aisix
app=$(awk '/^appVersion:/ {gsub(/"/, "", $2); print $2}' "$chart/Chart.yaml")
image=${AISIX_IMAGE:-docker.io/api7/aisix:$app}
work=$(mktemp -d)
chmod 755 "$work"
run_id=$$
trap 'docker rm -f "aisix-cfgboot-standalone-$run_id" "aisix-cfgboot-managed-$run_id" >/dev/null 2>&1 || true; rm -rf "$work"' EXIT

# extract <template> <data key> <out file> [helm args...]
extract() {
local template=$1 key=$2 out=$3
shift 3
helm template ci "$chart" "$@" --show-only "templates/$template" \
| python3 -c '
import sys, yaml
key = sys.argv[1]
for doc in yaml.safe_load_all(sys.stdin):
if doc:
data = doc.get("data") or doc.get("stringData") or {}
if key in data:
sys.stdout.write(data[key])
' "$key" >"$out"
test -s "$out"
chmod 644 "$out"
}

echo "== image $image"
docker pull -q "$image" >/dev/null

echo "== standalone"
mkdir -p "$work/standalone"
set_standalone=(-f "$chart/ci/standalone-values.yaml" -f "$chart/ci/config-values.yaml")
extract configmap.yaml config.yaml "$work/standalone/config.yaml" "${set_standalone[@]}"
extract secret.yaml resources.yaml "$work/standalone/resources.yaml" "${set_standalone[@]}"
cat "$work/standalone/config.yaml"
name=aisix-cfgboot-standalone-$run_id
docker run -d --name "$name" -p 127.0.0.1::3000 \
-e AISIX_CONFIG_PATH=/etc/aisix/chart/config.yaml \
-e OPENAI_API_KEY=sk-ci-placeholder -e CALLER_API_KEY=ci-caller-placeholder \
-v "$work/standalone/config.yaml:/etc/aisix/chart/config.yaml:ro" \
-v "$work/standalone/resources.yaml:/etc/aisix/resources/resources.yaml:ro" \
"$image" >/dev/null
port=$(docker port "$name" 3000/tcp | head -n1 | sed 's/.*://')
ok=
for _ in $(seq 1 60); do
if curl -fsS "http://127.0.0.1:$port/livez" >/dev/null 2>&1; then ok=1; break; fi
if [ "$(docker inspect -f '{{.State.Running}}' "$name")" != true ]; then break; fi
sleep 1
done
if [ -z "$ok" ]; then
echo "standalone gateway did not come up on the rendered config:"
docker logs "$name" 2>&1 | tail -n 50
exit 1
fi
echo "standalone: /livez answered"

echo "== control-plane mode"
mkdir -p "$work/managed/cp-mtls"
extract configmap.yaml config.yaml "$work/managed/config.yaml" -f "$chart/ci/config-values.yaml"
cat "$work/managed/config.yaml"
openssl req -x509 -newkey rsa:2048 -nodes -days 1 -subj "/CN=aisix-ci" \
-keyout "$work/managed/cp-mtls/key.pem" -out "$work/managed/cp-mtls/cert.pem" 2>/dev/null
cp "$work/managed/cp-mtls/cert.pem" "$work/managed/cp-mtls/ca.pem"
chmod 755 "$work/managed/cp-mtls"
chmod 644 "$work/managed/cp-mtls/"*.pem
name=aisix-cfgboot-managed-$run_id
docker run -d --name "$name" \
-e AISIX_CONFIG_PATH=/etc/aisix/chart/config.yaml \
-v "$work/managed/config.yaml:/etc/aisix/chart/config.yaml:ro" \
-v "$work/managed/cp-mtls:/etc/aisix/cp-mtls:ro" \
"$image" >/dev/null
for _ in $(seq 1 30); do
[ "$(docker inspect -f '{{.State.Running}}' "$name")" = true ] || break
sleep 1
done
logs=$(docker logs "$name" 2>&1)
echo "$logs" | tail -n 20
# `level=debug` is config-values.yaml's log_level: the file was read and applied.
if echo "$logs" | grep -q "config load failed"; then
echo "control-plane mode: the rendered config file did not load"
exit 1
fi
if ! echo "$logs" | grep -q "tracing initialised service=aisix level=debug"; then
echo "control-plane mode: the gateway never got past loading its config file"
exit 1
fi
echo "control-plane mode: config file loaded; the gateway stopped at the control-plane connection"
73 changes: 73 additions & 0 deletions .github/scripts/aisix-render-checks.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
#!/usr/bin/env bash
# Render-time assertions for charts/aisix: what the config file, env and
# volumes look like for a given set of values, and which values the chart
# refuses. Needs helm and python3 with PyYAML.
set -euo pipefail

chart=charts/aisix
base=(-f "$chart/ci/default-values.yaml")
fails=0

render() { helm template ci "$chart" "${base[@]}" "$@"; }

# query <python expr over `docs` (the rendered manifests)> <helm args...>
query() {
local expr=$1
shift
render "$@" | python3 -c '
import sys, yaml
docs = [d for d in yaml.safe_load_all(sys.stdin) if d]
dep = next(d for d in docs if d["kind"] == "Deployment")
pod = dep["spec"]["template"]["spec"]
env = {e["name"]: e for e in pod["containers"][0].get("env", [])}
vols = {v["name"] for v in pod.get("volumes", [])}
cm = next(d for d in docs if d["kind"] == "ConfigMap" and d["metadata"]["name"].endswith("-config"))
cfg = yaml.safe_load(cm["data"]["config.yaml"])
sys.exit(0 if eval(sys.argv[1]) else 1)
' "$expr"
}

check() {
local what=$1
shift
if "$@"; then echo "ok $what"; else echo "FAIL $what"; fails=$((fails + 1)); fi
}

refuses() {
local want=$1
shift
local out
if out=$(render "$@" 2>&1); then return 1; fi
grep -q -- "$want" <<<"$out"
}

check "control-plane mode mounts the mTLS bundle as files" \
query '"cp-mtls" in vols and cfg["managed"]["cp_cert_file"] == "/etc/aisix/cp-mtls/cert.pem" and not any(n.startswith("AISIX_MANAGED__CP_") for n in env)'
check "extraEnvVars PEMs keep the env wiring: no file keys, no mount, all three PEMs from the bundle Secret" \
query '"cp-mtls" not in vols and not any(k.startswith("cp_") and k.endswith("_file") for k in cfg["managed"]) and all(env[f"AISIX_MANAGED__CP_{s}_PEM"].get("valueFrom") or env[f"AISIX_MANAGED__CP_{s}_PEM"].get("value") for s in ("CERT", "KEY", "CA"))' \
--set 'extraEnvVars[0].name=AISIX_MANAGED__CP_CERT_PEM' --set 'extraEnvVars[0].value=pem'
# 1.5.0 rendered the chart's own PEM variables and then the extraEnvVars ones,
# duplicate names included. Rendering the same pair keeps an upgrade's
# three-way merge from deleting both entries of a name that one side drops.
check "extraEnvVars PEMs render after the chart's own, exactly as 1.5.0 did" \
query '[e["name"] for e in pod["containers"][0]["env"] if e["name"] == "AISIX_MANAGED__CP_CERT_PEM"] == ["AISIX_MANAGED__CP_CERT_PEM"] * 2 and pod["containers"][0]["env"][[e["name"] for e in pod["containers"][0]["env"]].index("AISIX_MANAGED__CP_CERT_PEM")]["valueFrom"]["secretKeyRef"]["name"] == "aisix-gateway-certificate"' \
--set 'extraEnvVars[0].name=AISIX_MANAGED__CP_CERT_PEM' --set 'extraEnvVars[0].value=pem'
check "configSecrets becomes a secretKeyRef env var" \
query 'env["AISIX_CACHE__REDIS__PASSWORD"]["valueFrom"]["secretKeyRef"] == {"name": "redis-auth", "key": "password"}' \
--set 'configSecrets.cache\.redis\.password.secretName=redis-auth' --set 'configSecrets.cache\.redis\.password.key=password'
check "config values land in the file, list-typed ones included" \
query 'cfg["observability"]["heap_profiling"]["auto_dump"]["thresholds"] == [0.7, 0.95] and cfg["proxy"]["request_id"]["accept_headers"] == ["x-aisix-request-id", "x-request-id"]' \
-f "$chart/ci/config-values.yaml"
check "standalone mode renders no etcd or managed section" \
query '"etcd" not in cfg and "managed" not in cfg and cfg["admin"] == {"enabled": False} and cfg["resources_file"]' \
-f "$chart/ci/standalone-values.yaml"
check "a chart-owned key under config is refused" \
refuses "use containerPorts.proxy" --set config.proxy.addr=0.0.0.0:1
check "a credential under config is refused" \
refuses "set it through configSecrets.cache.redis.password" --set config.cache.redis.password=x
check "a dotted key under config is refused" \
refuses "contains a dot" --set 'config.cache\.redis\.password=x'
check "configSecrets outside the allow-list is refused" \
refuses "not a credential-bearing setting" --set 'configSecrets.upstream\.timeout_ms.secretName=a' --set 'configSecrets.upstream\.timeout_ms.key=b'

[ "$fails" -eq 0 ] || { echo "$fails render check(s) failed"; exit 1; }
116 changes: 116 additions & 0 deletions .github/scripts/check-aisix-config-drift.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
#!/usr/bin/env python3
"""Fail when charts/aisix's `config:` block drifts from the gateway.

The gateway publishes config.reference.json — every startup-config key with
its default — at the root of api7/aisix. This compares it, at the tag of the
chart's appVersion, with the chart's `config:` block: the same keys, with the
same defaults, except the keys charts/aisix/config-policy.yaml names as set by
the chart or held in Secrets; and, for each block that is off unless written,
the same sub-keys as config-policy.yaml accounts for.

A release that predates config.reference.json has no tag copy; the check then
falls back to api7/aisix main and says so. CONFIG_REFERENCE_FILE points the
check at a local copy instead, for testing a gateway change before it merges.
"""
import json
import os
import sys
import urllib.error
import urllib.request

import yaml

CHART = "charts/aisix"
RAW = "https://raw.githubusercontent.com/api7/aisix/{ref}/config.reference.json"


def fetch(ref):
try:
with urllib.request.urlopen(RAW.format(ref=ref), timeout=30) as resp:
return json.load(resp)
except urllib.error.HTTPError as err:
if err.code == 404:
return None
raise


def flatten(node, prefix=""):
out = {}
if isinstance(node, dict) and node:
for key, value in node.items():
out.update(flatten(value, f"{prefix}{key}."))
else:
out[prefix[:-1]] = node
return out


def excluded(path, prefixes):
return any(path == p or path.startswith((p + ".", p + "[]")) for p in prefixes)


def main():
with open(f"{CHART}/Chart.yaml") as f:
app_version = yaml.safe_load(f)["appVersion"]
with open(f"{CHART}/values.yaml") as f:
chart_config = yaml.safe_load(f)["config"]
with open(f"{CHART}/config-policy.yaml") as f:
policy = yaml.safe_load(f)

ref = f"v{app_version}"
local = os.environ.get("CONFIG_REFERENCE_FILE")
if local:
ref = local
with open(local) as f:
reference = json.load(f)
else:
reference = fetch(ref)
if reference is None:
print(f"note: api7/aisix {ref} has no config.reference.json; comparing against main")
ref = "main"
reference = fetch(ref)
if reference is None:
sys.exit("api7/aisix main has no config.reference.json")

skip = list(policy["owned"]) + list(policy["secrets"])
want = {k: v for k, v in flatten(reference["defaults"]).items() if not excluded(k, skip)}
have = flatten(chart_config)

problems = []
# Blocks that are off unless written: every gateway sub-key must be one
# the chart documents, owns, or routes through configSecrets.
documented = policy.get("optional") or {}
for block, keys in sorted(reference["optional_blocks"].items()):
if excluded(block, skip):
continue
listed = {f"{block}.{k}" for k in documented.get(block, [])}
for path in sorted(f"{block}.{k}" for k in flatten(keys)):
if path not in listed and not excluded(path, skip):
problems.append(
f"unaccounted sub-key: {path} — add it to config-policy.yaml "
f"optional.{block}, or to secrets if it holds a credential"
)
for path in sorted(listed - {f"{block}.{k}" for k in flatten(keys)}):
problems.append(f"not a gateway setting at {ref}: {path} (config-policy.yaml optional)")

for path in sorted(want.keys() - have.keys()):
problems.append(f"missing from config: {path} (gateway default {json.dumps(want[path])})")
for path in sorted(have.keys() - want.keys()):
problems.append(f"not a gateway setting at {ref}: {path}")
for path in sorted(want.keys() & have.keys()):
if want[path] != have[path]:
problems.append(
f"default differs: {path} is {json.dumps(have[path])} in values.yaml, "
f"{json.dumps(want[path])} in the gateway"
)

if problems:
print(f"charts/aisix config: drifted from api7/aisix {ref} config.reference.json:")
for line in problems:
print(f" {line}")
print("Mirror the gateway in values.yaml `config:`, or name the key in config-policy.yaml.")
sys.exit(1)
print(f"charts/aisix config: matches api7/aisix {ref} ({len(want)} keys)")


if __name__ == "__main__":
main()
11 changes: 11 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,17 @@ jobs:
--charts charts/developer-portal-fe \
--charts charts/ngxdig'

- name: Check aisix chart config against the gateway
run: |
python3 -c 'import yaml' 2>/dev/null || sudo apt-get install -y python3-yaml
python3 .github/scripts/check-aisix-config-drift.py

- name: Check aisix chart renders
run: bash .github/scripts/aisix-render-checks.sh

- name: Boot the aisix gateway on the chart-rendered config
run: bash .github/scripts/aisix-config-boot.sh

- name: Verify Chart.lock files
run: |
docker run --rm --interactive --network host \
Expand Down
14 changes: 14 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,20 @@ The gateway chart's `values.yaml`/configmap mirror the gateway's `conf/config-de

When adding values, keep defaults identical to `config-default.yaml` so a default render changes nothing. To verify a rendered config is valid, extract `config.yaml` from the rendered configmap and run it through the real image: `docker run --rm --entrypoint sh -v $PWD/config.yaml:/usr/local/apisix/conf/config.yaml api7/api7-ee-3-gateway:<ver> -c 'apisix init'` — the config schema is validated at init.

## aisix chart: application settings are explicit values rendered to a ConfigMap

Every application setting `charts/aisix` deploys is declared explicitly in `values.yaml` and reaches the gateway as its config file through a ConfigMap. `extraEnvVars` is for system-level environment (`TZ`) and for variables a standalone resources file references — never the documented way to set a gateway setting, even though an `AISIX_*` variable there still overrides the file.

How the chart implements it:

- `config:` mirrors the gateway's startup file one-to-one — the same section and key names as `config.example.yaml` / `crates/aisix-core/src/config.rs` in `api7/aisix` — with every key listed at the gateway's own default (`null` = omitted = gateway default). No chart-specific renaming. `aisix.configFile` in `_helpers.tpl` renders it into `templates/configmap.yaml` in both modes, fills the chart-owned keys for the mode, and drops nulls. The pod template carries `checksum/config` and `checksum/secret`, so a change rolls the pods.
- `config-policy.yaml` is the single list of what `config:` does not carry: `owned` keys the chart sets from its other values (listen addresses, `listeners`, `rateLimit`, `controlPlane`, `standalone`, `admin`, most of `etcd`), and `secrets` — credential-bearing keys, set only through `configSecrets` and wired as `secretKeyRef` env. The render fails when `config:` names either kind. Credentials never go into the ConfigMap: where the gateway has a `*_file` key (the control-plane mTLS bundle) the Secret is mounted as a file, otherwise it is a chart-managed `secretKeyRef` env var.
- `.github/scripts/check-aisix-config-drift.py` compares `config:` with `config.reference.json` from `api7/aisix` at `v<appVersion>` (falling back to `main` for a tag that predates the file), minus `config-policy.yaml`. So a new gateway key, a changed default, or a new chart-owned/secret key is a `values.yaml` or `config-policy.yaml` edit, made with the chart bump that moves `appVersion` to the release that has it — not earlier, since the chart only runs its own `appVersion` image and an older gateway rejects unknown keys.
- `config-policy.yaml` also lists, under `optional`, the documented sub-keys of the blocks that are off unless written (`cache.redis`, `ratelimit.redis`); the drift check fails on a gateway sub-key there that none of the three lists accounts for.
- A release that sets `AISIX_MANAGED__CP_*_PEM` through `extraEnvVars` keeps the pre-file wiring (PEM env vars from the bundle Secret, no `cp_*_file`, no mount): the gateway rejects a PEM and a file for the same slot. `.github/scripts/aisix-render-checks.sh` pins that and the refusals; extend it when the rendering changes.
- `.github/scripts/aisix-config-boot.sh` boots the `appVersion` image on the rendered file in both modes with `ci/config-values.yaml`; run it locally after changing `config:` or `aisix.configFile`.
- Values keys are a compatibility surface for `helm upgrade` from released charts, `--reuse-values` included — and `--reuse-values` replaces the new chart's defaults with the old release's values, so a template must treat a new values key as possibly absent (`| default dict`).

## developer-portal-fe chart: config is a pass-through

`config.yaml` is rendered from `developerPortal.config`, which carries the application's whole config schema (owned by `api7/api7ee-developer-portal`, `apps/site/src/lib/config/schema.ts`). **Do not hoist schema fields into their own `.Values` keys** — a dedicated key plus template plumbing per field mirrors that schema, drifts on every application release, and leaves two ways to set one field. Only the connection settings the chart itself owns are templated, and they are merged over the block, so a user cannot replace the `${PORTAL_TOKEN}` / `${DB_URL}` / `${AUTH_SECRET}` placeholders with literal credentials — those placeholders stay in the ConfigMap and resolve from Secret-backed env vars at startup.
Expand Down
Loading
Loading