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
7 changes: 7 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
bin
.git
test
config
third_party
hack
*.md
45 changes: 45 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
name: CI

on:
push:
branches: [main]
pull_request:

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: gofmt
run: test -z "$(gofmt -l .)" || { gofmt -l .; exit 1; }
- name: lint
run: make lint
- name: generated code and CRDs are current
run: make crds-check
- name: unit and envtest
run: make test
- name: unit tests against the aisix binary
run: make test-aisix

e2e:
needs: check
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
- uses: helm/kind-action@v1
with:
install_only: true
- uses: azure/setup-helm@v4
- name: e2e
run: test/e2e/run.sh
env:
# The chart download falls back to `gh release download`.
GH_TOKEN: ${{ github.token }}
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/bin/
/cover.out
6 changes: 6 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
version: "2"
linters:
default: standard
formatters:
enable:
- gofmt
33 changes: 33 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# syntax=docker/dockerfile:1

# The aisix release whose `validate` the controller runs. Keep it equal to
# third_party/aisix/VERSION.
ARG AISIX_VERSION=1.9.0

FROM --platform=$BUILDPLATFORM golang:1.26 AS build
ARG TARGETOS TARGETARCH
WORKDIR /src
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod go mod download
COPY api ./api
COPY cmd ./cmd
COPY internal ./internal
RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH \
go build -trimpath -ldflags="-s -w" -o /out/aisix-controller ./cmd/aisix-controller

FROM docker.io/api7/aisix:${AISIX_VERSION} AS aisix

# The gateway image grants the binary cap_net_bind_service. A container
# that drops all capabilities with no_new_privs cannot exec such a file,
# and validation binds nothing, so a plain copy drops it.
FROM debian:trixie-slim AS aisix-bin
COPY --from=aisix /usr/local/bin/aisix /aisix-with-caps
RUN cp /aisix-with-caps /aisix

# Same Debian release as the aisix image, for the binary's glibc.
FROM debian:trixie-slim
COPY --from=aisix-bin /aisix /usr/local/bin/aisix
COPY --from=build /out/aisix-controller /usr/local/bin/aisix-controller
USER 65532:65532
ENTRYPOINT ["/usr/local/bin/aisix-controller"]
57 changes: 57 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
IMAGE ?= aisix-controller:dev
AISIX_TAG ?= $(shell cat third_party/aisix/VERSION)
ENVTEST_K8S_VERSION ?= 1.36.2
LOCALBIN ?= $(CURDIR)/bin
CONTROLLER_GEN ?= $(LOCALBIN)/controller-gen
SETUP_ENVTEST ?= $(LOCALBIN)/setup-envtest
GOLANGCI_LINT ?= $(LOCALBIN)/golangci-lint

CONTROLLER_TOOLS_VERSION ?= v0.20.0
SETUP_ENVTEST_VERSION ?= release-0.24
GOLANGCI_LINT_VERSION ?= v2.7.1

.PHONY: build test test-aisix lint generate crds crds-check sync-schemas image e2e

build:
go build ./...

## Unit tests plus the envtest suites.
test: $(SETUP_ENVTEST)
KUBEBUILDER_ASSETS="$$($(SETUP_ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(LOCALBIN) -p path)" go test ./...

## Unit tests against the real aisix binary, in a Linux container.
test-aisix:
hack/test-with-aisix.sh ./internal/...

lint: $(GOLANGCI_LINT)
$(GOLANGCI_LINT) run ./...

## DeepCopy methods for the API types.
generate: $(CONTROLLER_GEN)
$(CONTROLLER_GEN) object:headerFile=hack/boilerplate.go.txt paths=./api/...

## CRDs from the vendored aisix schemas.
crds:
go run ./cmd/crdgen -schemas third_party/aisix -out config/crd

crds-check: generate crds
git diff --exit-code api config/crd

## Vendor another aisix release's schemas: make sync-schemas AISIX_TAG=v1.10.0
sync-schemas:
hack/sync-schemas.sh $(AISIX_TAG)

image:
docker build --build-arg AISIX_VERSION=$(patsubst v%,%,$(AISIX_TAG)) -t $(IMAGE) .

e2e:
IMAGE=$(IMAGE) test/e2e/run.sh

$(CONTROLLER_GEN):
GOBIN=$(LOCALBIN) go install sigs.k8s.io/controller-tools/cmd/controller-gen@$(CONTROLLER_TOOLS_VERSION)

$(SETUP_ENVTEST):
GOBIN=$(LOCALBIN) go install sigs.k8s.io/controller-runtime/tools/setup-envtest@$(SETUP_ENVTEST_VERSION)

$(GOLANGCI_LINT):
GOBIN=$(LOCALBIN) go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION)
111 changes: 111 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# aisix-controller

