Gateway-Controller
The Gateway-Controller is the xDS control plane that manages API configurations and dynamically configures the Router (Envoy Proxy).
Features
- REST API: Submit, update, delete, and query API configurations via HTTP
- Validation: Comprehensive validation with field-level error messages
- Persistence: Embedded SQLite database for configuration storage
- In-Memory Cache: Fast access with thread-safe operations
- xDS Server: gRPC server implementing xDS State-of-the-World protocol to serve Envoy
- Zero-Downtime Updates: Configuration changes applied without dropping connections (restarts)
Architecture
REST API (Port 9090)
↓
Validation
↓
Persistence (SQLite) + In-Memory Cache
↓
xDS Translator
↓
xDS gRPC Server (Port 18000)
↓
Router (Envoy)
Building
Prerequisites
- Go 1.25.1+
- Make
- Docker buildx
- brew install docker-buildx
- mkdir -p ~/.docker/cli-plugins
- ln -sfn /opt/homebrew/opt/docker-buildx/bin/docker-buildx ~/.docker/cli-plugins/docker-buildx
- docker buildx version #
Build from Source
# Generate API code from OpenAPI spec
make generate
# Build binary
make build
# Run tests
make test
# Build Docker image
make docker
Running
Local Development
# Run with default settings
make run
# Or run the binary directly
./bin/controller
Docker
docker run -p 9090:9090 -p 18000:18000 \
-v $(pwd)/data:/data \
wso2/gateway-controller:latest
Configuration
The Gateway-Controller is configured from a TOML config file (--config <path>, default
/etc/gateway-controller/config.toml) layered over built-in defaults.
Priority: Config file > Defaults. Environment variables do not override config keys
directly. An environment value reaches a setting only through an explicit {{ env "NAME" }}
interpolation token in the config file (see Environment values via interpolation).
Configuration File
Create a config.yaml file (default location: /etc/gateway-controller/config.yaml):
# Server configuration
server:
api_port: 9090 # REST API port
xds_port: 18000 # xDS gRPC server port
shutdown_timeout: 15s # Graceful shutdown timeout
# Storage configuration
storage:
type: sqlite # "sqlite", "postgres"
sqlite:
path: ./data/gateway.db # SQLite database file path
postgres: # Used when type=postgres
host: localhost
port: 5432
database: gateway
user: gateway
password: ""
sslmode: require # disable, require, verify-ca, verify-full
connect_timeout: 5s
max_open_conns: 25
max_idle_conns: 5
conn_max_lifetime: 30m
conn_max_idle_time: 5m
application_name: gateway-controller
# Router (Envoy) configuration
router:
access_logs:
enabled: true # Enable/disable access logs
format: json # "json" or "text"
listener_port: 8080 # Envoy proxy port
# Logging configuration
logging:
level: info # "debug", "info", "warn", "error"
format: json # "json" or "text"
Command-Line Flags
# Specify custom config file location
./bin/controller --config /path/to/config.yaml
Environment values via interpolation
There is no APIP_GW_ prefix auto-override. An environment variable affects configuration only
when the config file references it with a {{ env "NAME" "default" }} token, resolved at startup via
os.LookupEnv. A missing or empty variable falls back to the token's default; a bare token with no
default fails startup. Secrets can instead be read from a mounted file with {{ file "PATH" }}
(restricted to an allowlist of directories, overridable with APIP_CONFIG_FILE_SOURCE_ALLOWLIST).
APIP_GW_... is just a naming convention for the token argument (mirrors platform-api's APIP_CP_).
The shipped configs/config.toml and configs/config-template.toml already carry tokens for the
common settings:
[controller.storage]
type = '{{ env "APIP_GW_CONTROLLER_STORAGE_TYPE" "sqlite" }}'
[controller.storage.sqlite]
path = '{{ env "APIP_GW_CONTROLLER_STORAGE_SQLITE_PATH" "./data/gateway.db" }}'
[controller.storage.database]
dsn = '{{ env "APIP_GW_CONTROLLER_STORAGE_DATABASE_DSN" "" }}' # SQL Server (type = "sqlserver")
[controller.controlplane]
host = '{{ env "APIP_GW_CONTROLLER_CONTROLPLANE_HOST" "" }}'
token = '{{ env "APIP_GW_CONTROLLER_CONTROLPLANE_TOKEN" "" }}'
[controller.logging]
level = '{{ env "APIP_GW_CONTROLLER_LOGGING_LEVEL" "info" }}'
Deliver the values with docker-compose env_file: ./api-platform.env (recommended); a
docker compose --env-file api-platform.env up at the CLI also works. Generate api-platform.env
with ./scripts/setup.sh (or create it by hand) and edit it:
# api-platform.env
APIP_GW_CONTROLLER_STORAGE_TYPE=sqlserver
APIP_GW_CONTROLLER_STORAGE_DATABASE_DSN=sqlserver://sa:secret@sqlserver:1433?database=gateway&encrypt=disable
APIP_GW_CONTROLLER_CONTROLPLANE_HOST=connect.example.com:9243
APIP_GW_CONTROLLER_CONTROLPLANE_TOKEN=<registration-token>
APIP_GW_CONTROLLER_LOGGING_LEVEL=debug
APIP_GW_CONTROLLER_AUTH_BASIC_ADMIN_USERNAME=admin
APIP_GW_CONTROLLER_AUTH_BASIC_ADMIN_PASSWORD_HASH=<bcrypt-hash-of-the-admin-password>
When delivering the bcrypt hash through docker-compose, use env_file with format: raw (as the
shipped composes do) so the $ characters in the hash are not treated as compose interpolation.
To configure a key that has no token in the shipped config, add the {{ env }} token to your
config file for that key, or set the value in the file directly.
Configuration Modes
Persistent Mode with SQLite (Default)
Use SQLite database for persistence across restarts:
storage:
type: sqlite
sqlite:
path: ./data/gateway.db
Persistent Mode with PostgreSQL
Use an external PostgreSQL server for persistence:
storage:
type: postgres
postgres:
host: postgres.example.internal
port: 5432
database: gateway
user: gateway
password: ${DB_PASSWORD}
sslmode: require
connect_timeout: 5s
max_open_conns: 25
max_idle_conns: 5
conn_max_lifetime: 30m
conn_max_idle_time: 5m
application_name: gateway-controller
Persistent Mode with SQL Server
Use an external SQL Server instance for persistence:
storage:
type: sqlserver
database:
driver: sqlserver
host: sqlserver.example.internal
port: 1433
database: gateway
user: gateway
password: ${DB_PASSWORD}
connect_timeout: 5s
max_open_conns: 25
max_idle_conns: 5
conn_max_lifetime: 30m
conn_max_idle_time: 5m
application_name: gateway-controller
options:
encrypt: disable
trust_server_certificate: "true"
Memory-Only Mode
No persistent storage (useful for testing):
storage:
type: memory
Access Logs
Structured logs for aggregation:
router:
access_logs:
enabled: true
format: json
Example output:
{"start_time":"2025-10-12T15:45:00Z","method":"GET","path":"/weather/US/Seattle","response_code":200,"duration":125}
Text Format
Human-readable format:
router:
access_logs:
enabled: true
format: text
Example output:
[2025-10-12T15:45:00Z] "GET /weather/US/Seattle HTTP/1.1" 200 - 512 1024 125 "10.0.0.1" "curl/7.68.0"
Disable Access Logs
For performance or privacy:
router:
access_logs:
enabled: false
Example Configurations
See config/ directory for examples:
config.yaml - Default production configuration
config-memory-only.yaml - Testing/development configuration
API Reference
The Gateway-Controller exposes two REST API surfaces:
- Management API — for managing APIs, subscriptions, certificates, secrets, LLM proxies, etc.
- Admin API — for operational endpoints such as health and config dump.
Base URLs
Management API: http://localhost:9090/api/management/v0.9
Admin API: http://localhost:9092/api/admin/v0.9
The paths shown in the examples below are relative to these base URLs.
Authentication
The Management API is protected by HTTP basic auth (see authentication.md). Use the
admin credential provisioned by scripts/setup.sh — the username defaults to admin and the password is
the one setup.sh printed. Export them once and pass with curl -u:
export ADMIN_USERNAME=admin
export ADMIN_PASSWORD='<the password scripts/setup.sh printed>'
# then, on each management call:
# curl -u "$ADMIN_USERNAME:$ADMIN_PASSWORD" ...
The Admin API (health, config dump) is reached on its own port and is governed separately.
Endpoints
Health Check (Admin API)
GET /health
# i.e. GET http://localhost:9092/api/admin/v0.9/health
Response:
{
"status": "healthy",
"timestamp": "2025-10-12T15:45:00Z"
}
Create API Configuration
POST /api/management/v0.9/rest-apis
Content-Type: application/yaml
version: api-platform.wso2.com/v1
kind: RestApi
data:
name: Weather API
version: v1.0
context: /weather
upstream:
- url: http://api.weather.com/api/v2
operations:
- method: GET
path: /{country}/{city}
Response (the server echoes back the full k8s-shaped resource with a
server-managed status block):
{
"apiVersion": "gateway.api-platform.wso2.com/v1",
"kind": "RestApi",
"metadata": { "name": "weather-api-v1.0" },
"spec": {
"displayName": "Weather API",
"version": "v1.0",
"context": "/weather",
"upstream": { "main": { "url": "http://api.weather.com/api/v2" } },
"operations": [ { "method": "GET", "path": "/{country}/{city}" } ]
},
"status": {
"id": "weather-api-v1.0",
"state": "deployed",
"createdAt": "2025-10-12T15:45:00Z",
"updatedAt": "2025-10-12T15:45:00Z",
"deployedAt": "2025-10-12T15:45:00Z"
}
}
List All APIs
GET /api/management/v0.9/rest-apis
Get API by Name and Version
GET /api/management/v0.9/rest-apis/{name}/{version}
Example:
GET /api/management/v0.9/rest-apis/Weather%20API/v1.0
Update API
PUT /api/management/v0.9/rest-apis/{name}/{version}
Content-Type: application/yaml
<updated configuration>
Example:
PUT /api/management/v0.9/rest-apis/Weather%20API/v1.0
Content-Type: application/yaml
version: api-platform.wso2.com/v1
kind: RestApi
data:
name: Weather API
version: v1.0
context: /weather
upstream:
- url: http://api.weather.com/api/v3
operations:
- method: GET
path: /{country}/{city}
Delete API
DELETE /api/management/v0.9/rest-apis/{name}/{version}
Example:
DELETE /api/management/v0.9/rest-apis/Weather%20API/v1.0
Data Storage
The Gateway-Controller uses SQLite (embedded relational database) for persistent storage of API configurations.
Database Schema
The SQLite database contains the following table:
deployments - Stores API configurations with full lifecycle metadata
Table Structure
| Column |
Type |
Description |
id |
TEXT (PRIMARY KEY) |
Unique UUID identifier |
name |
TEXT |
API name (indexed for fast lookups) |
version |
TEXT |
API version (indexed for fast lookups) |
context |
TEXT |
Base path (e.g., "/weather") |
kind |
TEXT |
API type ("RestApi", "graphql", etc.) |
configuration |
TEXT |
Full JSON-serialized API configuration |
status |
TEXT |
Deployment status ("pending", "deployed", "failed") |
created_at |
TIMESTAMP |
Record creation timestamp |
updated_at |
TIMESTAMP |
Last modification timestamp |
deployed_at |
TIMESTAMP |
Timestamp of successful deployment (NULL if never deployed) |
deployed_version |
INTEGER |
xDS snapshot version number |
Unique Constraint: (name, version) - prevents duplicate API versions
Database Configuration
SQLite is configured with the following settings for optimal performance:
- Journal Mode: WAL (Write-Ahead Logging) - enables concurrent reads during writes
- Busy Timeout: 5000ms - retries locked database for 5 seconds before failing
- Synchronous: NORMAL - balanced durability (faster than FULL, safer than OFF)
- Cache Size: 2000 pages (~2MB in-memory cache)
- Foreign Keys: ON - enables referential integrity
Database Files
When using SQLite storage, the following files are created in the data directory:
./data/gateway.db - Main database file
./data/gateway.db-wal - Write-Ahead Log (transactions)
./data/gateway.db-shm - Shared memory for WAL
Inspecting the Database
You can inspect the SQLite database using the sqlite3 command-line tool:
# Connect to the database
sqlite3 ./data/gateway.db
# List all API configurations
SELECT name, version, status FROM deployments;
# View specific API configuration (pretty-print JSON)
SELECT json(configuration) FROM deployments WHERE name = 'Weather API';
# Count configurations by status
SELECT status, COUNT(*) FROM deployments GROUP BY status;
# Check database size and schema
.dbinfo
.schema deployments
# Exit
.quit
Database Backup
To backup the SQLite database:
# 1. Checkpoint WAL to main database file
sqlite3 ./data/gateway.db "PRAGMA wal_checkpoint(TRUNCATE);"
# 2. Copy database file
cp ./data/gateway.db ./backups/gateway-$(date +%Y%m%d-%H%M%S).db
# 3. Verify backup integrity
sqlite3 ./backups/gateway-*.db "PRAGMA integrity_check;"
Troubleshooting
Database is Locked Error
If you encounter "database is locked" errors:
-
Check for active connections:
lsof ./data/gateway.db
-
Ensure only one gateway-controller instance is running:
SQLite with a single writer connection is designed for single-instance deployments.
-
Restart the gateway-controller:
The controller will fail-fast on startup if the database is locked, with a clear error message.
-
Force unlock (DANGER: only if no processes are running):
rm ./data/gateway.db-wal ./data/gateway.db-shm
sqlite3 ./data/gateway.db "PRAGMA integrity_check;"
Empty Database on Startup
The gateway-controller automatically creates the database schema if it doesn't exist. If you see "schema initialization" in the logs, this is normal behavior on first startup.
SQLite is optimized for up to 100+ API configurations. If you experience performance issues with larger datasets, consider:
-
Check index usage:
sqlite3 ./data/gateway.db
EXPLAIN QUERY PLAN SELECT * FROM deployments WHERE name = 'MyAPI' AND version = 'v1';
-
Verify WAL mode is enabled:
sqlite3 ./data/gateway.db "PRAGMA journal_mode;"
# Should return: wal
-
For very large deployments (1000+ configurations), consider migrating to PostgreSQL (future support planned)
xDS Protocol
The Gateway-Controller implements Envoy's State-of-the-World (SotW) xDS protocol:
- Router connects to Gateway-Controller on port 18000
- Gateway-Controller generates complete xDS snapshot from in-memory configurations
- On configuration change, new snapshot is created and pushed to Router
- Router applies configuration gracefully (in-flight requests complete)
Development
Project Structure
gateway-controller/
├── cmd/
│ └── controller/
│ └── main.go # Entry point
├── pkg/
│ ├── api/
│ │ ├── generated/ # Generated from OpenAPI spec
│ │ ├── handlers/ # REST API handlers
│ │ └── middleware/ # Logging, error handling
│ ├── config/
│ │ ├── config.go # Configuration loader (Koanf)
│ │ ├── parser.go # YAML/JSON parsing
│ │ └── validator.go # Configuration validation
│ ├── models/
│ │ └── stored_config.go # Data structures
│ ├── storage/
│ │ ├── interface.go # Storage abstraction
│ │ ├── memory.go # In-memory cache
│ │ └── sqlite.go # SQLite implementation
│ ├── xds/
│ │ ├── server.go # xDS gRPC server
│ │ ├── snapshot.go # Snapshot manager
│ │ └── translator.go # Config → Envoy translation
│ └── logger/
│ └── logger.go # Zap logger setup
├── api/
│ ├── management-openapi.yaml # OpenAPI 3.0 spec — management API (deploy/manage APIs)
│ └── admin-openapi.yaml # OpenAPI 3.0 spec — admin API (health/debug/xDS status)
├── config/
│ ├── config.yaml # Default configuration
│ └── config-memory-only.yaml # Memory-only example
├── oapi-codegen.yaml # Code generation config
├── Dockerfile
└── Makefile
Code Generation
The REST API is generated from the OpenAPI specification using oapi-codegen:
make generate
This creates pkg/api/generated/generated.go with:
ServerInterface - Handler interface
- Request/response types
- Route registration
API Documentation Generation
The REST API reference docs under docs/rest-apis/gateway/ are generated from api/management-openapi.yaml:
make generate-apidocs
By default, request/response samples in the generated docs are auto-generated from the OpenAPI schemas. If an auto-generated sample is invalid or unclear, add an explicit example to the relevant component inside the components/schemas section of api/management-openapi.yaml.
For example, the RestAPI schema includes an explicit example that shows a realistic Petstore API configuration:
components:
schemas:
RestAPI:
type: object
required:
- apiVersion
- metadata
- kind
- spec
properties:
apiVersion:
type: string
example: gateway.api-platform.wso2.com/v1
kind:
type: string
example: RestApi
metadata:
$ref: "#/components/schemas/Metadata"
spec:
$ref: "#/components/schemas/APIConfigData"
example:
apiVersion: gateway.api-platform.wso2.com/v1
kind: RestApi
metadata:
name: petstore-api-v1.0
spec:
displayName: Petstore-API
version: v1.0
context: /petstore/$version
upstream:
main:
url: https://petstore3.swagger.io/api/v3
operations:
- method: GET
path: /pet/findByStatus
- method: GET
path: /pet/{petId}
- method: DELETE
path: /pet/{petId}
The example field at the schema level overrides auto-generation for that component. Add one per component where the auto-generated sample is inaccurate or incomplete. After editing the spec, re-run make generate-apidocs to regenerate the docs.
Logging
The Gateway-Controller uses structured logging (Zap) with configurable levels.
Debug Mode
Set controller.logging.level = "debug" in your config file directly, or — because the shipped
config.toml reads the level from an interpolation token — export the variable that token
references (setting the env var alone does nothing unless the config has the matching token):
# config.toml contains: level = '{{ env "APIP_GW_CONTROLLER_LOGGING_LEVEL" "info" }}'
export APIP_GW_CONTROLLER_LOGGING_LEVEL=debug
./bin/controller --config configs/config.toml
Debug logs include:
- Complete API configuration payloads
- xDS snapshot details
- Configuration diffs for updates
- Configuration validation: < 1 second
- xDS update push: < 5 seconds
- Supports 100+ API configurations
- Thread-safe concurrent operations