A Kubernetes controller for the [AISIX](https://github.com/api7/aisix) AI gateway. It reconciles AISIX custom resources into a standalone gateway's resources file, so models, provider credentials and caller keys are managed with `kubectl apply`.

## How it works

```
ProviderKey / Model / APIKey + Secrets
│
▼
aisix-controller ── render ── aisix validate (bundled binary) ──► Secret aisix-resources
│ │ mounted by the chart
│ stamps aisix.api7.ai/resources-hash on the pod template ▼
└──────────────────────────────────────────────────► gateway Deployment rolls
▲ │
└──── GET <pod>:9090/status/config (applied.config_hash) ◄──┘
```

1. Every change to a custom resource, a referenced Secret, the gateway Deployment or its pods re-renders the whole resources file (`_format_version: "1"`).
2. The document is checked with `aisix validate` from the same aisix release, run with an empty environment. A resource the gateway would reject keeps its previously served entry, or is left out if it was never served. The Secret never holds a file the gateway cannot load, which matters because a gateway pod with an invalid file fails to start.
3. A changed document is written to the `aisix-resources` Secret, next to `aisix-controller.index.json`, which records the resource each entry belongs to so a resource's served entry is found even after its `display_name` changes. The document's SHA-256 is stamped on the gateway Deployment's pod template. The gateway reads its resources file at boot and on SIGHUP only, so the stamp rolls the pods.
4. `Programmed` turns true once every ready gateway pod reports the written file's hash as `applied.config_hash` on its status listener.

## Resources

All kinds are namespaced, in `aisix.api7.ai/v1alpha1`. A `spec` is the AISIX resource document with AISIX's own snake_case field names, minus credentials and id-form references, plus the references below. `display_name` defaults to `metadata.name`; set it when the name is not a valid Kubernetes name (for example `openai/gpt-4o`).

| Kind | Short name | Reference fields |
|---|---|---|
| `ProviderKey` | `aipk` | `api_key_secret_ref: {name, key}`: the upstream credential |
| `Model` | `aimodel` | `provider_key_ref: {name}`: a ProviderKey's `metadata.name` (direct models only) |
| `APIKey` | `aikey` | `key_secret_ref: {name, key}`: the caller's plaintext key; only its SHA-256 is written |

Secret refs may name any Secret in the namespace except the controller's own `aisix-resources`. A `provider_key_ref` must name a ProviderKey that is served; one that lost a shared `display_name` to an older ProviderKey is rejected.

```yaml
apiVersion: aisix.api7.ai/v1alpha1
kind: ProviderKey
metadata:
name: openai
spec:
provider: openai
api_key_secret_ref: {name: llm-creds, key: openai}
---
apiVersion: aisix.api7.ai/v1alpha1
kind: Model
metadata:
name: gpt-4o
spec:
display_name: openai/gpt-4o
provider: openai
model_name: gpt-4o
provider_key_ref: {name: openai}
---
apiVersion: aisix.api7.ai/v1alpha1
kind: APIKey
metadata:
name: team-a
spec:
allowed_models: ["openai/gpt-4o"]
key_secret_ref: {name: caller-keys, key: team-a}
```

Conditions:

| Condition | True when |
|---|---|
| `Accepted` | The current spec is valid for the gateway |
| `Programmed` | Every ready gateway pod serves a resources file containing the current spec |

The CRD schemas are generated from the strict JSON Schemas of the aisix release in `third_party/aisix/VERSION`. Rules a structural schema cannot hold (`oneOf` branches, `if`/`then`) are still enforced by `aisix validate`, and show up as `Accepted=False`.

## Install

The controller and the gateway run in the same namespace. Install the controller first, so the resources Secret exists:

```bash
kubectl apply --server-side -k config/crd
kubectl -n aisix apply -k config/controller

helm install aisix aisix --repo https://charts.api7.ai -n aisix \
--set controlPlane.enabled=false \
--set standalone.existingSecret=aisix-resources \
--set fullnameOverride=aisix
```

Flags (see `aisix-controller --help`): `--secret`, `--secret-key`, `--deployment`, `--gateway-status-port` (the chart's metrics port, default 9090), `--aisix-binary`, `--resync`, `--poll`, `--leader-elect`.

The controller reads each gateway pod's `/status/config` on the metrics port, which the gateway serves without authentication. Keep the chart's `metrics.enabled` on, and allow controller-to-gateway traffic on that port if a NetworkPolicy applies.

## Limitations in v0.1

- Every configuration change restarts the gateway pods, at least `preStopSleepSeconds` (30 s by default) per batch, and resets in-memory rate-limit counters.
- One gateway Deployment per controller, and one namespace.
- No admission webhook: invalid specs are reported through `Accepted=False`.
- The resources file must fit in one Secret (1 MiB).
- Kinds beyond ProviderKey, Model and APIKey are not supported yet.

## Development

```bash
make build
make test # unit + envtest suites
make test-aisix # unit tests against the real aisix binary (Docker)
make lint
make crds # regenerate config/crd from third_party/aisix
make crds-check # fail if generated code or CRDs are stale
make e2e # kind cluster + AISIX chart + controller
```

To move to another aisix release: `make sync-schemas AISIX_TAG=v1.x.y && make crds`, then bump `AISIX_VERSION` in the Dockerfile.
28 changes: 28 additions & 0 deletions api/v1alpha1/groupversion_info.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// Package v1alpha1 holds the aisix.api7.ai/v1alpha1 custom resource types.
// +kubebuilder:object:generate=true
// +groupName=aisix.api7.ai
package v1alpha1

import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
)

var (
GroupVersion = schema.GroupVersion{Group: "aisix.api7.ai", Version: "v1alpha1"}

SchemeBuilder = runtime.NewSchemeBuilder(addKnownTypes)

AddToScheme = SchemeBuilder.AddToScheme
)

func addKnownTypes(s *runtime.Scheme) error {
s.AddKnownTypes(GroupVersion,
&ProviderKey{}, &ProviderKeyList{},
&Model{}, &ModelList{},
&APIKey{}, &APIKeyList{},
)
metav1.AddToGroupVersion(s, GroupVersion)
return nil
}
Loading
Loading