diff --git a/.github/workflows/gateway-integration-test-sqlserver.yml b/.github/workflows/gateway-integration-test-sqlserver.yml
new file mode 100644
index 000000000..de84cdb1b
--- /dev/null
+++ b/.github/workflows/gateway-integration-test-sqlserver.yml
@@ -0,0 +1,147 @@
+name: Gateway Integration Test (SQL Server)
+
+on:
+ workflow_dispatch:
+ pull_request:
+ branches:
+ - main
+ paths:
+ - 'gateway/**'
+ - 'common/**'
+ - 'tests/mock-servers/mock-platform-api/**'
+ - '.github/workflows/gateway-integration-test-sqlserver.yml'
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
+ cancel-in-progress: true
+
+jobs:
+ integration-test:
+ runs-on: ubuntu-24.04
+ env:
+ # Used for both Compose interpolation and the runner-side sqlcmd checks below.
+ MSSQL_SA_PASSWORD: Gateway_Strong!Pass123
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
+ with:
+ persist-credentials: false
+
+ - name: Set up Go
+ uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0
+ with:
+ go-version: '1.26.2'
+ cache-dependency-path: '**/go.sum'
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
+
+ - name: Build coverage-instrumented images
+ run: |
+ cd gateway
+ make build-coverage VERSION=test
+
+ - name: Build mock server images
+ run: |
+ for mock in mock-jwks mock-azure-content-safety mock-aws-bedrock-guardrail mock-embedding-provider mock-analytics-collector; do
+ echo "Building $mock..."
+ docker build -t ghcr.io/wso2/api-platform/$mock:latest tests/mock-servers/$mock
+ done
+
+ - name: Build sample-service
+ run: |
+ cd samples/sample-service
+ make build
+
+ - name: Verify gateway-controller uses SQL Server
+ run: |
+ set -euo pipefail
+ cd gateway/it
+
+ PROJECT="gateway-it-sqlserver-verify"
+ cleanup() {
+ docker compose -p "$PROJECT" -f docker-compose.test.sqlserver.yaml down -v --remove-orphans || true
+ }
+ trap cleanup EXIT
+
+ docker compose -p "$PROJECT" -f docker-compose.test.sqlserver.yaml up -d sqlserver mssql-init gateway-controller
+
+ timeout 120 bash -c 'until curl -fsS http://localhost:9092/api/admin/v0.9/health >/dev/null 2>&1; do sleep 2; done' || {
+ echo "=== Health check timed out — gateway-controller logs ==="
+ docker logs it-gateway-controller 2>&1 || true
+ exit 1
+ }
+
+ # Dump controller logs on any subsequent failure so the cause is never hidden
+ # by the cleanup trap (otherwise an assertion failure tears everything down silently).
+ dump_logs() {
+ echo "=== gateway-controller logs ==="
+ docker logs it-gateway-controller 2>&1 || true
+ }
+ trap 'dump_logs; cleanup' EXIT
+
+ docker logs it-gateway-controller > /tmp/gateway-controller.log 2>&1 || true
+ grep -Eq "Initializing SQLServer schema|SQLServer schema initialized" /tmp/gateway-controller.log
+
+ table_count="$(docker compose -p "$PROJECT" -f docker-compose.test.sqlserver.yaml exec -T sqlserver \
+ /opt/mssql-tools18/bin/sqlcmd -C -S localhost -U sa -P "$MSSQL_SA_PASSWORD" -d gateway_test -h -1 -W \
+ -Q "SET NOCOUNT ON; SELECT COUNT(*) FROM information_schema.tables WHERE table_name = 'artifacts'" | tr -d '[:space:]')"
+ echo "artifacts table_count=$table_count"
+ [ "$table_count" = "1" ]
+
+ # The controller's DSN sets 'app name=gateway-controller', so its sessions are
+ # tagged accordingly in sys.dm_exec_sessions (SQL Server's analog of pg_stat_activity).
+ conn_count="$(docker compose -p "$PROJECT" -f docker-compose.test.sqlserver.yaml exec -T sqlserver \
+ /opt/mssql-tools18/bin/sqlcmd -C -S localhost -U sa -P "$MSSQL_SA_PASSWORD" -d gateway_test -h -1 -W \
+ -Q "SET NOCOUNT ON; SELECT COUNT(*) FROM sys.dm_exec_sessions WHERE program_name = 'gateway-controller'" | tr -d '[:space:]')"
+ echo "gateway-controller conn_count=$conn_count"
+ [ "${conn_count:-0}" -ge 1 ]
+
+ - name: Run integration tests
+ run: |
+ cd gateway
+ COMPOSE_FILE=docker-compose.test.sqlserver.yaml make test-integration
+
+ - name: Upload coverage report
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
+ if: always()
+ with:
+ name: coverage-report-sqlserver
+ path: gateway/it/coverage/output
+ retention-days: 7
+
+ - name: Upload test reports
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
+ if: always()
+ with:
+ name: test-reports-sqlserver
+ path: gateway/it/reports/
+ retention-days: 7
+
+ - name: Debug on failure - Dump logs
+ if: failure()
+ run: |
+ echo "=== Docker Containers ==="
+ docker ps -a
+
+ for ctr in it-gateway-controller it-gateway-runtime it-sqlserver it-mock-platform-api; do
+ echo ""
+ echo "=== Docker logs: $ctr ==="
+ docker logs "$ctr" 2>&1 || echo "(container $ctr not found)"
+ done
+
+ echo ""
+ echo "=== gateway/it/logs Directory Contents ==="
+ if [ -d gateway/it/logs ]; then
+ if [ "$(ls -A gateway/it/logs)" ]; then
+ for f in gateway/it/logs/*; do
+ echo ""
+ echo "--- Contents of $f ---"
+ cat "$f"
+ done
+ else
+ echo "No log files found in gateway/it/logs."
+ fi
+ else
+ echo "Directory gateway/it/logs does not exist."
+ fi
diff --git a/.github/workflows/operator-integration-test.yml b/.github/workflows/operator-integration-test.yml
index 163ea182d..96be8fb80 100644
--- a/.github/workflows/operator-integration-test.yml
+++ b/.github/workflows/operator-integration-test.yml
@@ -323,9 +323,10 @@ jobs:
# Storage configuration
storage:
- # Storage type: "sqlite", "postgres" (future), or "memory"
+ # Storage type: "sqlite", "postgres", "sqlserver", or "memory"
# - sqlite: Use SQLite embedded database for persistence
- # - postgres: Use PostgreSQL database for persistence (future support)
+ # - postgres: Use PostgreSQL database for persistence
+ # - sqlserver: Use SQL Server database for persistence
# - memory: No persistent storage, all configs lost on restart (useful for testing)
type: sqlite
diff --git a/.github/workflows/platform-api-gateway-e2e.yml b/.github/workflows/platform-api-gateway-e2e.yml
new file mode 100644
index 000000000..ad10f73db
--- /dev/null
+++ b/.github/workflows/platform-api-gateway-e2e.yml
@@ -0,0 +1,61 @@
+name: Platform API + Gateway E2E
+
+# Combined live-traffic end-to-end test: the real platform-api control plane
+# deploys an API to the real gateway data plane, and a request through the
+# gateway ingress must reach the sample backend — exercised on every database.
+#
+# This builds the gateway (Envoy) images, so it is intentionally not on the
+# per-PR critical path: it runs on demand and when the e2e itself changes.
+on:
+ workflow_dispatch:
+ pull_request:
+ branches:
+ - main
+ paths:
+ - 'tests/integration-e2e/**'
+ - '.github/workflows/platform-api-gateway-e2e.yml'
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
+ cancel-in-progress: true
+
+jobs:
+ e2e:
+ runs-on: ubuntu-24.04
+ strategy:
+ fail-fast: false
+ matrix:
+ db: [sqlite, postgres, sqlserver]
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
+ with:
+ persist-credentials: false
+
+ - name: Set up Go
+ uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0
+ with:
+ go-version: '1.26.2'
+ cache-dependency-path: '**/go.sum'
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
+
+ - name: Build platform-api image
+ run: docker build -t platform-api:it-e2e --build-context common=../common .
+ working-directory: platform-api
+
+ - name: Build gateway images (controller + runtime)
+ run: make build VERSION=it-e2e
+ working-directory: gateway
+
+ - name: Build sample-service image
+ run: make build
+ working-directory: samples/sample-service
+
+ - name: Run combined e2e (${{ matrix.db }})
+ run: E2E_DB=${{ matrix.db }} go test -run TestFeatures -count=1 -v -timeout 25m ./...
+ working-directory: tests/integration-e2e
diff --git a/.github/workflows/platform-api-pr-check.yml b/.github/workflows/platform-api-pr-check.yml
index af9a703f0..95a888b2a 100644
--- a/.github/workflows/platform-api-pr-check.yml
+++ b/.github/workflows/platform-api-pr-check.yml
@@ -13,15 +13,20 @@ concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: true
+permissions:
+ contents: read
+
jobs:
pr-check:
runs-on: ubuntu-24.04
steps:
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
+ with:
+ persist-credentials: false
- name: Set up Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0
with:
go-version: '1.26.2'
cache-dependency-path: '**/go.sum'
@@ -33,3 +38,94 @@ jobs:
- name: Run tests
run: make test
working-directory: platform-api
+
+ - name: Cross-database integration tests (SQLite)
+ run: IT_DB=sqlite go test -tags integration -count=1 ./internal/integration/...
+ working-directory: platform-api/src
+
+ pr-check-postgres:
+ runs-on: ubuntu-24.04
+ services:
+ postgres:
+ image: postgres:16
+ env:
+ POSTGRES_USER: postgres
+ POSTGRES_PASSWORD: postgres
+ POSTGRES_DB: platform_api_it
+ ports:
+ - 5432:5432
+ options: >-
+ --health-cmd "pg_isready -U postgres -d platform_api_it"
+ --health-interval 2s --health-timeout 3s --health-retries 30
+ env:
+ IT_DB: postgres
+ IT_DB_HOST: localhost
+ IT_DB_PORT: 5432
+ IT_DB_USER: postgres
+ IT_DB_PASSWORD: postgres
+ IT_DB_NAME: platform_api_it
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
+ with:
+ persist-credentials: false
+
+ - name: Set up Go
+ uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0
+ with:
+ go-version: '1.26.2'
+ cache-dependency-path: '**/go.sum'
+
+ - name: Cross-database integration tests (PostgreSQL)
+ run: go test -tags integration -count=1 -v ./internal/integration/...
+ working-directory: platform-api/src
+
+ pr-check-sqlserver:
+ runs-on: ubuntu-24.04
+ env:
+ IT_DB: sqlserver
+ IT_DB_HOST: 127.0.0.1
+ IT_DB_PORT: 1433
+ IT_DB_USER: sa
+ IT_DB_NAME: platform_api_it
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
+ with:
+ persist-credentials: false
+
+ - name: Set up Go
+ uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0
+ with:
+ go-version: '1.26.2'
+ cache-dependency-path: '**/go.sum'
+
+ - name: Start SQL Server
+ run: |
+ set -euo pipefail
+
+ # Generate the SA password at runtime instead of using a committed
+ # literal or a stored secret. A runtime value avoids secret-scanner
+ # false positives and secret-handling pitfalls (e.g. a stray trailing
+ # newline) that silently fail SQL Server's password policy at init.
+ SA_PASSWORD="Aa1$(openssl rand -hex 16)"
+ echo "::add-mask::$SA_PASSWORD"
+ echo "IT_DB_PASSWORD=$SA_PASSWORD" >> "$GITHUB_ENV"
+
+ docker run -d --name sqlserver \
+ -e ACCEPT_EULA=Y \
+ -e MSSQL_PID=Developer \
+ -e MSSQL_SA_PASSWORD="$SA_PASSWORD" \
+ -p 1433:1433 \
+ mcr.microsoft.com/mssql/server:2022-latest
+
+ - name: Build with SQL Server configuration
+ run: go build ./cmd/main.go
+ working-directory: platform-api/src
+
+ # The harness waits for the server and creates the test database itself,
+ # then exercises the real schema, pagination and delete cascades against
+ # SQL Server (the previous `make test` step here only ran SQLite).
+ - name: Cross-database integration tests (SQL Server)
+ run: go test -tags integration -count=1 -v ./internal/integration/...
+ working-directory: platform-api/src
diff --git a/common/eventhub/sqlbackend.go b/common/eventhub/sqlbackend.go
index c23eaef79..5c621d0c4 100644
--- a/common/eventhub/sqlbackend.go
+++ b/common/eventhub/sqlbackend.go
@@ -30,6 +30,7 @@ import (
"github.com/google/uuid"
"github.com/jackc/pgx/v5/stdlib"
"github.com/jmoiron/sqlx"
+ mssql "github.com/microsoft/go-mssqldb"
)
const (
@@ -107,6 +108,8 @@ func bindTypeForDB(db *sql.DB) int {
switch db.Driver().(type) {
case *stdlib.Driver:
return sqlx.DOLLAR
+ case *mssql.Driver:
+ return sqlx.AT
default:
return sqlx.QUESTION
}
@@ -116,6 +119,30 @@ func (b *SQLBackend) rebind(query string) string {
return sqlx.Rebind(b.bindType, query)
}
+// limitClause returns a portable row-limit clause. SQL Server does not support
+// LIMIT and instead uses ANSI OFFSET/FETCH (which requires an ORDER BY — every
+// call site here is already ordered). The single `?` is rebound like any other.
+func (b *SQLBackend) limitClause() string {
+ if b.bindType == sqlx.AT {
+ return "OFFSET 0 ROWS FETCH NEXT ? ROWS ONLY"
+ }
+ return "LIMIT ?"
+}
+
+// ensureGatewayInsertSQL returns a portable "insert if absent" for gateway_states.
+// SQL Server has no INSERT ... ON CONFLICT; it uses a guarded IF NOT EXISTS with a
+// key-range lock (UPDLOCK, SERIALIZABLE) to stay race-safe under concurrent
+// publishes. Both forms take the gateway id as a single bind parameter — the
+// SQL Server form references @p1 twice (one argument), and is left untouched by
+// rebind (it contains no `?`).
+func (b *SQLBackend) ensureGatewayInsertSQL() string {
+ if b.bindType == sqlx.AT {
+ return `IF NOT EXISTS (SELECT 1 FROM gateway_states WITH (UPDLOCK, SERIALIZABLE) WHERE gateway_id = @p1)
+ INSERT INTO gateway_states (gateway_id, version_id) VALUES (@p1, '')`
+ }
+ return `INSERT INTO gateway_states (gateway_id, version_id) VALUES (?, '') ON CONFLICT (gateway_id) DO NOTHING`
+}
+
// Initialize prepares statements and starts background goroutines
func (b *SQLBackend) Initialize() error {
if err := b.prepareStatements(); err != nil {
@@ -181,12 +208,10 @@ func (b *SQLBackend) prepareStatements() (err error) {
return fmt.Errorf("failed to prepare update gateway version statement: %w", err)
}
- // Idempotent upsert — creates the gateway_states row when publishing for a gateway
+ // Idempotent insert — creates the gateway_states row when publishing for a gateway
// that has not yet established a WebSocket connection (and therefore has not been
- // explicitly registered). ON CONFLICT DO NOTHING leaves an existing row unchanged.
- b.ensureGatewayStmt, err = b.db.Prepare(b.rebind(`
- INSERT INTO gateway_states (gateway_id, version_id) VALUES (?, '') ON CONFLICT (gateway_id) DO NOTHING
- `))
+ // explicitly registered). Leaves an existing row unchanged.
+ b.ensureGatewayStmt, err = b.db.Prepare(b.rebind(b.ensureGatewayInsertSQL()))
if err != nil {
return fmt.Errorf("failed to prepare ensure gateway statement: %w", err)
}
@@ -203,7 +228,7 @@ func (b *SQLBackend) prepareStatements() (err error) {
FROM gateway_states
WHERE gateway_id > ?
ORDER BY gateway_id ASC
- LIMIT ?
+ ` + b.limitClause() + `
`))
if err != nil {
return fmt.Errorf("failed to prepare get gateway states page statement: %w", err)
@@ -220,7 +245,7 @@ func (b *SQLBackend) prepareStatements() (err error) {
}
b.getEventByIDStmt, err = b.db.Prepare(b.rebind(`
- SELECT event_id FROM events WHERE event_id = ?
+ SELECT event_id FROM events WHERE gateway_id = ? AND event_id = ?
`))
if err != nil {
return fmt.Errorf("failed to prepare get event by ID statement: %w", err)
@@ -324,7 +349,7 @@ func (b *SQLBackend) Publish(gatewayID string, event Event) error {
}
err = nil
- eventExists, checkErr := b.eventExists(eventID)
+ eventExists, checkErr := b.eventExists(gatewayID, eventID)
if checkErr != nil {
return fmt.Errorf("failed to check event existence after insert failure: %w", checkErr)
}
@@ -360,9 +385,9 @@ func (b *SQLBackend) Publish(gatewayID string, event Event) error {
return nil
}
-func (b *SQLBackend) eventExists(eventID string) (bool, error) {
+func (b *SQLBackend) eventExists(gatewayID, eventID string) (bool, error) {
var existingEventID string
- err := b.getEventByIDStmt.QueryRow(eventID).Scan(&existingEventID)
+ err := b.getEventByIDStmt.QueryRow(gatewayID, eventID).Scan(&existingEventID)
if err == nil {
return true, nil
}
diff --git a/common/eventhub/sqlserver_publish_test.go b/common/eventhub/sqlserver_publish_test.go
new file mode 100644
index 000000000..f5f87707b
--- /dev/null
+++ b/common/eventhub/sqlserver_publish_test.go
@@ -0,0 +1,168 @@
+/*
+ * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com).
+ *
+ * WSO2 LLC. licenses this file to you under the Apache License,
+ * Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package eventhub
+
+import (
+ "database/sql"
+ "fmt"
+ "os"
+ "testing"
+ "time"
+
+ _ "github.com/microsoft/go-mssqldb"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// setupSQLServerHub stands up an EventHub against a live SQL Server using the
+// PRODUCTION schema (composite PRIMARY KEY (gateway_id, event_id)). The SQLite
+// unit-test schema uses a single-column event_id PK, which globally enforces
+// event_id uniqueness and therefore hides the gateway-scoping behaviour of the
+// duplicate check. This test exercises the real shape.
+func setupSQLServerHub(t *testing.T) (*sql.DB, EventHub) {
+ t.Helper()
+
+ dsn := os.Getenv("SQLSERVER_TEST_DSN")
+ if dsn == "" {
+ t.Skip("SQLSERVER_TEST_DSN is not set; skipping sqlserver eventhub tests")
+ }
+
+ db, err := sql.Open("sqlserver", dsn)
+ require.NoError(t, err)
+
+ // Production-shaped schema: composite PK so a given event_id is unique only
+ // within a gateway, and an FK so an event for an unregistered gateway fails.
+ _, err = db.Exec(`
+ IF OBJECT_ID(N'dbo.gateway_states', N'U') IS NULL
+ CREATE TABLE dbo.gateway_states (
+ gateway_id NVARCHAR(64) NOT NULL PRIMARY KEY,
+ version_id NVARCHAR(64) NOT NULL DEFAULT '',
+ updated_at DATETIME2(7) NOT NULL DEFAULT SYSUTCDATETIME()
+ );`)
+ require.NoError(t, err)
+ _, err = db.Exec(`
+ IF OBJECT_ID(N'dbo.events', N'U') IS NULL
+ CREATE TABLE dbo.events (
+ gateway_id NVARCHAR(64) NOT NULL,
+ processed_timestamp DATETIME2(7) NOT NULL DEFAULT SYSUTCDATETIME(),
+ originated_timestamp DATETIME2(7) NOT NULL,
+ entity_type NVARCHAR(64) NOT NULL,
+ action NVARCHAR(20) NOT NULL CHECK(action IN ('CREATE', 'UPDATE', 'DELETE')),
+ entity_id NVARCHAR(255) NOT NULL,
+ event_id NVARCHAR(64) NOT NULL,
+ event_data NVARCHAR(MAX) NOT NULL,
+ PRIMARY KEY (gateway_id, event_id),
+ FOREIGN KEY (gateway_id) REFERENCES dbo.gateway_states(gateway_id) ON DELETE CASCADE
+ );`)
+ require.NoError(t, err)
+
+ hub := New(db, testLogger(), Config{
+ PollInterval: time.Hour, // we drive Publish directly; no polling needed
+ CleanupInterval: time.Hour,
+ RetentionPeriod: time.Hour,
+ })
+ require.NoError(t, hub.Initialize())
+
+ t.Cleanup(func() {
+ hub.Close()
+ db.Close()
+ })
+ return db, hub
+}
+
+// TestSQLServerPublish_DuplicateCheckIsGatewayScoped verifies the duplicate
+// check is gateway-scoped: the SAME event_id published to two DIFFERENT gateways
+// must be stored independently for each, never suppressed as a cross-gateway
+// "duplicate".
+//
+// PublishEvent ensures the gateway_states row exists before the FK-constrained
+// event insert, so publishing to a not-yet-connected gateway succeeds (it is
+// auto-registered) rather than failing. The composite (gateway_id, event_id) key
+// then lets the same event_id coexist for both gateways. A non-gateway-scoped
+// duplicate check would have wrongly seen A's row and skipped B's insert.
+func TestSQLServerPublish_DuplicateCheckIsGatewayScoped(t *testing.T) {
+ db, hub := setupSQLServerHub(t)
+
+ suffix := fmt.Sprintf("%d", time.Now().UnixNano())
+ gwA := "gw-a-" + suffix
+ gwB := "gw-b-" + suffix // not explicitly registered; auto-created on publish
+ sharedEventID := "shared-evt-" + suffix
+
+ require.NoError(t, hub.RegisterGateway(gwA))
+ t.Cleanup(func() {
+ _, _ = db.Exec("DELETE FROM gateway_states WHERE gateway_id = @p1", gwA)
+ _, _ = db.Exec("DELETE FROM gateway_states WHERE gateway_id = @p1", gwB)
+ })
+
+ evt := Event{
+ EventType: EventTypeAPI,
+ Action: "CREATE",
+ EntityID: "entity-1",
+ EventID: sharedEventID,
+ OriginatedTimestamp: time.Now(),
+ EventData: EmptyEventData,
+ }
+
+ // Publish to the registered gateway A — must succeed.
+ require.NoError(t, hub.PublishEvent(gwA, evt))
+
+ // Publish the SAME event_id to gateway B. B is auto-registered, and because
+ // the duplicate check is gateway-scoped it must NOT treat A's row as B's
+ // duplicate — so this succeeds and stores a distinct row for B.
+ require.NoError(t, hub.PublishEvent(gwB, evt),
+ "publish of a shared event_id to a different gateway must succeed, not be suppressed as a duplicate")
+
+ // Both gateways must independently hold the event.
+ var countA, countB int
+ require.NoError(t, db.QueryRow("SELECT COUNT(*) FROM events WHERE gateway_id = @p1 AND event_id = @p2", gwA, sharedEventID).Scan(&countA))
+ require.NoError(t, db.QueryRow("SELECT COUNT(*) FROM events WHERE gateway_id = @p1 AND event_id = @p2", gwB, sharedEventID).Scan(&countB))
+ assert.Equal(t, 1, countA, "gateway A event should be persisted")
+ assert.Equal(t, 1, countB, "gateway B event should be persisted independently of A")
+}
+
+// TestSQLServerPublish_TrueDuplicateStillSuppressed verifies the fix does not
+// regress legitimate same-gateway de-duplication: re-publishing the same
+// (gateway_id, event_id) is still treated as a duplicate and returns nil.
+func TestSQLServerPublish_TrueDuplicateStillSuppressed(t *testing.T) {
+ db, hub := setupSQLServerHub(t)
+
+ suffix := fmt.Sprintf("%d", time.Now().UnixNano())
+ gw := "gw-dup-" + suffix
+ eventID := "dup-evt-" + suffix
+
+ require.NoError(t, hub.RegisterGateway(gw))
+ t.Cleanup(func() { _, _ = db.Exec("DELETE FROM gateway_states WHERE gateway_id = @p1", gw) })
+
+ evt := Event{
+ EventType: EventTypeAPI,
+ Action: "CREATE",
+ EntityID: "entity-1",
+ EventID: eventID,
+ OriginatedTimestamp: time.Now(),
+ EventData: EmptyEventData,
+ }
+
+ require.NoError(t, hub.PublishEvent(gw, evt))
+ // Same gateway + same event_id again → genuine duplicate → suppressed (nil).
+ assert.NoError(t, hub.PublishEvent(gw, evt))
+
+ var count int
+ require.NoError(t, db.QueryRow("SELECT COUNT(*) FROM events WHERE gateway_id = @p1 AND event_id = @p2", gw, eventID).Scan(&count))
+ assert.Equal(t, 1, count, "duplicate publish must not create a second row")
+}
diff --git a/common/go.mod b/common/go.mod
index d863e58c9..37ee50001 100644
--- a/common/go.mod
+++ b/common/go.mod
@@ -12,7 +12,7 @@ require (
github.com/jmoiron/sqlx v1.4.0
github.com/mattn/go-sqlite3 v1.14.41
github.com/stretchr/testify v1.11.1
- golang.org/x/crypto v0.47.0
+ golang.org/x/crypto v0.50.0
)
require (
@@ -28,6 +28,8 @@ require (
github.com/go-playground/validator/v10 v10.29.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.1 // indirect
+ github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
+ github.com/golang-sql/sqlexp v0.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
@@ -36,6 +38,7 @@ require (
github.com/kr/text v0.2.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/microsoft/go-mssqldb v1.10.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
@@ -43,14 +46,15 @@ require (
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.57.1 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
+ github.com/shopspring/decimal v1.4.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
go.uber.org/mock v0.6.0 // indirect
golang.org/x/arch v0.23.0 // indirect
- golang.org/x/net v0.49.0 // indirect
- golang.org/x/sync v0.19.0 // indirect
- golang.org/x/sys v0.40.0 // indirect
- golang.org/x/text v0.33.0 // indirect
+ golang.org/x/net v0.53.0 // indirect
+ golang.org/x/sync v0.20.0 // indirect
+ golang.org/x/sys v0.43.0 // indirect
+ golang.org/x/text v0.36.0 // indirect
golang.org/x/time v0.12.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
diff --git a/common/go.sum b/common/go.sum
index 5cddad694..ee7b75104 100644
--- a/common/go.sum
+++ b/common/go.sum
@@ -39,6 +39,10 @@ github.com/goccy/go-yaml v1.19.1 h1:3rG3+v8pkhRqoQ/88NYNMHYVGYztCOCIZ7UQhu7H+NE=
github.com/goccy/go-yaml v1.19.1/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
+github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
+github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
+github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
+github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
@@ -71,6 +75,8 @@ github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mattn/go-sqlite3 v1.14.41 h1:8p7Pwz5NHkEbWSqc/ygU4CBGubhFFkpgP9KwcdkAHNA=
github.com/mattn/go-sqlite3 v1.14.41/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
+github.com/microsoft/go-mssqldb v1.10.0 h1:pHEt+Qz6YFPWqREq10mqSE524QQo+/QremwTCQht7TY=
+github.com/microsoft/go-mssqldb v1.10.0/go.mod h1:mnG7lGa9iYJbzJqGCXyuQCegStKMr3kogDLD6+bmggg=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -87,6 +93,8 @@ github.com/quic-go/quic-go v0.57.1 h1:25KAAR9QR8KZrCZRThWMKVAwGoiHIrNbT72ULHTuI1
github.com/quic-go/quic-go v0.57.1/go.mod h1:ly4QBAjHA2VhdnxhojRsCUOeJwKYg+taDlos92xb1+s=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
+github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
+github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@@ -109,15 +117,25 @@ golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
+golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
+golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
+golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
+golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
+golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
+golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
+golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
+golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
+golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
diff --git a/gateway/Makefile b/gateway/Makefile
index 714dc6005..624ffff8a 100644
--- a/gateway/Makefile
+++ b/gateway/Makefile
@@ -265,6 +265,44 @@ clean-db: ## Remove local dev SQLite database files
rm -f gateway-controller/data/gateway.db gateway-controller/data/gateway.db-shm gateway-controller/data/gateway.db-wal
@echo "Cleaned local dev database files"
+# Docker Compose Targets (PostgreSQL storage overlay)
+COMPOSE_POSTGRES := docker compose -f docker-compose.yaml -f docker-compose.postgres.yaml
+
+.PHONY: up-postgres
+up-postgres: ## Start the gateway stack with PostgreSQL storage (overlay)
+ $(COMPOSE_POSTGRES) up -d
+
+.PHONY: logs-postgres
+logs-postgres: ## Tail gateway-controller logs (PostgreSQL stack)
+ $(COMPOSE_POSTGRES) logs -f gateway-controller
+
+.PHONY: down-postgres
+down-postgres: ## Stop the PostgreSQL gateway stack (keeps volumes)
+ $(COMPOSE_POSTGRES) down
+
+.PHONY: down-postgres-clean
+down-postgres-clean: ## Stop the PostgreSQL gateway stack and remove volumes
+ $(COMPOSE_POSTGRES) down -v
+
+# Docker Compose Targets (SQL Server storage overlay)
+COMPOSE_SQLSERVER := docker compose -f docker-compose.yaml -f docker-compose.sqlserver.yaml
+
+.PHONY: up-sqlserver
+up-sqlserver: ## Start the gateway stack with SQL Server storage (overlay)
+ $(COMPOSE_SQLSERVER) up -d
+
+.PHONY: logs-sqlserver
+logs-sqlserver: ## Tail gateway-controller logs (SQL Server stack)
+ $(COMPOSE_SQLSERVER) logs -f gateway-controller
+
+.PHONY: down-sqlserver
+down-sqlserver: ## Stop the SQL Server gateway stack (keeps volumes)
+ $(COMPOSE_SQLSERVER) down
+
+.PHONY: down-sqlserver-clean
+down-sqlserver-clean: ## Stop the SQL Server gateway stack and remove volumes
+ $(COMPOSE_SQLSERVER) down -v
+
.PHONY: clean
clean: clean-dist ## Clean all build artifacts
@rm -rf target/build
diff --git a/gateway/README.md b/gateway/README.md
index f9bcfc653..7b8904811 100644
--- a/gateway/README.md
+++ b/gateway/README.md
@@ -72,8 +72,10 @@ Environment variables use `APIP_GW_` prefix:
| Variable | Description |
|----------|-------------|
-| `APIP_GW_CONTROLLER_STORAGE_TYPE` | `sqlite` or `memory` |
+| `APIP_GW_CONTROLLER_STORAGE_TYPE` | `sqlite`, `postgres`, `sqlserver`, or `memory` |
| `APIP_GW_CONTROLLER_STORAGE_SQLITE_PATH` | Path to SQLite database |
+| `APIP_GW_CONTROLLER_STORAGE_POSTGRES_DSN` | PostgreSQL DSN (when storage type is `postgres`) |
+| `APIP_GW_CONTROLLER_STORAGE_DATABASE_DSN` | SQL Server DSN (when storage type is `sqlserver`) |
| `APIP_GW_CONTROLLER_LOGGING_LEVEL` | `debug`, `info`, `warn`, `error` |
| `APIP_GW_POLICY__ENGINE_METRICS_PORT` | Policy engine metrics port |
diff --git a/gateway/configs/config-template.toml b/gateway/configs/config-template.toml
index a8834832a..d11d5ebb6 100644
--- a/gateway/configs/config-template.toml
+++ b/gateway/configs/config-template.toml
@@ -68,6 +68,29 @@ conn_max_lifetime = "30m"
conn_max_idle_time = "5m"
application_name = "gateway-controller"
+[controller.storage.database]
+# Global DB configuration used by sqlserver (and future SQL backends).
+# For sqlite and postgres, legacy [controller.storage.sqlite]/[controller.storage.postgres]
+# sections are still supported for backward compatibility.
+driver = "sqlserver"
+# Optional DSN. If set, host/port/database/user/password are ignored.
+# dsn = "sqlserver://user:password@localhost:1433?database=gateway&encrypt=true"
+host = "localhost"
+port = 1433
+database = "gateway"
+user = "gateway"
+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"
+
+[controller.storage.database.options]
+encrypt = "true" # disable, false, true, strict
+trust_server_certificate = "false"
+
[controller.policies]
# Directory containing policy definitions
definitions_path = "./default-policies"
diff --git a/gateway/docker-compose.sqlserver.yaml b/gateway/docker-compose.sqlserver.yaml
new file mode 100644
index 000000000..33f92fc40
--- /dev/null
+++ b/gateway/docker-compose.sqlserver.yaml
@@ -0,0 +1,68 @@
+# --------------------------------------------------------------------
+# Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com).
+#
+# WSO2 LLC. licenses this file to you under the Apache License,
+# Version 2.0 (the "License"); you may not use this file except
+# in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+# --------------------------------------------------------------------
+
+# Overlay for gateway/docker-compose.yaml that swaps controller storage to SQL Server.
+# Usage:
+# make up-sqlserver
+# make logs-sqlserver
+# make down-sqlserver
+
+services:
+ sqlserver:
+ image: mcr.microsoft.com/mssql/server:2022-latest
+ environment:
+ - ACCEPT_EULA=Y
+ - MSSQL_PID=Developer
+ - MSSQL_SA_PASSWORD=${MSSQL_SA_PASSWORD:?Set MSSQL_SA_PASSWORD to a strong value}
+ ports:
+ - "1433:1433"
+ healthcheck:
+ test: ["CMD-SHELL", "/opt/mssql-tools18/bin/sqlcmd -C -S localhost -U sa -P \"$$MSSQL_SA_PASSWORD\" -Q 'SELECT 1' || exit 1"]
+ interval: 5s
+ timeout: 3s
+ retries: 20
+ start_period: 20s
+ networks:
+ - gateway-network
+
+ mssql-init:
+ image: mcr.microsoft.com/mssql/server:2022-latest
+ depends_on:
+ sqlserver:
+ condition: service_healthy
+ environment:
+ - MSSQL_SA_PASSWORD=${MSSQL_SA_PASSWORD:?Set MSSQL_SA_PASSWORD to a strong value}
+ - MSSQL_DB=${MSSQL_DB:-gateway}
+ entrypoint: ["/bin/bash", "-c"]
+ command:
+ - >
+ /opt/mssql-tools18/bin/sqlcmd -C -S sqlserver -U sa -P "$$MSSQL_SA_PASSWORD"
+ -Q "IF DB_ID('$$MSSQL_DB') IS NULL CREATE DATABASE [$$MSSQL_DB]"
+ restart: "no"
+ networks:
+ - gateway-network
+
+ gateway-controller:
+ depends_on:
+ sqlserver:
+ condition: service_healthy
+ mssql-init:
+ condition: service_completed_successfully
+ environment:
+ - APIP_GW_CONTROLLER_STORAGE_TYPE=sqlserver
+ - APIP_GW_CONTROLLER_STORAGE_DATABASE_DSN=sqlserver://sa:${MSSQL_SA_PASSWORD:?Set MSSQL_SA_PASSWORD to a strong value}@sqlserver:1433?database=${MSSQL_DB:-gateway}&encrypt=disable&TrustServerCertificate=true&app+name=gateway-controller
diff --git a/gateway/gateway-controller/README.md b/gateway/gateway-controller/README.md
index e641d2ac9..92db92dda 100644
--- a/gateway/gateway-controller/README.md
+++ b/gateway/gateway-controller/README.md
@@ -140,29 +140,41 @@ Override any configuration value using the `APIP_GW_` prefix:
```bash
# Override server API port
-export APIP_GW_GATEWAY__CONTROLLER_SERVER_API__PORT=9091
+export APIP_GW_CONTROLLER_SERVER_API__PORT=9091
# Set storage type to memory
-export APIP_GW_GATEWAY__CONTROLLER_STORAGE_TYPE=memory
+export APIP_GW_CONTROLLER_STORAGE_TYPE=memory
# Override SQLite database path
-export APIP_GW_GATEWAY__CONTROLLER_STORAGE_SQLITE_PATH=/custom/path/gateway.db
+export APIP_GW_CONTROLLER_STORAGE_SQLITE_PATH=/custom/path/gateway.db
# Configure PostgreSQL storage
-export APIP_GW_GATEWAY__CONTROLLER_STORAGE_TYPE=postgres
-export APIP_GW_GATEWAY__CONTROLLER_STORAGE_POSTGRES_HOST=postgres.example.internal
-export APIP_GW_GATEWAY__CONTROLLER_STORAGE_POSTGRES_PORT=5432
-export APIP_GW_GATEWAY__CONTROLLER_STORAGE_POSTGRES_DATABASE=gateway
-export APIP_GW_GATEWAY__CONTROLLER_STORAGE_POSTGRES_USER=gateway
-export APIP_GW_GATEWAY__CONTROLLER_STORAGE_POSTGRES_PASSWORD=secret
-export APIP_GW_GATEWAY__CONTROLLER_STORAGE_POSTGRES_SSLMODE=require
-export APIP_GW_GATEWAY__CONTROLLER_STORAGE_POSTGRES_MAX__OPEN__CONNS=25
+export APIP_GW_CONTROLLER_STORAGE_TYPE=postgres
+export APIP_GW_CONTROLLER_STORAGE_POSTGRES_HOST=postgres.example.internal
+export APIP_GW_CONTROLLER_STORAGE_POSTGRES_PORT=5432
+export APIP_GW_CONTROLLER_STORAGE_POSTGRES_DATABASE=gateway
+export APIP_GW_CONTROLLER_STORAGE_POSTGRES_USER=gateway
+export APIP_GW_CONTROLLER_STORAGE_POSTGRES_PASSWORD=secret
+export APIP_GW_CONTROLLER_STORAGE_POSTGRES_SSLMODE=require
+export APIP_GW_CONTROLLER_STORAGE_POSTGRES_MAX__OPEN__CONNS=25
+
+# Configure SQL Server storage
+export APIP_GW_CONTROLLER_STORAGE_TYPE=sqlserver
+export APIP_GW_CONTROLLER_STORAGE_DATABASE_DRIVER=sqlserver
+export APIP_GW_CONTROLLER_STORAGE_DATABASE_HOST=sqlserver.example.internal
+export APIP_GW_CONTROLLER_STORAGE_DATABASE_PORT=1433
+export APIP_GW_CONTROLLER_STORAGE_DATABASE_DATABASE=gateway
+export APIP_GW_CONTROLLER_STORAGE_DATABASE_USER=gateway
+export APIP_GW_CONTROLLER_STORAGE_DATABASE_PASSWORD=secret
+export APIP_GW_CONTROLLER_STORAGE_DATABASE_OPTIONS_ENCRYPT=disable
+export APIP_GW_CONTROLLER_STORAGE_DATABASE_OPTIONS_TRUST__SERVER__CERTIFICATE=true
+export APIP_GW_CONTROLLER_STORAGE_DATABASE_MAX__OPEN__CONNS=25
# Disable access logs
-export APIP_GW_GATEWAY__CONTROLLER_ROUTER_ACCESS__LOGS_ENABLED=false
+export APIP_GW_CONTROLLER_ROUTER_ACCESS__LOGS_ENABLED=false
# Set debug logging
-export APIP_GW_GATEWAY__CONTROLLER_LOGGING_LEVEL=debug
+export APIP_GW_CONTROLLER_LOGGING_LEVEL=debug
./bin/controller
```
@@ -202,6 +214,30 @@ storage:
application_name: gateway-controller
```
+#### Persistent Mode with SQL Server
+Use an external SQL Server instance for persistence:
+
+```yaml
+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):
@@ -646,10 +682,10 @@ The Gateway-Controller uses structured logging (Zap) with configurable levels.
```bash
# Using environment variable
-APIP_GW_GATEWAY__CONTROLLER_LOGGING_LEVEL=debug ./bin/controller
+APIP_GW_CONTROLLER_LOGGING_LEVEL=debug ./bin/controller
# Or using config file
-export APIP_GW_GATEWAY__CONTROLLER_LOGGING_LEVEL=debug
+export APIP_GW_CONTROLLER_LOGGING_LEVEL=debug
./bin/controller
```
diff --git a/gateway/gateway-controller/cmd/controller/main.go b/gateway/gateway-controller/cmd/controller/main.go
index 199b76602..ce8cc5299 100644
--- a/gateway/gateway-controller/cmd/controller/main.go
+++ b/gateway/gateway-controller/cmd/controller/main.go
@@ -38,10 +38,10 @@ import (
"github.com/wso2/api-platform/gateway/gateway-controller/pkg/policyxds"
"github.com/wso2/api-platform/gateway/gateway-controller/pkg/service/restapi"
"github.com/wso2/api-platform/gateway/gateway-controller/pkg/storage"
- "github.com/wso2/api-platform/gateway/gateway-controller/pkg/webhooksecretxds"
"github.com/wso2/api-platform/gateway/gateway-controller/pkg/transform"
"github.com/wso2/api-platform/gateway/gateway-controller/pkg/utils"
"github.com/wso2/api-platform/gateway/gateway-controller/pkg/version"
+ "github.com/wso2/api-platform/gateway/gateway-controller/pkg/webhooksecretxds"
"github.com/wso2/api-platform/gateway/gateway-controller/pkg/xds"
)
@@ -54,10 +54,11 @@ const (
)
func toBackendConfig(cfg *config.Config) storage.BackendConfig {
- pg := cfg.Controller.Storage.Postgres
+ pg := cfg.Controller.Storage.EffectivePostgresConfig()
+ ms := cfg.Controller.Storage.EffectiveSQLServerConfig()
return storage.BackendConfig{
Type: cfg.Controller.Storage.Type,
- SQLitePath: cfg.Controller.Storage.SQLite.Path,
+ SQLitePath: cfg.Controller.Storage.EffectiveSQLitePath(),
Postgres: storage.PostgresConnectionConfig{
DSN: pg.DSN,
Host: pg.Host,
@@ -73,6 +74,22 @@ func toBackendConfig(cfg *config.Config) storage.BackendConfig {
ConnMaxIdleTime: pg.ConnMaxIdleTime,
ApplicationName: pg.ApplicationName,
},
+ SQLServer: storage.SQLServerConnectionConfig{
+ DSN: ms.DSN,
+ Host: ms.Host,
+ Port: ms.Port,
+ Database: ms.Database,
+ User: ms.User,
+ Password: ms.Password,
+ Encrypt: cfg.Controller.Storage.SQLServerEncrypt(),
+ TrustServerCertificate: cfg.Controller.Storage.SQLServerTrustServerCertificate(),
+ ConnectTimeout: ms.ConnectTimeout,
+ MaxOpenConns: ms.MaxOpenConns,
+ MaxIdleConns: ms.MaxIdleConns,
+ ConnMaxLifetime: ms.ConnMaxLifetime,
+ ConnMaxIdleTime: ms.ConnMaxIdleTime,
+ ApplicationName: ms.ApplicationName,
+ },
GatewayID: cfg.Controller.Server.GatewayID,
}
}
@@ -127,8 +144,8 @@ func main() {
// guarantee a fresh, reproducible state on every boot.
if cfg.ImmutableGateway.Enabled {
log.Info("Immutable gateway mode enabled — removing existing SQLite files for fresh start",
- slog.String("path", cfg.Controller.Storage.SQLite.Path))
- if err := immutable.ResetSQLiteFiles(cfg.Controller.Storage.SQLite.Path, log); err != nil {
+ slog.String("path", cfg.Controller.Storage.EffectiveSQLitePath()))
+ if err := immutable.ResetSQLiteFiles(cfg.Controller.Storage.EffectiveSQLitePath(), log); err != nil {
log.Error("Failed to reset SQLite files for immutable mode", slog.Any("error", err))
os.Exit(1)
}
@@ -140,7 +157,7 @@ func main() {
if err != nil {
if strings.EqualFold(cfg.Controller.Storage.Type, "sqlite") && errors.Is(err, storage.ErrDatabaseLocked) {
log.Error("Database is locked by another process",
- slog.String("database_path", cfg.Controller.Storage.SQLite.Path),
+ slog.String("database_path", cfg.Controller.Storage.EffectiveSQLitePath()),
slog.String("troubleshooting", "Check if another gateway-controller instance is running or remove stale WAL files"))
os.Exit(1)
}
@@ -156,10 +173,17 @@ func main() {
var eventHubStorage storage.Storage
// Create separate storage connection for EventHub (avoids SQLite lock contention)
ehBackendCfg := toBackendConfig(cfg)
+ // Apply the EventHub-specific pool sizing to whichever SQL backend is in use
+ // (a separate, smaller pool for the poller). Keep this in sync for every
+ // connection-pooled backend so the override isn't silently dropped.
ehBackendCfg.Postgres.MaxOpenConns = cfg.Controller.EventHub.Database.MaxOpenConns
ehBackendCfg.Postgres.MaxIdleConns = cfg.Controller.EventHub.Database.MaxIdleConns
ehBackendCfg.Postgres.ConnMaxLifetime = cfg.Controller.EventHub.Database.ConnMaxLifetime
ehBackendCfg.Postgres.ConnMaxIdleTime = cfg.Controller.EventHub.Database.ConnMaxIdleTime
+ ehBackendCfg.SQLServer.MaxOpenConns = cfg.Controller.EventHub.Database.MaxOpenConns
+ ehBackendCfg.SQLServer.MaxIdleConns = cfg.Controller.EventHub.Database.MaxIdleConns
+ ehBackendCfg.SQLServer.ConnMaxLifetime = cfg.Controller.EventHub.Database.ConnMaxLifetime
+ ehBackendCfg.SQLServer.ConnMaxIdleTime = cfg.Controller.EventHub.Database.ConnMaxIdleTime
eventHubStorage, err = storage.NewStorage(ehBackendCfg, log)
if err != nil {
log.Error("Failed to initialize EventHub storage", slog.Any("error", err))
diff --git a/gateway/gateway-controller/go.mod b/gateway/gateway-controller/go.mod
index 3bfa669f3..a662c980d 100644
--- a/gateway/gateway-controller/go.mod
+++ b/gateway/gateway-controller/go.mod
@@ -17,6 +17,7 @@ require (
github.com/knadh/koanf/providers/file v1.2.1
github.com/knadh/koanf/v2 v2.3.2
github.com/mattn/go-sqlite3 v1.14.41
+ github.com/microsoft/go-mssqldb v1.10.0
github.com/oapi-codegen/runtime v1.1.2
github.com/prometheus/client_golang v1.23.2
github.com/prometheus/client_model v0.6.2
@@ -56,6 +57,8 @@ require (
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.1 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
+ github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
+ github.com/golang-sql/sqlexp v0.1.0 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
@@ -83,6 +86,7 @@ require (
github.com/prometheus/procfs v0.16.1 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.57.1 // indirect
+ github.com/shopspring/decimal v1.4.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
github.com/woodsbury/decimal128 v1.3.0 // indirect
@@ -90,11 +94,11 @@ require (
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/arch v0.23.0 // indirect
- golang.org/x/crypto v0.48.0 // indirect
- golang.org/x/net v0.51.0 // indirect
+ golang.org/x/crypto v0.50.0 // indirect
+ golang.org/x/net v0.53.0 // indirect
golang.org/x/sync v0.20.0 // indirect
- golang.org/x/sys v0.41.0 // indirect
- golang.org/x/text v0.35.0 // indirect
+ golang.org/x/sys v0.43.0 // indirect
+ golang.org/x/text v0.36.0 // indirect
golang.org/x/time v0.12.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect
diff --git a/gateway/gateway-controller/go.sum b/gateway/gateway-controller/go.sum
index cb72422f1..b3e354197 100644
--- a/gateway/gateway-controller/go.sum
+++ b/gateway/gateway-controller/go.sum
@@ -2,6 +2,18 @@ cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4=
cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
+github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1 h1:jHb/wfvRikGdxMXYV3QG/SzUOPYN9KEUUuC0Yd0/vC0=
+github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1/go.mod h1:pzBXCYn05zvYIrwLgtK8Ap8QcjRg+0i76tMQdWN6wOk=
+github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4=
+github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0=
+github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 h1:fhqpLE3UEXi9lPaBRpQ6XuRW0nU7hgg4zlmZZa+a9q4=
+github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0/go.mod h1:7dCRMLwisfRH3dBupKeNCioWYUZ4SS09Z14H+7i8ZoY=
+github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.4.0 h1:E4MgwLBGeVB5f2MdcIVD3ELVAWpr+WD6MUe1i+tM/PA=
+github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.4.0/go.mod h1:Y2b/1clN4zsAoUd/pgNAQHjLDnTis/6ROkUfyob6psM=
+github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 h1:nCYfgcSyHZXJI8J0IWE5MsCGlb2xp9fJiXyxWgmOFg4=
+github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0/go.mod h1:ucUjca2JtSZboY8IoUqyQyuuXvwbMBVwFOm0vdQPNhA=
+github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs=
+github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk=
github.com/MicahParks/jwkset v0.11.0 h1:yc0zG+jCvZpWgFDFmvs8/8jqqVBG9oyIbmBtmjOhoyQ=
github.com/MicahParks/jwkset v0.11.0/go.mod h1:U2oRhRaLgDCLjtpGL2GseNKGmZtLs/3O7p+OZaL5vo0=
github.com/MicahParks/keyfunc/v3 v3.7.0 h1:pdafUNyq+p3ZlvjJX1HWFP7MA3+cLpDtg69U3kITJGM=
@@ -74,6 +86,10 @@ github.com/goccy/go-yaml v1.19.1 h1:3rG3+v8pkhRqoQ/88NYNMHYVGYztCOCIZ7UQhu7H+NE=
github.com/goccy/go-yaml v1.19.1/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
+github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
+github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
+github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
+github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
@@ -129,6 +145,8 @@ github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mattn/go-sqlite3 v1.14.41 h1:8p7Pwz5NHkEbWSqc/ygU4CBGubhFFkpgP9KwcdkAHNA=
github.com/mattn/go-sqlite3 v1.14.41/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
+github.com/microsoft/go-mssqldb v1.10.0 h1:pHEt+Qz6YFPWqREq10mqSE524QQo+/QremwTCQht7TY=
+github.com/microsoft/go-mssqldb v1.10.0/go.mod h1:mnG7lGa9iYJbzJqGCXyuQCegStKMr3kogDLD6+bmggg=
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
@@ -152,6 +170,8 @@ github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
+github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
+github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@@ -171,6 +191,8 @@ github.com/quic-go/quic-go v0.57.1 h1:25KAAR9QR8KZrCZRThWMKVAwGoiHIrNbT72ULHTuI1
github.com/quic-go/quic-go v0.57.1/go.mod h1:ly4QBAjHA2VhdnxhojRsCUOeJwKYg+taDlos92xb1+s=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
+github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
+github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
@@ -218,17 +240,17 @@ go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
-golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
-golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
-golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
-golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
+golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
+golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
+golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
+golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
-golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
-golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
-golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
+golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
+golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
+golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
diff --git a/gateway/gateway-controller/pkg/config/config.go b/gateway/gateway-controller/pkg/config/config.go
index 9010ad6b1..345517c6a 100644
--- a/gateway/gateway-controller/pkg/config/config.go
+++ b/gateway/gateway-controller/pkg/config/config.go
@@ -22,6 +22,7 @@ import (
"fmt"
"log/slog"
"net/url"
+ "strconv"
"strings"
"time"
@@ -257,9 +258,29 @@ type LLMConfig struct {
// StorageConfig holds storage-related configuration
type StorageConfig struct {
- Type string `koanf:"type"` // "sqlite", "postgres", or "memory"
- SQLite SQLiteConfig `koanf:"sqlite"` // SQLite-specific configuration
- Postgres PostgresConfig `koanf:"postgres"` // PostgreSQL-specific configuration
+ Type string `koanf:"type"` // "sqlite", "postgres", or "sqlserver"
+ Database *DatabaseConfig `koanf:"database"` // Global database configuration
+ SQLite SQLiteConfig `koanf:"sqlite"` // Legacy SQLite configuration (backward compatibility)
+ Postgres PostgresConfig `koanf:"postgres"` // Legacy PostgreSQL configuration (backward compatibility)
+}
+
+// DatabaseConfig holds unified database configuration for all SQL backends.
+type DatabaseConfig struct {
+ Driver string `koanf:"driver"`
+ DSN string `koanf:"dsn"`
+ Path string `koanf:"path"`
+ Host string `koanf:"host"`
+ Port int `koanf:"port"`
+ Database string `koanf:"database"`
+ User string `koanf:"user"`
+ Password string `koanf:"password"`
+ ConnectTimeout time.Duration `koanf:"connect_timeout"`
+ MaxOpenConns int `koanf:"max_open_conns"`
+ MaxIdleConns int `koanf:"max_idle_conns"`
+ ConnMaxLifetime time.Duration `koanf:"conn_max_lifetime"`
+ ConnMaxIdleTime time.Duration `koanf:"conn_max_idle_time"`
+ ApplicationName string `koanf:"application_name"`
+ Options map[string]string `koanf:"options"`
}
// SQLiteConfig holds SQLite-specific configuration
@@ -284,6 +305,132 @@ type PostgresConfig struct {
ApplicationName string `koanf:"application_name"`
}
+// EffectiveSQLitePath resolves SQLite path with precedence:
+// storage.database.path > storage.sqlite.path.
+func (s StorageConfig) EffectiveSQLitePath() string {
+ if s.Database != nil && strings.TrimSpace(s.Database.Path) != "" {
+ return s.Database.Path
+ }
+ return s.SQLite.Path
+}
+
+// EffectivePostgresConfig resolves PostgreSQL config with precedence:
+// storage.database.* > storage.postgres.*.
+func (s StorageConfig) EffectivePostgresConfig() PostgresConfig {
+ pg := s.Postgres
+ if s.Database == nil {
+ return pg
+ }
+ db := s.Database
+ if db.DSN != "" {
+ pg.DSN = db.DSN
+ }
+ if db.Host != "" {
+ pg.Host = db.Host
+ }
+ if db.Port != 0 {
+ pg.Port = db.Port
+ }
+ if db.Database != "" {
+ pg.Database = db.Database
+ }
+ if db.User != "" {
+ pg.User = db.User
+ }
+ if db.Password != "" {
+ pg.Password = db.Password
+ }
+ if db.ConnectTimeout != 0 {
+ pg.ConnectTimeout = db.ConnectTimeout
+ }
+ if db.MaxOpenConns != 0 {
+ pg.MaxOpenConns = db.MaxOpenConns
+ }
+ if db.MaxIdleConns != 0 {
+ pg.MaxIdleConns = db.MaxIdleConns
+ }
+ if db.ConnMaxLifetime != 0 {
+ pg.ConnMaxLifetime = db.ConnMaxLifetime
+ }
+ if db.ConnMaxIdleTime != 0 {
+ pg.ConnMaxIdleTime = db.ConnMaxIdleTime
+ }
+ if db.ApplicationName != "" {
+ pg.ApplicationName = db.ApplicationName
+ }
+ if sslMode := getDatabaseOption(db.Options, "sslmode"); sslMode != "" {
+ pg.SSLMode = sslMode
+ }
+ return pg
+}
+
+// EffectiveSQLServerConfig resolves SQL Server config from the global database section.
+func (s StorageConfig) EffectiveSQLServerConfig() DatabaseConfig {
+ if s.Database == nil {
+ return DatabaseConfig{}
+ }
+ return *s.Database
+}
+
+// SQL Server "encrypt" option: single source of truth for the default and the
+// accepted values, shared by SQLServerEncrypt() and validateStorage so the
+// default and the allowed set are never duplicated.
+const defaultSQLServerEncrypt = "true"
+
+var validSQLServerEncryptModes = []string{"disable", "false", "true", "strict"}
+
+// SQLServerEncrypt resolves sqlserver encrypt option from storage.database.options.
+func (s StorageConfig) SQLServerEncrypt() string {
+ if s.Database == nil {
+ return ""
+ }
+ if encrypt := getDatabaseOption(s.Database.Options, "encrypt"); encrypt != "" {
+ return encrypt
+ }
+ return defaultSQLServerEncrypt
+}
+
+// SQLServerTrustServerCertificate resolves sqlserver trust flag from storage.database.options.
+func (s StorageConfig) SQLServerTrustServerCertificate() bool {
+ if s.Database == nil {
+ return false
+ }
+ raw := getDatabaseOption(s.Database.Options, "trust_server_certificate")
+ if raw == "" {
+ return false
+ }
+ parsed, err := strconv.ParseBool(raw)
+ if err != nil {
+ return false
+ }
+ return parsed
+}
+
+func getDatabaseOption(options map[string]string, key string) string {
+ if options == nil {
+ return ""
+ }
+ for k, v := range options {
+ if strings.EqualFold(k, key) {
+ return strings.TrimSpace(v)
+ }
+ }
+ return ""
+}
+
+func setDatabaseOption(options map[string]string, key, value string) {
+ if options == nil {
+ return
+ }
+ for k := range options {
+ if strings.EqualFold(k, key) {
+ options[k] = value
+ return
+ }
+ }
+ options[key] = value
+}
+
// RouterConfig holds router (Envoy) related configuration
type RouterConfig struct {
AccessLogs AccessLogsConfig `koanf:"access_logs"`
@@ -379,12 +526,12 @@ type HTTPListenerConfig struct {
// PolicyEngineConfig holds policy engine ext_proc filter configuration
type PolicyEngineConfig struct {
- Mode string `koanf:"mode"` // Connection mode: "uds" (default) or "tcp"
- Host string `koanf:"host"` // Policy engine hostname/IP (TCP mode only)
- Port uint32 `koanf:"port"` // Policy engine ext_proc port (TCP mode only)
- TimeoutMs uint32 `koanf:"timeout_ms"`
- MessageTimeoutMs uint32 `koanf:"message_timeout_ms"`
- TLS PolicyEngineTLS `koanf:"tls"` // TLS configuration (TCP mode only)
+ Mode string `koanf:"mode"` // Connection mode: "uds" (default) or "tcp"
+ Host string `koanf:"host"` // Policy engine hostname/IP (TCP mode only)
+ Port uint32 `koanf:"port"` // Policy engine ext_proc port (TCP mode only)
+ TimeoutMs uint32 `koanf:"timeout_ms"`
+ MessageTimeoutMs uint32 `koanf:"message_timeout_ms"`
+ TLS PolicyEngineTLS `koanf:"tls"` // TLS configuration (TCP mode only)
}
// PolicyEngineTLS holds policy engine TLS configuration
@@ -724,11 +871,11 @@ func defaultConfig() *Config {
},
},
PolicyEngine: PolicyEngineConfig{
- Mode: "uds", // UDS mode by default
- Host: "policy-engine", // Only used in TCP mode
- Port: 9001, // Only used in TCP mode
- TimeoutMs: 60000,
- MessageTimeoutMs: 60000,
+ Mode: "uds", // UDS mode by default
+ Host: "policy-engine", // Only used in TCP mode
+ Port: 9001, // Only used in TCP mode
+ TimeoutMs: 60000,
+ MessageTimeoutMs: 60000,
TLS: PolicyEngineTLS{
Enabled: false,
CertPath: "",
@@ -811,7 +958,7 @@ func (c *Config) Validate() error {
}
// Validate storage type
- validStorageTypes := []string{"sqlite", "postgres"}
+ validStorageTypes := []string{"sqlite", "postgres", "sqlserver"}
isValidType := false
for _, t := range validStorageTypes {
if c.Controller.Storage.Type == t {
@@ -820,17 +967,23 @@ func (c *Config) Validate() error {
}
}
if !isValidType {
- return fmt.Errorf("storage.type must be one of: sqlite, postgres, got: %s", c.Controller.Storage.Type)
+ return fmt.Errorf("storage.type must be one of: sqlite, postgres, sqlserver, got: %s", c.Controller.Storage.Type)
}
// Validate SQLite configuration
- if c.Controller.Storage.Type == "sqlite" && c.Controller.Storage.SQLite.Path == "" {
+ if c.Controller.Storage.Type == "sqlite" && c.Controller.Storage.EffectiveSQLitePath() == "" {
return fmt.Errorf("storage.sqlite.path is required when storage.type is 'sqlite'")
}
// Validate PostgreSQL configuration
if c.Controller.Storage.Type == "postgres" {
- pg := &c.Controller.Storage.Postgres
+ pg := c.Controller.Storage.EffectivePostgresConfig()
+ if c.Controller.Storage.Database != nil {
+ driver := strings.TrimSpace(c.Controller.Storage.Database.Driver)
+ if driver != "" && !strings.EqualFold(driver, "postgres") {
+ return fmt.Errorf("storage.database.driver must be 'postgres' when storage.type is 'postgres', got: %s", driver)
+ }
+ }
if pg.DSN == "" {
if pg.Host == "" {
@@ -905,6 +1058,105 @@ func (c *Config) Validate() error {
if pg.ApplicationName == "" {
pg.ApplicationName = "gateway-controller"
}
+
+ c.Controller.Storage.Postgres = pg
+ }
+
+ // Validate SQL Server configuration
+ if c.Controller.Storage.Type == "sqlserver" {
+ if c.Controller.Storage.Database == nil {
+ return fmt.Errorf("storage.database is required when storage.type is 'sqlserver'")
+ }
+ ms := c.Controller.Storage.Database
+
+ if ms.Driver == "" {
+ ms.Driver = "sqlserver"
+ }
+ if !strings.EqualFold(ms.Driver, "sqlserver") && !strings.EqualFold(ms.Driver, "mssql") {
+ return fmt.Errorf("storage.database.driver must be one of: sqlserver, mssql, got: %s", ms.Driver)
+ }
+
+ if ms.DSN == "" {
+ if ms.Host == "" {
+ return fmt.Errorf("storage.database.host is required when storage.type is 'sqlserver' and storage.database.dsn is empty")
+ }
+ if ms.Database == "" {
+ return fmt.Errorf("storage.database.database is required when storage.type is 'sqlserver' and storage.database.dsn is empty")
+ }
+ if ms.User == "" {
+ return fmt.Errorf("storage.database.user is required when storage.type is 'sqlserver' and storage.database.dsn is empty")
+ }
+ }
+
+ if ms.Port <= 0 {
+ ms.Port = 1433
+ }
+ if ms.Port > 65535 {
+ return fmt.Errorf("storage.database.port must be between 1 and 65535, got: %d", ms.Port)
+ }
+
+ encrypt := getDatabaseOption(ms.Options, "encrypt")
+ if encrypt == "" {
+ encrypt = defaultSQLServerEncrypt
+ }
+ isValidEncrypt := false
+ for _, e := range validSQLServerEncryptModes {
+ if strings.EqualFold(encrypt, e) {
+ encrypt = e
+ isValidEncrypt = true
+ break
+ }
+ }
+ if !isValidEncrypt {
+ return fmt.Errorf("storage.database.options.encrypt must be one of: %s, got: %s",
+ strings.Join(validSQLServerEncryptModes, ", "), encrypt)
+ }
+ setDatabaseOption(ms.Options, "encrypt", encrypt)
+
+ if trustServerCertificate := getDatabaseOption(ms.Options, "trust_server_certificate"); trustServerCertificate != "" {
+ if _, err := strconv.ParseBool(trustServerCertificate); err != nil {
+ return fmt.Errorf("storage.database.options.trust_server_certificate must be boolean, got: %s", trustServerCertificate)
+ }
+ }
+
+ if ms.ConnectTimeout <= 0 {
+ ms.ConnectTimeout = 5 * time.Second
+ }
+
+ if ms.MaxOpenConns == 0 {
+ ms.MaxOpenConns = 25
+ }
+ if ms.MaxOpenConns < 1 {
+ return fmt.Errorf("storage.database.max_open_conns must be >= 1, got: %d", ms.MaxOpenConns)
+ }
+
+ if ms.MaxIdleConns == 0 {
+ ms.MaxIdleConns = 5
+ }
+ if ms.MaxIdleConns < 0 {
+ return fmt.Errorf("storage.database.max_idle_conns must be >= 0, got: %d", ms.MaxIdleConns)
+ }
+ if ms.MaxIdleConns > ms.MaxOpenConns {
+ ms.MaxIdleConns = ms.MaxOpenConns
+ }
+
+ if ms.ConnMaxLifetime == 0 {
+ ms.ConnMaxLifetime = 30 * time.Minute
+ }
+ if ms.ConnMaxLifetime < 0 {
+ return fmt.Errorf("storage.database.conn_max_lifetime must be >= 0, got: %s", ms.ConnMaxLifetime)
+ }
+
+ if ms.ConnMaxIdleTime == 0 {
+ ms.ConnMaxIdleTime = 5 * time.Minute
+ }
+ if ms.ConnMaxIdleTime < 0 {
+ return fmt.Errorf("storage.database.conn_max_idle_time must be >= 0, got: %s", ms.ConnMaxIdleTime)
+ }
+
+ if ms.ApplicationName == "" {
+ ms.ApplicationName = "gateway-controller"
+ }
}
// Validate access log format
diff --git a/gateway/gateway-controller/pkg/config/config_test.go b/gateway/gateway-controller/pkg/config/config_test.go
index 00bb1a48b..91e53733a 100644
--- a/gateway/gateway-controller/pkg/config/config_test.go
+++ b/gateway/gateway-controller/pkg/config/config_test.go
@@ -19,6 +19,8 @@
package config
import (
+ "os"
+ "path/filepath"
"strings"
"testing"
"time"
@@ -303,6 +305,151 @@ func TestConfig_Validate_PostgresConfig(t *testing.T) {
}
}
+func TestConfig_Validate_SQLiteConfig_GlobalDatabasePath(t *testing.T) {
+ cfg := validConfig()
+ cfg.Controller.Storage.Type = "sqlite"
+ cfg.Controller.Storage.SQLite.Path = ""
+ cfg.Controller.Storage.Database = &DatabaseConfig{
+ Path: "/tmp/from-global.db",
+ }
+
+ err := cfg.Validate()
+ require.NoError(t, err)
+ assert.Equal(t, "/tmp/from-global.db", cfg.Controller.Storage.EffectiveSQLitePath())
+}
+
+func TestConfig_Validate_PostgresConfig_GlobalDatabaseOverridesLegacy(t *testing.T) {
+ cfg := validConfig()
+ cfg.Controller.Storage.Type = "postgres"
+ cfg.Controller.Storage.Postgres.Host = "legacy-host"
+ cfg.Controller.Storage.Postgres.Database = "legacy-db"
+ cfg.Controller.Storage.Postgres.User = "legacy-user"
+ cfg.Controller.Storage.Database = &DatabaseConfig{
+ Driver: "postgres",
+ Host: "global-host",
+ Database: "global-db",
+ User: "global-user",
+ Options: map[string]string{
+ "sslmode": "prefer",
+ },
+ }
+
+ err := cfg.Validate()
+ require.NoError(t, err)
+ assert.Equal(t, "global-host", cfg.Controller.Storage.Postgres.Host)
+ assert.Equal(t, "global-db", cfg.Controller.Storage.Postgres.Database)
+ assert.Equal(t, "global-user", cfg.Controller.Storage.Postgres.User)
+ assert.Equal(t, "prefer", cfg.Controller.Storage.Postgres.SSLMode)
+}
+
+func TestConfig_Validate_SQLServerConfig_GlobalDatabase(t *testing.T) {
+ tests := []struct {
+ name string
+ database *DatabaseConfig
+ wantErr bool
+ errContains string
+ }{
+ {
+ name: "Missing global database section",
+ database: nil,
+ wantErr: true,
+ errContains: "storage.database is required",
+ },
+ {
+ name: "Invalid driver",
+ database: &DatabaseConfig{
+ Driver: "postgres",
+ Host: "localhost",
+ Database: "gw",
+ User: "sa",
+ },
+ wantErr: true,
+ errContains: "storage.database.driver must be one of",
+ },
+ {
+ name: "Invalid encrypt option",
+ database: &DatabaseConfig{
+ Driver: "sqlserver",
+ Host: "localhost",
+ Database: "gw",
+ User: "sa",
+ Options: map[string]string{
+ "encrypt": "bad",
+ },
+ },
+ wantErr: true,
+ errContains: "storage.database.options.encrypt must be one of",
+ },
+ {
+ name: "Valid SQLServer global database config",
+ database: &DatabaseConfig{
+ Driver: "mssql",
+ Host: "localhost",
+ Database: "gw",
+ User: "sa",
+ Options: map[string]string{
+ "encrypt": "strict",
+ "trust_server_certificate": "true",
+ },
+ },
+ wantErr: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ cfg := validConfig()
+ cfg.Controller.Storage.Type = "sqlserver"
+ cfg.Controller.Storage.Database = tt.database
+
+ err := cfg.Validate()
+ if tt.wantErr {
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), tt.errContains)
+ return
+ }
+
+ require.NoError(t, err)
+ require.NotNil(t, cfg.Controller.Storage.Database)
+ assert.Equal(t, 1433, cfg.Controller.Storage.Database.Port)
+ assert.Equal(t, "strict", cfg.Controller.Storage.SQLServerEncrypt())
+ assert.True(t, cfg.Controller.Storage.SQLServerTrustServerCertificate())
+ })
+ }
+}
+
+func TestLoadConfig_SQLServerDatabaseFromEnv(t *testing.T) {
+ tmpDir := t.TempDir()
+ configPath := filepath.Join(tmpDir, "config.toml")
+ require.NoError(t, os.WriteFile(configPath, []byte(""), 0o644))
+
+ t.Setenv("APIP_GW_CONTROLLER_STORAGE_TYPE", "sqlserver")
+ t.Setenv("APIP_GW_CONTROLLER_STORAGE_DATABASE_DRIVER", "mssql")
+ t.Setenv("APIP_GW_CONTROLLER_STORAGE_DATABASE_HOST", "sqlserver.local")
+ t.Setenv("APIP_GW_CONTROLLER_STORAGE_DATABASE_PORT", "1433")
+ t.Setenv("APIP_GW_CONTROLLER_STORAGE_DATABASE_DATABASE", "gateway")
+ t.Setenv("APIP_GW_CONTROLLER_STORAGE_DATABASE_USER", "sa")
+ t.Setenv("APIP_GW_CONTROLLER_STORAGE_DATABASE_PASSWORD", "secret")
+ t.Setenv("APIP_GW_CONTROLLER_STORAGE_DATABASE_OPTIONS_ENCRYPT", "disable")
+ t.Setenv("APIP_GW_CONTROLLER_STORAGE_DATABASE_OPTIONS_TRUST__SERVER__CERTIFICATE", "true")
+ t.Setenv("APIP_GW_CONTROLLER_STORAGE_DATABASE_MAX__OPEN__CONNS", "30")
+
+ cfg, err := LoadConfig(configPath)
+ require.NoError(t, err)
+ require.NotNil(t, cfg.Controller.Storage.Database)
+
+ assert.Equal(t, "sqlserver", cfg.Controller.Storage.Type)
+ assert.Equal(t, "mssql", cfg.Controller.Storage.Database.Driver)
+ assert.Equal(t, "sqlserver.local", cfg.Controller.Storage.Database.Host)
+ assert.Equal(t, 1433, cfg.Controller.Storage.Database.Port)
+ assert.Equal(t, "gateway", cfg.Controller.Storage.Database.Database)
+ assert.Equal(t, "sa", cfg.Controller.Storage.Database.User)
+ assert.Equal(t, "secret", cfg.Controller.Storage.Database.Password)
+ assert.Equal(t, 30, cfg.Controller.Storage.Database.MaxOpenConns)
+ assert.Equal(t, "disable", cfg.Controller.Storage.SQLServerEncrypt())
+ assert.True(t, cfg.Controller.Storage.SQLServerTrustServerCertificate())
+}
+
func TestConfig_Validate_AccessLogFormat(t *testing.T) {
tests := []struct {
name string
diff --git a/gateway/gateway-controller/pkg/storage/factory.go b/gateway/gateway-controller/pkg/storage/factory.go
index 8de20c95d..c75e543b8 100644
--- a/gateway/gateway-controller/pkg/storage/factory.go
+++ b/gateway/gateway-controller/pkg/storage/factory.go
@@ -28,10 +28,11 @@ import (
// BackendConfig contains the minimal storage backend configuration required by NewStorage.
type BackendConfig struct {
- Type string
- SQLitePath string
- Postgres PostgresConnectionConfig
- GatewayID string
+ Type string
+ SQLitePath string
+ Postgres PostgresConnectionConfig
+ SQLServer SQLServerConnectionConfig
+ GatewayID string
}
// NewStorage creates the configured persistent storage backend.
@@ -62,6 +63,17 @@ func NewStorage(cfg BackendConfig, logger *slog.Logger) (Storage, error) {
store.isUniqueViolation = isPostgresUniqueConstraintError
return store, nil
+ case "sqlserver":
+ backend, err := newSQLServerStorage(cfg.SQLServer, logger)
+ if err != nil {
+ return nil, err
+ }
+
+ store := newSQLStore(backend.db, backend.logger, "sqlserver", cfg.GatewayID)
+ store.rebindQuery = func(query string) string { return sqlx.Rebind(sqlx.AT, query) }
+ store.isUniqueViolation = isSQLServerUniqueConstraintError
+ return store, nil
+
default:
return nil, fmt.Errorf("%w: %s", ErrUnsupportedStorageType, cfg.Type)
}
diff --git a/gateway/gateway-controller/pkg/storage/factory_test.go b/gateway/gateway-controller/pkg/storage/factory_test.go
index 39487ea8c..1a523f265 100644
--- a/gateway/gateway-controller/pkg/storage/factory_test.go
+++ b/gateway/gateway-controller/pkg/storage/factory_test.go
@@ -58,6 +58,23 @@ func TestNewStorage_Postgres(t *testing.T) {
defer s.Close()
}
+func TestNewStorage_SQLServer(t *testing.T) {
+ dsn := os.Getenv("SQLSERVER_TEST_DSN")
+ if dsn == "" {
+ t.Skip("SQLSERVER_TEST_DSN is not set; skipping sqlserver factory test")
+ }
+
+ logger := slog.New(slog.NewTextHandler(io.Discard, nil))
+ s, err := NewStorage(BackendConfig{Type: "sqlserver", SQLServer: SQLServerConnectionConfig{DSN: dsn}}, logger)
+ if err != nil {
+ t.Fatalf("expected sqlserver storage, got error: %v", err)
+ }
+ if s == nil {
+ t.Fatal("expected non-nil storage")
+ }
+ defer s.Close()
+}
+
func TestNewStorage_UnsupportedType(t *testing.T) {
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
_, err := NewStorage(BackendConfig{Type: "mysql"}, logger)
diff --git a/gateway/gateway-controller/pkg/storage/gateway-controller-db.sqlserver.sql b/gateway/gateway-controller/pkg/storage/gateway-controller-db.sqlserver.sql
new file mode 100644
index 000000000..741921c37
--- /dev/null
+++ b/gateway/gateway-controller/pkg/storage/gateway-controller-db.sqlserver.sql
@@ -0,0 +1,274 @@
+-- SQL Server Schema for Gateway-Controller API Configurations
+-- Version: 2
+--
+-- Portable counterpart of gateway-controller-db.postgres.sql. Type mapping:
+-- TEXT (keyed) -> NVARCHAR(64)/NVARCHAR(255) (NVARCHAR(MAX) cannot be indexed;
+-- composite key columns are kept small to stay under SQL Server's
+-- 900-byte clustered / 1700-byte nonclustered index-key limits)
+-- TEXT (free/JSON) -> NVARCHAR(MAX)
+-- BYTEA -> VARBINARY(MAX)
+-- TIMESTAMPTZ -> DATETIME2(7) DEFAULT SYSUTCDATETIME()
+-- BOOLEAN -> BIT
+-- INTEGER -> INT
+-- Every object is guarded by IF NOT EXISTS so the batch is idempotent.
+
+-- Base table for all artifact types
+IF OBJECT_ID(N'dbo.artifacts', N'U') IS NULL
+CREATE TABLE dbo.artifacts (
+ uuid NVARCHAR(64) NOT NULL,
+ gateway_id NVARCHAR(64) NOT NULL,
+ display_name NVARCHAR(255) NOT NULL,
+ version NVARCHAR(64) NOT NULL,
+ kind NVARCHAR(64) NOT NULL,
+ handle NVARCHAR(255) NOT NULL,
+ desired_state NVARCHAR(20) NOT NULL CHECK(desired_state IN ('deployed', 'undeployed')),
+ deployment_id NVARCHAR(255),
+ origin NVARCHAR(255) NOT NULL,
+ created_at DATETIME2(7) NOT NULL DEFAULT SYSUTCDATETIME(),
+ updated_at DATETIME2(7) NOT NULL DEFAULT SYSUTCDATETIME(),
+ deployed_at DATETIME2(7),
+ cp_sync_status NVARCHAR(20) CHECK(cp_sync_status IN ('pending', 'success', 'failed')),
+ cp_sync_info NVARCHAR(MAX),
+ cp_artifact_id NVARCHAR(255),
+ PRIMARY KEY (gateway_id, uuid),
+ UNIQUE(gateway_id, kind, display_name, version),
+ UNIQUE(gateway_id, kind, handle)
+);
+
+IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_artifacts_cp_artifact_id' AND object_id = OBJECT_ID(N'dbo.artifacts'))
+CREATE INDEX idx_artifacts_cp_artifact_id ON dbo.artifacts(gateway_id, cp_artifact_id) WHERE cp_artifact_id IS NOT NULL;
+
+-- Per-resource-type tables
+
+IF OBJECT_ID(N'dbo.rest_apis', N'U') IS NULL
+CREATE TABLE dbo.rest_apis (
+ uuid NVARCHAR(64) NOT NULL,
+ gateway_id NVARCHAR(64) NOT NULL,
+ configuration NVARCHAR(MAX) NOT NULL,
+ PRIMARY KEY (gateway_id, uuid),
+ FOREIGN KEY(gateway_id, uuid) REFERENCES dbo.artifacts(gateway_id, uuid) ON DELETE CASCADE
+);
+
+IF OBJECT_ID(N'dbo.websub_apis', N'U') IS NULL
+CREATE TABLE dbo.websub_apis (
+ uuid NVARCHAR(64) NOT NULL,
+ gateway_id NVARCHAR(64) NOT NULL,
+ configuration NVARCHAR(MAX) NOT NULL,
+ PRIMARY KEY (gateway_id, uuid),
+ FOREIGN KEY(gateway_id, uuid) REFERENCES dbo.artifacts(gateway_id, uuid) ON DELETE CASCADE
+);
+
+IF OBJECT_ID(N'dbo.llm_providers', N'U') IS NULL
+CREATE TABLE dbo.llm_providers (
+ uuid NVARCHAR(64) NOT NULL,
+ gateway_id NVARCHAR(64) NOT NULL,
+ configuration NVARCHAR(MAX) NOT NULL,
+ PRIMARY KEY (gateway_id, uuid),
+ FOREIGN KEY(gateway_id, uuid) REFERENCES dbo.artifacts(gateway_id, uuid) ON DELETE CASCADE
+);
+
+IF OBJECT_ID(N'dbo.llm_proxies', N'U') IS NULL
+CREATE TABLE dbo.llm_proxies (
+ uuid NVARCHAR(64) NOT NULL,
+ gateway_id NVARCHAR(64) NOT NULL,
+ configuration NVARCHAR(MAX) NOT NULL,
+ provider_uuid NVARCHAR(64) NOT NULL,
+ PRIMARY KEY (gateway_id, uuid),
+ FOREIGN KEY(gateway_id, uuid) REFERENCES dbo.artifacts(gateway_id, uuid) ON DELETE CASCADE,
+ FOREIGN KEY(gateway_id, provider_uuid) REFERENCES dbo.llm_providers(gateway_id, uuid) ON DELETE NO ACTION
+);
+
+IF OBJECT_ID(N'dbo.mcp_proxies', N'U') IS NULL
+CREATE TABLE dbo.mcp_proxies (
+ uuid NVARCHAR(64) NOT NULL,
+ gateway_id NVARCHAR(64) NOT NULL,
+ configuration NVARCHAR(MAX) NOT NULL,
+ PRIMARY KEY (gateway_id, uuid),
+ FOREIGN KEY(gateway_id, uuid) REFERENCES dbo.artifacts(gateway_id, uuid) ON DELETE CASCADE
+);
+
+-- Table for custom TLS certificates
+IF OBJECT_ID(N'dbo.certificates', N'U') IS NULL
+CREATE TABLE dbo.certificates (
+ uuid NVARCHAR(64) NOT NULL,
+ gateway_id NVARCHAR(64) NOT NULL,
+ name NVARCHAR(255) NOT NULL,
+ certificate VARBINARY(MAX) NOT NULL,
+ subject NVARCHAR(MAX) NOT NULL,
+ issuer NVARCHAR(MAX) NOT NULL,
+ not_before DATETIME2(7) NOT NULL,
+ not_after DATETIME2(7) NOT NULL,
+ cert_count INT NOT NULL DEFAULT 1,
+ created_at DATETIME2(7) NOT NULL DEFAULT SYSUTCDATETIME(),
+ updated_at DATETIME2(7) NOT NULL DEFAULT SYSUTCDATETIME(),
+ PRIMARY KEY (gateway_id, uuid),
+ UNIQUE(gateway_id, name)
+);
+
+-- LLM Provider Templates table
+IF OBJECT_ID(N'dbo.llm_provider_templates', N'U') IS NULL
+CREATE TABLE dbo.llm_provider_templates (
+ uuid NVARCHAR(64) NOT NULL,
+ gateway_id NVARCHAR(64) NOT NULL,
+ handle NVARCHAR(255) NOT NULL,
+ configuration NVARCHAR(MAX) NOT NULL,
+ created_at DATETIME2(7) NOT NULL DEFAULT SYSUTCDATETIME(),
+ updated_at DATETIME2(7) NOT NULL DEFAULT SYSUTCDATETIME(),
+ PRIMARY KEY (gateway_id, uuid),
+ UNIQUE(gateway_id, handle)
+);
+
+-- Table for API keys
+IF OBJECT_ID(N'dbo.api_keys', N'U') IS NULL
+CREATE TABLE dbo.api_keys (
+ uuid NVARCHAR(64) NOT NULL,
+ gateway_id NVARCHAR(64) NOT NULL,
+ name NVARCHAR(255) NOT NULL,
+ api_key NVARCHAR(255) NOT NULL,
+ masked_api_key NVARCHAR(255) NOT NULL,
+ artifact_uuid NVARCHAR(64) NOT NULL,
+ status NVARCHAR(20) NOT NULL CHECK(status IN ('active', 'revoked', 'expired')) DEFAULT 'active',
+ created_at DATETIME2(7) NOT NULL DEFAULT SYSUTCDATETIME(),
+ created_by NVARCHAR(255) NOT NULL DEFAULT 'system',
+ updated_at DATETIME2(7) NOT NULL DEFAULT SYSUTCDATETIME(),
+ expires_at DATETIME2(7) NULL,
+ source NVARCHAR(64) NOT NULL DEFAULT 'local',
+ external_ref_id NVARCHAR(255) NULL,
+ issuer NVARCHAR(255) NULL DEFAULT NULL,
+ PRIMARY KEY (gateway_id, api_key),
+ CONSTRAINT uq_api_keys_artifact_name UNIQUE (gateway_id, artifact_uuid, name),
+ CONSTRAINT uq_api_keys_uuid UNIQUE (gateway_id, uuid)
+);
+
+IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_api_key_status' AND object_id = OBJECT_ID(N'dbo.api_keys'))
+CREATE INDEX idx_api_key_status ON dbo.api_keys(status);
+IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_created_by' AND object_id = OBJECT_ID(N'dbo.api_keys'))
+CREATE INDEX idx_created_by ON dbo.api_keys(created_by);
+
+-- Subscription plans table (organization-scoped rate/billing plans)
+IF OBJECT_ID(N'dbo.subscription_plans', N'U') IS NULL
+CREATE TABLE dbo.subscription_plans (
+ uuid NVARCHAR(64) NOT NULL,
+ gateway_id NVARCHAR(64) NOT NULL,
+ plan_name NVARCHAR(255) NOT NULL,
+ billing_plan NVARCHAR(MAX),
+ stop_on_quota_reach BIT DEFAULT 1,
+ throttle_limit_count INT,
+ throttle_limit_unit NVARCHAR(64),
+ expiry_time DATETIME2(7),
+ status NVARCHAR(20) NOT NULL CHECK(status IN ('ACTIVE', 'INACTIVE')) DEFAULT 'ACTIVE',
+ created_at DATETIME2(7) NOT NULL DEFAULT SYSUTCDATETIME(),
+ updated_at DATETIME2(7) NOT NULL DEFAULT SYSUTCDATETIME(),
+ PRIMARY KEY (gateway_id, uuid),
+ UNIQUE(gateway_id, plan_name)
+);
+
+-- Subscriptions table (application-level subscriptions for REST APIs, even before deployment)
+IF OBJECT_ID(N'dbo.subscriptions', N'U') IS NULL
+CREATE TABLE dbo.subscriptions (
+ uuid NVARCHAR(64) NOT NULL,
+ gateway_id NVARCHAR(64) NOT NULL,
+ api_id NVARCHAR(255) NOT NULL,
+ application_id NVARCHAR(255),
+ subscription_token_hash NVARCHAR(64) NOT NULL,
+ subscription_plan_id NVARCHAR(64),
+ billing_customer_id NVARCHAR(255),
+ billing_subscription_id NVARCHAR(255),
+ status NVARCHAR(20) NOT NULL CHECK(status IN ('ACTIVE', 'INACTIVE', 'REVOKED')) DEFAULT 'ACTIVE',
+ created_at DATETIME2(7) NOT NULL DEFAULT SYSUTCDATETIME(),
+ updated_at DATETIME2(7) NOT NULL DEFAULT SYSUTCDATETIME(),
+ PRIMARY KEY (gateway_id, uuid),
+ FOREIGN KEY (gateway_id, subscription_plan_id) REFERENCES dbo.subscription_plans(gateway_id, uuid),
+ UNIQUE(gateway_id, api_id, subscription_token_hash)
+);
+
+IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_subscriptions_application_id' AND object_id = OBJECT_ID(N'dbo.subscriptions'))
+CREATE INDEX idx_subscriptions_application_id ON dbo.subscriptions(application_id);
+
+-- Table for gateway states (used by eventhub for multi-replica sync)
+IF OBJECT_ID(N'dbo.gateway_states', N'U') IS NULL
+CREATE TABLE dbo.gateway_states (
+ gateway_id NVARCHAR(64) NOT NULL PRIMARY KEY,
+ version_id NVARCHAR(255) NOT NULL DEFAULT '',
+ updated_at DATETIME2(7) NOT NULL DEFAULT SYSUTCDATETIME()
+);
+
+-- Table for events (used by eventhub for multi-replica sync)
+IF OBJECT_ID(N'dbo.events', N'U') IS NULL
+CREATE TABLE dbo.events (
+ gateway_id NVARCHAR(64) NOT NULL,
+ processed_timestamp DATETIME2(7) NOT NULL DEFAULT SYSUTCDATETIME(),
+ originated_timestamp DATETIME2(7) NOT NULL,
+ entity_type NVARCHAR(64) NOT NULL,
+ action NVARCHAR(20) NOT NULL CHECK(action IN ('CREATE', 'UPDATE', 'DELETE')),
+ entity_id NVARCHAR(255) NOT NULL,
+ event_id NVARCHAR(64) NOT NULL,
+ event_data NVARCHAR(MAX) NOT NULL,
+ PRIMARY KEY (gateway_id, event_id),
+ FOREIGN KEY (gateway_id) REFERENCES dbo.gateway_states(gateway_id) ON DELETE CASCADE
+);
+
+IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_events_gateway_id_processed_timestamp' AND object_id = OBJECT_ID(N'dbo.events'))
+CREATE INDEX idx_events_gateway_id_processed_timestamp ON dbo.events(gateway_id, processed_timestamp);
+
+-- Applications
+IF OBJECT_ID(N'dbo.applications', N'U') IS NULL
+CREATE TABLE dbo.applications (
+ application_uuid NVARCHAR(64) NOT NULL,
+ gateway_id NVARCHAR(64) NOT NULL,
+ application_id NVARCHAR(255) NOT NULL,
+ application_name NVARCHAR(255) NOT NULL,
+ application_type NVARCHAR(255) NOT NULL,
+ created_at DATETIME2(7) NOT NULL DEFAULT SYSUTCDATETIME(),
+ updated_at DATETIME2(7) NOT NULL DEFAULT SYSUTCDATETIME(),
+ PRIMARY KEY (gateway_id, application_uuid)
+);
+
+-- Application to API key mappings
+IF OBJECT_ID(N'dbo.application_api_keys', N'U') IS NULL
+CREATE TABLE dbo.application_api_keys (
+ application_uuid NVARCHAR(64) NOT NULL,
+ api_key_id NVARCHAR(64) NOT NULL,
+ gateway_id NVARCHAR(64) NOT NULL,
+ created_at DATETIME2(7) NOT NULL DEFAULT SYSUTCDATETIME(),
+ updated_at DATETIME2(7) NOT NULL DEFAULT SYSUTCDATETIME(),
+ PRIMARY KEY (gateway_id, application_uuid, api_key_id),
+ FOREIGN KEY (gateway_id, application_uuid) REFERENCES dbo.applications(gateway_id, application_uuid) ON DELETE CASCADE,
+ FOREIGN KEY (gateway_id, api_key_id) REFERENCES dbo.api_keys(gateway_id, uuid) ON DELETE CASCADE
+);
+
+IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_app_api_keys_apikey' AND object_id = OBJECT_ID(N'dbo.application_api_keys'))
+CREATE INDEX idx_app_api_keys_apikey ON dbo.application_api_keys(gateway_id, api_key_id);
+
+-- Table for encrypted secrets (gateway_id + handle form the composite PK)
+IF OBJECT_ID(N'dbo.secrets', N'U') IS NULL
+CREATE TABLE dbo.secrets (
+ gateway_id NVARCHAR(64) NOT NULL,
+ handle NVARCHAR(255) NOT NULL,
+ display_name NVARCHAR(255) NOT NULL,
+ description NVARCHAR(MAX),
+ ciphertext VARBINARY(MAX) NOT NULL,
+ created_at DATETIME2(7) NOT NULL DEFAULT SYSUTCDATETIME(),
+ updated_at DATETIME2(7) NOT NULL DEFAULT SYSUTCDATETIME(),
+ PRIMARY KEY (gateway_id, handle)
+);
+
+-- Table for encrypted per-artifact webhook secrets (gateway-scoped)
+IF OBJECT_ID(N'dbo.webhook_secrets', N'U') IS NULL
+CREATE TABLE dbo.webhook_secrets (
+ uuid NVARCHAR(64) NOT NULL,
+ gateway_id NVARCHAR(64) NOT NULL,
+ artifact_uuid NVARCHAR(64) NOT NULL,
+ name NVARCHAR(255) NOT NULL,
+ display_name NVARCHAR(255) NOT NULL,
+ ciphertext VARBINARY(MAX) NOT NULL,
+ status NVARCHAR(20) NOT NULL CHECK(status IN ('active', 'revoked')) DEFAULT 'active',
+ created_at DATETIME2(7) NOT NULL DEFAULT SYSUTCDATETIME(),
+ updated_at DATETIME2(7) NOT NULL DEFAULT SYSUTCDATETIME(),
+ PRIMARY KEY (gateway_id, uuid),
+ CONSTRAINT uq_webhook_secrets_artifact_name UNIQUE (gateway_id, artifact_uuid, name),
+ FOREIGN KEY (gateway_id, artifact_uuid) REFERENCES dbo.artifacts(gateway_id, uuid) ON DELETE CASCADE
+);
+
+IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_webhook_secrets_artifact' AND object_id = OBJECT_ID(N'dbo.webhook_secrets'))
+CREATE INDEX idx_webhook_secrets_artifact ON dbo.webhook_secrets(gateway_id, artifact_uuid);
diff --git a/gateway/gateway-controller/pkg/storage/sql_store.go b/gateway/gateway-controller/pkg/storage/sql_store.go
index 00b3e947d..b74e28cdd 100644
--- a/gateway/gateway-controller/pkg/storage/sql_store.go
+++ b/gateway/gateway-controller/pkg/storage/sql_store.go
@@ -19,6 +19,7 @@
package storage
import (
+ "context"
"crypto/sha256"
"database/sql"
"encoding/hex"
@@ -54,14 +55,24 @@ type sqlStore struct {
isUniqueViolation func(error) bool
backendName string
+
+ // ctx is the store lifecycle context passed to every DB call. It is
+ // cancelled by Close(), so in-flight queries are interrupted on shutdown
+ // rather than blocking db.Close(). Request-scoped cancellation would
+ // require threading a context through the Storage interface methods.
+ ctx context.Context
+ cancel context.CancelFunc
}
func newSQLStore(db *sql.DB, logger *slog.Logger, backendName string, gatewayId string) *sqlStore {
+ ctx, cancel := context.WithCancel(context.Background())
return &sqlStore{
db: db,
logger: logger,
gatewayId: gatewayId,
backendName: backendName,
+ ctx: ctx,
+ cancel: cancel,
// Defaults are identity/false; backends can override.
rebindQuery: func(query string) string { return query },
isUniqueViolation: func(error) bool { return false },
@@ -76,23 +87,154 @@ func (s *sqlStore) bind(query string) string {
}
func (s *sqlStore) exec(query string, args ...interface{}) (sql.Result, error) {
- return s.db.Exec(s.bind(query), args...)
+ return s.db.ExecContext(s.ctx, s.bind(query), args...)
}
func (s *sqlStore) queryRow(query string, args ...interface{}) *sql.Row {
- return s.db.QueryRow(s.bind(query), args...)
+ return s.db.QueryRowContext(s.ctx, s.bind(query), args...)
}
func (s *sqlStore) query(query string, args ...interface{}) (*sql.Rows, error) {
- return s.db.Query(s.bind(query), args...)
+ return s.db.QueryContext(s.ctx, s.bind(query), args...)
}
func (s *sqlStore) prepare(query string) (*sql.Stmt, error) {
- return s.db.Prepare(s.bind(query))
+ return s.db.PrepareContext(s.ctx, s.bind(query))
+}
+
+// ExecQ / QueryRowQ let *sqlStore satisfy rowExecer so the same helpers
+// (e.g. upsert) work both standalone and inside a transaction (*sqlStoreTx).
+func (s *sqlStore) ExecQ(query string, args ...interface{}) (sql.Result, error) {
+ return s.exec(query, args...)
+}
+
+func (s *sqlStore) QueryRowQ(query string, args ...interface{}) *sql.Row {
+ return s.queryRow(query, args...)
+}
+
+// rowExecer is implemented by both *sqlStore and *sqlStoreTx, letting query
+// helpers run either standalone or within a transaction.
+type rowExecer interface {
+ ExecQ(query string, args ...interface{}) (sql.Result, error)
+ QueryRowQ(query string, args ...interface{}) *sql.Row
+}
+
+// upsertSpec describes a portable INSERT-or-UPDATE. All SQL it generates is
+// plain ANSI (INSERT / UPDATE / SELECT with `?` placeholders), so it runs
+// unchanged on SQLite, PostgreSQL and SQL Server — no ON CONFLICT / MERGE /
+// RETURNING. Where the previous ON CONFLICT clauses referenced excluded.
+// (the incoming row), callers pass that value as a bound parameter instead;
+// references to the existing row stay as bare column names.
+type upsertSpec struct {
+ table string // target table
+ columns []string // INSERT column list
+ insertValues []interface{} // values for the INSERT (len == len(columns))
+ keyColumns []string // unique/conflict key columns (UPDATE & SELECT WHERE)
+ keyValues []interface{} // values for keyColumns
+ setClauses []string // UPDATE assignments, e.g. "display_name = ?"
+ setValues []interface{} // values for the `?` in setClauses (in order)
+ guard string // optional extra UPDATE predicate, e.g. "(deployed_at IS NULL OR deployed_at < ?)"
+ guardValues []interface{} // values for the `?` in guard
+}
+
+// upsert performs a portable insert-or-update. It first runs a guarded UPDATE;
+// if no row matches it checks existence and INSERTs only when absent. This
+// preserves the previous ON CONFLICT ... WHERE semantics:
+// didWrite is false only when the row already exists and the guard rejected the
+// update (e.g. a newer row is already stored).
+func (s *sqlStore) upsert(e rowExecer, spec upsertSpec) (didWrite bool, err error) {
+ whereSQL := equalityPredicate(spec.keyColumns)
+
+ update := fmt.Sprintf("UPDATE %s SET %s WHERE %s",
+ spec.table, strings.Join(spec.setClauses, ", "), whereSQL)
+ updateArgs := make([]interface{}, 0, len(spec.setValues)+len(spec.keyValues)+len(spec.guardValues))
+ updateArgs = append(updateArgs, spec.setValues...)
+ updateArgs = append(updateArgs, spec.keyValues...)
+ if spec.guard != "" {
+ update += " AND " + spec.guard
+ updateArgs = append(updateArgs, spec.guardValues...)
+ }
+
+ res, err := e.ExecQ(update, updateArgs...)
+ if err != nil {
+ return false, err
+ }
+ n, err := res.RowsAffected()
+ if err != nil {
+ return false, err
+ }
+ if n > 0 {
+ return true, nil
+ }
+
+ // No row updated: either the row is absent, or it exists but the guard
+ // rejected the update. Only INSERT when it is genuinely absent.
+ exists, err := s.rowExists(e, spec.table, whereSQL, spec.keyValues)
+ if err != nil {
+ return false, err
+ }
+ if exists {
+ return false, nil // guard rejected; keep the existing row
+ }
+
+ insert := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)",
+ spec.table, strings.Join(spec.columns, ", "), repeatPlaceholders(len(spec.columns)))
+ if _, ierr := e.ExecQ(insert, spec.insertValues...); ierr != nil {
+ if !s.isUniqueViolation(ierr) {
+ return false, ierr
+ }
+ // Lost a race with a concurrent insert, or a different unique
+ // constraint collided. Retry the guarded UPDATE against the key.
+ res2, uerr := e.ExecQ(update, updateArgs...)
+ if uerr != nil {
+ return false, uerr
+ }
+ if n2, _ := res2.RowsAffected(); n2 > 0 {
+ return true, nil
+ }
+ // Key row now exists but the guard rejected it → keep existing.
+ // Otherwise the violation came from another unique constraint → surface it.
+ if again, _ := s.rowExists(e, spec.table, whereSQL, spec.keyValues); again {
+ return false, nil
+ }
+ return false, ierr
+ }
+ return true, nil
+}
+
+// rowExists reports whether a row matching whereSQL (built from key columns)
+// exists, using portable SELECT 1.
+func (s *sqlStore) rowExists(e rowExecer, table, whereSQL string, keyValues []interface{}) (bool, error) {
+ var one int
+ err := e.QueryRowQ(fmt.Sprintf("SELECT 1 FROM %s WHERE %s", table, whereSQL), keyValues...).Scan(&one)
+ if err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return false, nil
+ }
+ return false, err
+ }
+ return true, nil
+}
+
+// equalityPredicate joins columns into "c1 = ? AND c2 = ?".
+func equalityPredicate(columns []string) string {
+ parts := make([]string, len(columns))
+ for i, c := range columns {
+ parts[i] = c + " = ?"
+ }
+ return strings.Join(parts, " AND ")
+}
+
+// repeatPlaceholders returns "?, ?, ..." with n placeholders.
+func repeatPlaceholders(n int) string {
+ if n <= 0 {
+ return ""
+ }
+ return strings.TrimSuffix(strings.Repeat("?, ", n), ", ")
}
func (s *sqlStore) begin() (*sqlStoreTx, error) {
- tx, err := s.db.Begin()
+ tx, err := s.db.BeginTx(s.ctx, nil)
if err != nil {
return nil, err
}
@@ -105,15 +247,15 @@ type sqlStoreTx struct {
}
func (t *sqlStoreTx) ExecQ(query string, args ...interface{}) (sql.Result, error) {
- return t.tx.Exec(t.store.bind(query), args...)
+ return t.tx.ExecContext(t.store.ctx, t.store.bind(query), args...)
}
func (t *sqlStoreTx) QueryRowQ(query string, args ...interface{}) *sql.Row {
- return t.tx.QueryRow(t.store.bind(query), args...)
+ return t.tx.QueryRowContext(t.store.ctx, t.store.bind(query), args...)
}
func (t *sqlStoreTx) QueryQ(query string, args ...interface{}) (*sql.Rows, error) {
- return t.tx.Query(t.store.bind(query), args...)
+ return t.tx.QueryContext(t.store.ctx, t.store.bind(query), args...)
}
func (t *sqlStoreTx) Commit() error {
@@ -230,7 +372,7 @@ func (s *sqlStore) SaveConfig(cfg *models.StoredConfig) error {
}
}()
- stmt, err := tx.tx.Prepare(s.bind(query))
+ stmt, err := tx.tx.PrepareContext(s.ctx, s.bind(query))
if err != nil {
return fmt.Errorf("failed to prepare statement: %w", err)
}
@@ -257,7 +399,8 @@ func (s *sqlStore) SaveConfig(cfg *models.StoredConfig) error {
if cfg.CPArtifactID != "" {
cpArtifactID = cfg.CPArtifactID
}
- _, err = stmt.Exec(
+ _, err = stmt.ExecContext(
+ s.ctx,
cfg.UUID,
s.gatewayId,
cfg.DisplayName,
@@ -346,7 +489,7 @@ func (s *sqlStore) UpdateConfig(cfg *models.StoredConfig) error {
}
}()
- stmt, err := tx.tx.Prepare(s.bind(query))
+ stmt, err := tx.tx.PrepareContext(s.ctx, s.bind(query))
if err != nil {
metrics.DatabaseOperationsTotal.WithLabelValues("update", table, "error").Inc()
metrics.StorageErrorsTotal.WithLabelValues("update", "prepare_error").Inc()
@@ -374,7 +517,8 @@ func (s *sqlStore) UpdateConfig(cfg *models.StoredConfig) error {
if cfg.CPArtifactID != "" {
updateCPArtifactID = cfg.CPArtifactID
}
- result, err := stmt.Exec(
+ result, err := stmt.ExecContext(
+ s.ctx,
cfg.DisplayName,
cfg.Version,
cfg.Kind,
@@ -448,29 +592,6 @@ func (s *sqlStore) UpsertConfig(cfg *models.StoredConfig) (bool, error) {
return false, fmt.Errorf("handle (metadata.name) is required and cannot be empty")
}
- // INSERT ... ON CONFLICT upsert with deployed_at guard.
- // The WHERE clause ensures we only overwrite when the incoming deployed_at
- // is strictly newer than the stored value (or the stored value is NULL).
- query := `
- INSERT INTO artifacts (
- uuid, gateway_id, display_name, version, kind, handle,
- desired_state, deployment_id, origin, created_at, updated_at, deployed_at,
- cp_sync_status, cp_sync_info, cp_artifact_id
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
- ON CONFLICT(gateway_id, uuid) DO UPDATE SET
- display_name = excluded.display_name,
- version = excluded.version,
- kind = excluded.kind,
- handle = excluded.handle,
- desired_state = excluded.desired_state,
- deployment_id = excluded.deployment_id,
- origin = excluded.origin,
- updated_at = excluded.updated_at,
- deployed_at = excluded.deployed_at
- WHERE artifacts.deployed_at IS NULL
- OR artifacts.deployed_at < excluded.deployed_at
- `
-
tx, err := s.begin()
if err != nil {
metrics.DatabaseOperationsTotal.WithLabelValues("upsert", table, "error").Inc()
@@ -483,13 +604,6 @@ func (s *sqlStore) UpsertConfig(cfg *models.StoredConfig) (bool, error) {
}
}()
- stmt, err := tx.tx.Prepare(s.bind(query))
- if err != nil {
- metrics.DatabaseOperationsTotal.WithLabelValues("upsert", table, "error").Inc()
- return false, fmt.Errorf("failed to prepare statement: %w", err)
- }
- defer stmt.Close()
-
now := time.Now()
var deploymentID interface{}
if cfg.DeploymentID != "" {
@@ -508,35 +622,42 @@ func (s *sqlStore) UpsertConfig(cfg *models.StoredConfig) (bool, error) {
if cfg.CPArtifactID != "" {
upsertCPArtifactID = cfg.CPArtifactID
}
- result, err := stmt.Exec(
- cfg.UUID,
- s.gatewayId,
- cfg.DisplayName,
- cfg.Version,
- cfg.Kind,
- cfg.Handle,
- cfg.DesiredState,
- deploymentID,
- cfg.Origin,
- now,
- now,
- cfg.DeployedAt,
- upsertCPSyncStatus,
- upsertCPSyncInfo,
- upsertCPArtifactID,
- )
- if err != nil {
- metrics.DatabaseOperationsTotal.WithLabelValues("upsert", table, "error").Inc()
- return false, fmt.Errorf("failed to upsert artifact: %w", err)
- }
- rows, err := result.RowsAffected()
+ // Portable insert-or-update guarded by deployed_at: only overwrite when the
+ // incoming deployed_at is strictly newer (or the stored value is NULL).
+ // cp_sync_* and created_at are written on INSERT only — preserved on update.
+ didWrite, err := s.upsert(tx, upsertSpec{
+ table: "artifacts",
+ columns: []string{
+ "uuid", "gateway_id", "display_name", "version", "kind", "handle",
+ "desired_state", "deployment_id", "origin", "created_at", "updated_at", "deployed_at",
+ "cp_sync_status", "cp_sync_info", "cp_artifact_id",
+ },
+ insertValues: []interface{}{
+ cfg.UUID, s.gatewayId, cfg.DisplayName, cfg.Version, cfg.Kind, cfg.Handle,
+ cfg.DesiredState, deploymentID, cfg.Origin, now, now, cfg.DeployedAt,
+ upsertCPSyncStatus, upsertCPSyncInfo, upsertCPArtifactID,
+ },
+ keyColumns: []string{"gateway_id", "uuid"},
+ keyValues: []interface{}{s.gatewayId, cfg.UUID},
+ setClauses: []string{
+ "display_name = ?", "version = ?", "kind = ?", "handle = ?",
+ "desired_state = ?", "deployment_id = ?", "origin = ?",
+ "updated_at = ?", "deployed_at = ?",
+ },
+ setValues: []interface{}{
+ cfg.DisplayName, cfg.Version, cfg.Kind, cfg.Handle,
+ cfg.DesiredState, deploymentID, cfg.Origin, now, cfg.DeployedAt,
+ },
+ guard: "(deployed_at IS NULL OR deployed_at < ?)",
+ guardValues: []interface{}{cfg.DeployedAt},
+ })
if err != nil {
metrics.DatabaseOperationsTotal.WithLabelValues("upsert", table, "error").Inc()
- return false, fmt.Errorf("failed to get rows affected: %w", err)
+ return false, fmt.Errorf("failed to upsert artifact: %w", err)
}
- if rows == 0 {
+ if !didWrite {
// Stale event — existing row has a newer deployed_at. No-op.
_ = tx.Rollback()
committed = true // prevent double-rollback in defer
@@ -1248,13 +1369,13 @@ func (s *sqlStore) addResourceConfigTx(tx *sqlStoreTx, cfg *models.StoredConfig)
args = []interface{}{cfg.UUID, s.gatewayId, string(configJSON)}
}
- stmt, err := tx.tx.Prepare(s.bind(query))
+ stmt, err := tx.tx.PrepareContext(s.ctx, s.bind(query))
if err != nil {
return false, fmt.Errorf("failed to prepare statement: %w", err)
}
defer stmt.Close()
- _, err = stmt.Exec(args...)
+ _, err = stmt.ExecContext(s.ctx, args...)
if err != nil {
return false, fmt.Errorf("failed to insert resource configuration: %w", err)
}
@@ -1295,13 +1416,13 @@ func (s *sqlStore) updateResourceConfigTx(tx *sqlStoreTx, cfg *models.StoredConf
args = []interface{}{string(configJSON), cfg.UUID, s.gatewayId}
}
- stmt, err := tx.tx.Prepare(s.bind(query))
+ stmt, err := tx.tx.PrepareContext(s.ctx, s.bind(query))
if err != nil {
return false, fmt.Errorf("failed to prepare statement: %w", err)
}
defer stmt.Close()
- result, err := stmt.Exec(args...)
+ result, err := stmt.ExecContext(s.ctx, args...)
if err != nil {
return false, fmt.Errorf("failed to update resource configuration: %w", err)
}
@@ -1317,9 +1438,9 @@ func (s *sqlStore) updateResourceConfigTx(tx *sqlStoreTx, cfg *models.StoredConf
return true, nil
}
-// upsertResourceConfigTx performs INSERT ... ON CONFLICT(gateway_id, uuid)
-// DO UPDATE for the per-resource-type table. Works identically on SQLite
-// (3.24+) and PostgreSQL.
+// upsertResourceConfigTx performs a portable insert-or-update for the
+// per-resource-type table. Uses the shared upsert helper so it runs unchanged
+// on SQLite, PostgreSQL and SQL Server.
func (s *sqlStore) upsertResourceConfigTx(tx *sqlStoreTx, cfg *models.StoredConfig) error {
resourceTable, err := kindToResourceTable(cfg.Kind)
if err != nil {
@@ -1331,8 +1452,15 @@ func (s *sqlStore) upsertResourceConfigTx(tx *sqlStoreTx, cfg *models.StoredConf
return fmt.Errorf("failed to marshal configuration: %w", err)
}
- var query string
- var args []interface{}
+ spec := upsertSpec{
+ table: resourceTable,
+ columns: []string{"uuid", "gateway_id", "configuration"},
+ insertValues: []interface{}{cfg.UUID, s.gatewayId, string(configJSON)},
+ keyColumns: []string{"gateway_id", "uuid"},
+ keyValues: []interface{}{s.gatewayId, cfg.UUID},
+ setClauses: []string{"configuration = ?"},
+ setValues: []interface{}{string(configJSON)},
+ }
if cfg.Kind == "LlmProxy" {
proxyConfig, ok := cfg.SourceConfiguration.(api.LLMProxyConfiguration)
@@ -1343,29 +1471,13 @@ func (s *sqlStore) upsertResourceConfigTx(tx *sqlStoreTx, cfg *models.StoredConf
if err != nil {
return fmt.Errorf("failed to resolve provider: %w", err)
}
- query = fmt.Sprintf(`
- INSERT INTO %s (uuid, gateway_id, configuration, provider_uuid) VALUES (?, ?, ?, ?)
- ON CONFLICT(gateway_id, uuid) DO UPDATE SET
- configuration = excluded.configuration,
- provider_uuid = excluded.provider_uuid
- `, resourceTable)
- args = []interface{}{cfg.UUID, s.gatewayId, string(configJSON), providerUUID}
- } else {
- query = fmt.Sprintf(`
- INSERT INTO %s (uuid, gateway_id, configuration) VALUES (?, ?, ?)
- ON CONFLICT(gateway_id, uuid) DO UPDATE SET
- configuration = excluded.configuration
- `, resourceTable)
- args = []interface{}{cfg.UUID, s.gatewayId, string(configJSON)}
+ spec.columns = []string{"uuid", "gateway_id", "configuration", "provider_uuid"}
+ spec.insertValues = []interface{}{cfg.UUID, s.gatewayId, string(configJSON), providerUUID}
+ spec.setClauses = []string{"configuration = ?", "provider_uuid = ?"}
+ spec.setValues = []interface{}{string(configJSON), providerUUID}
}
- stmt, err := tx.tx.Prepare(s.bind(query))
- if err != nil {
- return fmt.Errorf("failed to prepare statement: %w", err)
- }
- defer stmt.Close()
-
- if _, err = stmt.Exec(args...); err != nil {
+ if _, err := s.upsert(tx, spec); err != nil {
return fmt.Errorf("failed to upsert resource configuration: %w", err)
}
@@ -1377,7 +1489,7 @@ func (s *sqlStore) upsertResourceConfigTx(tx *sqlStoreTx, cfg *models.StoredConf
func (s *sqlStore) resolveProviderUUID(tx *sqlStoreTx, providerHandle string) (string, error) {
var uuid string
query := s.bind(`SELECT a.uuid FROM artifacts a WHERE a.handle = ? AND a.gateway_id = ? AND a.kind = 'LlmProvider'`)
- err := tx.tx.QueryRow(query, providerHandle, s.gatewayId).Scan(&uuid)
+ err := tx.tx.QueryRowContext(s.ctx, query, providerHandle, s.gatewayId).Scan(&uuid)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return "", fmt.Errorf("provider '%s' not found for gateway '%s'", providerHandle, s.gatewayId)
@@ -1881,41 +1993,42 @@ func (s *sqlStore) SaveAPIKey(apiKey *models.APIKey) error {
// source is preserved from the existing row when it is already set.
// external_ref_id falls back to the existing value when the incoming one is NULL.
func (s *sqlStore) UpsertAPIKey(apiKey *models.APIKey) error {
- query := `
- INSERT INTO api_keys (
- uuid, gateway_id, name, api_key, masked_api_key, artifact_uuid, status,
- created_at, created_by, updated_at, expires_at,
- source, external_ref_id, issuer
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
- ON CONFLICT(gateway_id, artifact_uuid, name) DO UPDATE SET
- uuid = excluded.uuid,
- api_key = excluded.api_key,
- masked_api_key = excluded.masked_api_key,
- status = excluded.status,
- updated_at = excluded.updated_at,
- expires_at = excluded.expires_at,
- source = CASE WHEN api_keys.source != '' THEN api_keys.source ELSE excluded.source END,
- external_ref_id = COALESCE(excluded.external_ref_id, api_keys.external_ref_id),
- issuer = CASE WHEN api_keys.issuer != '' THEN api_keys.issuer ELSE excluded.issuer END
- WHERE api_keys.updated_at < excluded.updated_at
- `
-
- _, err := s.exec(query,
- apiKey.UUID,
- s.gatewayId,
- apiKey.Name,
- apiKey.APIKey,
- apiKey.MaskedAPIKey,
- apiKey.ArtifactUUID,
- apiKey.Status,
- apiKey.CreatedAt,
- apiKey.CreatedBy,
- apiKey.UpdatedAt,
- apiKey.ExpiresAt,
- apiKey.Source,
- apiKey.ExternalRefId,
- apiKey.Issuer,
- )
+ // Portable upsert keyed on (gateway_id, artifact_uuid, name), guarded so a
+ // racing event that already wrote a newer record is never overwritten.
+ // On update: source/issuer keep the existing value when already set, and
+ // external_ref_id falls back to the existing value when the incoming is NULL.
+ _, err := s.upsert(s, upsertSpec{
+ table: "api_keys",
+ columns: []string{
+ "uuid", "gateway_id", "name", "api_key", "masked_api_key", "artifact_uuid", "status",
+ "created_at", "created_by", "updated_at", "expires_at",
+ "source", "external_ref_id", "issuer",
+ },
+ insertValues: []interface{}{
+ apiKey.UUID, s.gatewayId, apiKey.Name, apiKey.APIKey, apiKey.MaskedAPIKey, apiKey.ArtifactUUID, apiKey.Status,
+ apiKey.CreatedAt, apiKey.CreatedBy, apiKey.UpdatedAt, apiKey.ExpiresAt,
+ apiKey.Source, apiKey.ExternalRefId, apiKey.Issuer,
+ },
+ keyColumns: []string{"gateway_id", "artifact_uuid", "name"},
+ keyValues: []interface{}{s.gatewayId, apiKey.ArtifactUUID, apiKey.Name},
+ setClauses: []string{
+ "uuid = ?",
+ "api_key = ?",
+ "masked_api_key = ?",
+ "status = ?",
+ "updated_at = ?",
+ "expires_at = ?",
+ "source = CASE WHEN source != '' THEN source ELSE ? END",
+ "external_ref_id = COALESCE(?, external_ref_id)",
+ "issuer = CASE WHEN issuer != '' THEN issuer ELSE ? END",
+ },
+ setValues: []interface{}{
+ apiKey.UUID, apiKey.APIKey, apiKey.MaskedAPIKey, apiKey.Status, apiKey.UpdatedAt, apiKey.ExpiresAt,
+ apiKey.Source, apiKey.ExternalRefId, apiKey.Issuer,
+ },
+ guard: "updated_at < ?",
+ guardValues: []interface{}{apiKey.UpdatedAt},
+ })
if err != nil {
if s.isUniqueViolation(err) {
return fmt.Errorf("%w: API key value already exists", ErrConflict)
@@ -1942,7 +2055,6 @@ func (s *sqlStore) GetAPIKeyByID(id string) (*models.APIKey, error) {
LEFT JOIN applications app
ON app.application_uuid = aak.application_uuid AND app.gateway_id = aak.gateway_id
WHERE ak.uuid = ? AND ak.gateway_id = ?
- LIMIT 1
`
var apiKey models.APIKey
@@ -2004,7 +2116,6 @@ func (s *sqlStore) GetAPIKeyByUUID(uuid string) (*models.APIKey, error) {
issuer
FROM api_keys
WHERE uuid = ? AND gateway_id = ?
- LIMIT 1
`
var apiKey models.APIKey
@@ -2156,7 +2267,6 @@ func (s *sqlStore) GetAPIKeysByAPIAndName(apiId, name string) (*models.APIKey, e
issuer
FROM api_keys
WHERE artifact_uuid = ? AND name = ? AND gateway_id = ?
- LIMIT 1
`
var apiKey models.APIKey
@@ -2377,17 +2487,15 @@ func (s *sqlStore) ReplaceApplicationAPIKeyMappings(application *models.StoredAp
}
rows.Close()
- if _, err = tx.ExecQ(`
- INSERT INTO applications (
- application_uuid, gateway_id, application_id, application_name, application_type, created_at, updated_at
- )
- VALUES (?, ?, ?, ?, ?, ?, ?)
- ON CONFLICT(gateway_id, application_uuid) DO UPDATE SET
- application_id = excluded.application_id,
- application_name = excluded.application_name,
- application_type = excluded.application_type,
- updated_at = excluded.updated_at
- `, application.ApplicationUUID, s.gatewayId, application.ApplicationID, application.ApplicationName, application.ApplicationType, now, now); err != nil {
+ if _, err = s.upsert(tx, upsertSpec{
+ table: "applications",
+ columns: []string{"application_uuid", "gateway_id", "application_id", "application_name", "application_type", "created_at", "updated_at"},
+ insertValues: []interface{}{application.ApplicationUUID, s.gatewayId, application.ApplicationID, application.ApplicationName, application.ApplicationType, now, now},
+ keyColumns: []string{"gateway_id", "application_uuid"},
+ keyValues: []interface{}{s.gatewayId, application.ApplicationUUID},
+ setClauses: []string{"application_id = ?", "application_name = ?", "application_type = ?", "updated_at = ?"},
+ setValues: []interface{}{application.ApplicationID, application.ApplicationName, application.ApplicationType, now},
+ }); err != nil {
_ = tx.Rollback()
return nil, fmt.Errorf("failed to upsert application metadata: %w", err)
}
@@ -2457,6 +2565,11 @@ func (s *sqlStore) Close() error {
backend = "SQL"
}
s.logger.Info("Closing storage", slog.String("backend", backend))
+ // Cancel the lifecycle context first so any in-flight queries are
+ // interrupted instead of blocking the underlying db.Close().
+ if s.cancel != nil {
+ s.cancel()
+ }
if err := s.db.Close(); err != nil {
return fmt.Errorf("failed to close database: %w", err)
}
@@ -3084,15 +3197,34 @@ func (s *sqlStore) UpdateSecret(secret *models.Secret) (*models.Secret, error) {
startTime := time.Now()
table := "secrets"
- query := `
- UPDATE secrets
- SET display_name = ?, description = ?, ciphertext = ?, updated_at = ?
- WHERE gateway_id = ? AND handle = ?
- RETURNING handle, display_name, description, ciphertext, created_at, updated_at
- `
-
+ // Portable update-then-read (no RETURNING): UPDATE, confirm a row matched,
+ // then SELECT the stored row to read back its timestamps. Both run in one
+ // transaction so the row read back is exactly the one written here, rather
+ // than a value a concurrent writer may have stored between the two statements.
now := time.Now().UTC()
- row := s.queryRow(query,
+
+ tx, err := s.begin()
+ if err != nil {
+ metrics.DatabaseOperationsTotal.WithLabelValues("update", table, "error").Inc()
+ metrics.StorageErrorsTotal.WithLabelValues("update", "exec_error").Inc()
+ s.logger.Error("Failed to begin transaction to update secret",
+ slog.String("secret_handle", secret.Handle),
+ slog.Any("error", err),
+ )
+ return nil, fmt.Errorf("failed to update secret: %w", err)
+ }
+ committed := false
+ defer func() {
+ if !committed {
+ _ = tx.Rollback()
+ }
+ }()
+
+ res, err := tx.ExecQ(`
+ UPDATE secrets
+ SET display_name = ?, description = ?, ciphertext = ?, updated_at = ?
+ WHERE gateway_id = ? AND handle = ?
+ `,
secret.DisplayName,
secret.Description,
secret.Ciphertext,
@@ -3100,9 +3232,21 @@ func (s *sqlStore) UpdateSecret(secret *models.Secret) (*models.Secret, error) {
s.gatewayId,
secret.Handle,
)
+ if err == nil {
+ var n int64
+ if n, err = res.RowsAffected(); err == nil && n == 0 {
+ err = sql.ErrNoRows
+ }
+ }
var updated models.Secret
- err := row.Scan(&updated.Handle, &updated.DisplayName, &updated.Description, &updated.Ciphertext, &updated.CreatedAt, &updated.UpdatedAt)
+ if err == nil {
+ row := tx.QueryRowQ(`
+ SELECT handle, display_name, description, ciphertext, created_at, updated_at
+ FROM secrets WHERE gateway_id = ? AND handle = ?
+ `, s.gatewayId, secret.Handle)
+ err = row.Scan(&updated.Handle, &updated.DisplayName, &updated.Description, &updated.Ciphertext, &updated.CreatedAt, &updated.UpdatedAt)
+ }
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
metrics.DatabaseOperationsTotal.WithLabelValues("update", table, "error").Inc()
@@ -3118,6 +3262,17 @@ func (s *sqlStore) UpdateSecret(secret *models.Secret) (*models.Secret, error) {
return nil, fmt.Errorf("failed to update secret: %w", err)
}
+ if err = tx.Commit(); err != nil {
+ metrics.DatabaseOperationsTotal.WithLabelValues("update", table, "error").Inc()
+ metrics.StorageErrorsTotal.WithLabelValues("update", "exec_error").Inc()
+ s.logger.Error("Failed to commit secret update",
+ slog.String("secret_handle", secret.Handle),
+ slog.Any("error", err),
+ )
+ return nil, fmt.Errorf("failed to update secret: %w", err)
+ }
+ committed = true
+
metrics.DatabaseOperationsTotal.WithLabelValues("update", table, "success").Inc()
metrics.DatabaseOperationDurationSeconds.WithLabelValues("update", table).Observe(time.Since(startTime).Seconds())
@@ -3171,7 +3326,7 @@ func (s *sqlStore) DeleteSecret(handle string) error {
// SecretExists checks if a secret with the given handle exists
func (s *sqlStore) SecretExists(handle string) (bool, error) {
- query := `SELECT EXISTS(SELECT 1 FROM secrets WHERE gateway_id = ? AND handle = ?)`
+ query := `SELECT CASE WHEN EXISTS(SELECT 1 FROM secrets WHERE gateway_id = ? AND handle = ?) THEN CAST(1 AS bit) ELSE CAST(0 AS bit) END`
var exists bool
err := s.queryRow(query, s.gatewayId, handle).Scan(&exists)
diff --git a/gateway/gateway-controller/pkg/storage/sqlserver.go b/gateway/gateway-controller/pkg/storage/sqlserver.go
new file mode 100644
index 000000000..9b6d77553
--- /dev/null
+++ b/gateway/gateway-controller/pkg/storage/sqlserver.go
@@ -0,0 +1,305 @@
+/*
+ * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com).
+ *
+ * WSO2 LLC. licenses this file to you under the Apache License,
+ * Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package storage
+
+import (
+ "context"
+ "database/sql"
+ _ "embed"
+ "errors"
+ "fmt"
+ "log/slog"
+ "net/url"
+ "strconv"
+ "regexp"
+ "strings"
+ "time"
+
+ mssql "github.com/microsoft/go-mssqldb"
+)
+
+//go:embed gateway-controller-db.sqlserver.sql
+var sqlserverSchemaSQL string
+
+const (
+ // sqlserverSchemaLockResource names the application lock used to serialize
+ // concurrent schema initialization across controller replicas.
+ sqlserverSchemaLockResource = "gateway-controller-schema-init"
+ // SQL Server error numbers for unique-constraint / duplicate-key violations.
+ sqlserverUniqueConstraintErr = 2627 // PRIMARY KEY / UNIQUE constraint violation
+ sqlserverDuplicateKeyErr = 2601 // unique index violation
+ // defaultSQLServerEncrypt is the connection-level fallback for the "encrypt"
+ // option, applied when the caller leaves it unset (e.g. storage used directly
+ // in tests). The config layer normalizes/validates the value before this for
+ // config-driven startup; this keeps the storage layer self-sufficient.
+ defaultSQLServerEncrypt = "true"
+ // schemaInitTimeout bounds the schema-initialization path (connection,
+ // application lock and DDL) so a stalled server cannot hang startup forever.
+ // It must exceed the 30s app-lock wait used in initSchema.
+ schemaInitTimeout = 2 * time.Minute
+)
+
+// SQLServerConnectionConfig holds SQL Server-specific connection settings.
+type SQLServerConnectionConfig struct {
+ DSN string
+ Host string
+ Port int
+ Database string
+ User string
+ Password string
+ Encrypt string // "disable", "false", "true", or "strict"
+ TrustServerCertificate bool
+ ConnectTimeout time.Duration
+ MaxOpenConns int
+ MaxIdleConns int
+ ConnMaxLifetime time.Duration
+ ConnMaxIdleTime time.Duration
+ ApplicationName string
+}
+
+// SQLServerStorage implements the Storage interface using Microsoft SQL Server.
+type SQLServerStorage struct {
+ db *sql.DB
+ logger *slog.Logger
+}
+
+// newSQLServerStorage creates a new SQL Server storage instance.
+func newSQLServerStorage(cfg SQLServerConnectionConfig, logger *slog.Logger) (*SQLServerStorage, error) {
+ cfg = withDefaultSQLServerConfig(cfg)
+ dsn, err := buildSQLServerDSN(cfg)
+ if err != nil {
+ return nil, fmt.Errorf("failed to build sqlserver dsn: %w", err)
+ }
+
+ db, err := sql.Open("sqlserver", dsn)
+ if err != nil {
+ return nil, fmt.Errorf("failed to open sqlserver database: %w", err)
+ }
+
+ db.SetMaxOpenConns(cfg.MaxOpenConns)
+ db.SetMaxIdleConns(cfg.MaxIdleConns)
+ db.SetConnMaxLifetime(cfg.ConnMaxLifetime)
+ db.SetConnMaxIdleTime(cfg.ConnMaxIdleTime)
+
+ storage := &SQLServerStorage{
+ db: db,
+ logger: logger,
+ }
+
+ pingTimeout := cfg.ConnectTimeout
+ if pingTimeout <= 0 {
+ pingTimeout = 5 * time.Second
+ }
+ pingCtx, cancel := context.WithTimeout(context.Background(), pingTimeout)
+ defer cancel()
+ if err := db.PingContext(pingCtx); err != nil {
+ _ = db.Close()
+ return nil, fmt.Errorf("failed to ping sqlserver database: %w", err)
+ }
+
+ if err := storage.initSchema(); err != nil {
+ _ = db.Close()
+ return nil, fmt.Errorf("failed to initialize schema: %w", err)
+ }
+
+ logger.Info("SQLServer storage initialized",
+ slog.String("host", cfg.Host),
+ slog.Int("port", cfg.Port),
+ slog.String("database", cfg.Database),
+ slog.String("encrypt", cfg.Encrypt),
+ slog.Bool("trust_server_certificate", cfg.TrustServerCertificate),
+ slog.Int("max_open_conns", cfg.MaxOpenConns),
+ slog.Int("max_idle_conns", cfg.MaxIdleConns),
+ slog.Duration("conn_max_lifetime", cfg.ConnMaxLifetime),
+ slog.Duration("conn_max_idle_time", cfg.ConnMaxIdleTime),
+ slog.String("dsn", sanitizeSQLServerDSN(dsn)))
+
+ return storage, nil
+}
+
+// initSchema creates the database schema if it doesn't exist. The embedded
+// schema is idempotent (every object is guarded by IF NOT EXISTS); an
+// application lock serializes concurrent initialization across replicas.
+func (s *SQLServerStorage) initSchema() (retErr error) {
+ // Bound the whole init path (connection acquisition, app-lock wait and DDL)
+ // so startup cannot hang indefinitely if SQL Server stalls after the ping.
+ ctx, cancel := context.WithTimeout(context.Background(), schemaInitTimeout)
+ defer cancel()
+
+ conn, err := s.db.Conn(ctx)
+ if err != nil {
+ return fmt.Errorf("failed to acquire sqlserver connection for schema init: %w", err)
+ }
+ defer func() {
+ if closeErr := conn.Close(); closeErr != nil {
+ if retErr != nil {
+ retErr = fmt.Errorf("%w; failed to close schema init connection: %v", retErr, closeErr)
+ } else {
+ retErr = fmt.Errorf("failed to close schema init connection: %w", closeErr)
+ }
+ }
+ }()
+
+ // Serialize schema init across replicas with a session-scoped application lock.
+ // sp_getapplock reports failure (e.g. a lock-wait timeout) only through its
+ // integer return code, not via a driver error, so capture and inspect it.
+ // Non-negative means granted (0 = granted, 1 = granted after waiting);
+ // negative codes (-1 timeout, -2 cancelled, -3 deadlock, -999 other) are failures.
+ var lockStatus mssql.ReturnStatus
+ if _, err := conn.ExecContext(ctx,
+ "EXEC sp_getapplock @Resource = @p1, @LockMode = 'Exclusive', @LockOwner = 'Session', @LockTimeout = 30000",
+ sqlserverSchemaLockResource, &lockStatus); err != nil {
+ return fmt.Errorf("failed to acquire schema init lock: %w", err)
+ }
+ if lockStatus < 0 {
+ return fmt.Errorf("failed to acquire schema init lock: sp_getapplock returned %d", int(lockStatus))
+ }
+ defer func() {
+ if _, unlockErr := conn.ExecContext(ctx,
+ "EXEC sp_releaseapplock @Resource = @p1, @LockOwner = 'Session'",
+ sqlserverSchemaLockResource); unlockErr != nil {
+ if retErr != nil {
+ retErr = fmt.Errorf("%w; failed to release schema init lock: %v", retErr, unlockErr)
+ } else {
+ retErr = fmt.Errorf("failed to release schema init lock: %w", unlockErr)
+ }
+ }
+ }()
+
+ s.logger.Info("Initializing SQLServer schema")
+ if _, err := conn.ExecContext(ctx, sqlserverSchemaSQL); err != nil {
+ return fmt.Errorf("failed to execute sqlserver schema: %w", err)
+ }
+
+ s.logger.Info("SQLServer schema initialized")
+ return nil
+}
+
+func withDefaultSQLServerConfig(cfg SQLServerConnectionConfig) SQLServerConnectionConfig {
+ if cfg.Port == 0 {
+ cfg.Port = 1433
+ }
+ if cfg.Encrypt == "" {
+ cfg.Encrypt = defaultSQLServerEncrypt
+ }
+ if cfg.ConnectTimeout <= 0 {
+ cfg.ConnectTimeout = 5 * time.Second
+ }
+ if cfg.MaxOpenConns == 0 {
+ cfg.MaxOpenConns = 25
+ }
+ if cfg.MaxIdleConns == 0 {
+ cfg.MaxIdleConns = 5
+ }
+ if cfg.ConnMaxLifetime == 0 {
+ cfg.ConnMaxLifetime = 30 * time.Minute
+ }
+ if cfg.ConnMaxIdleTime == 0 {
+ cfg.ConnMaxIdleTime = 5 * time.Minute
+ }
+ if cfg.ApplicationName == "" {
+ cfg.ApplicationName = "gateway-controller"
+ }
+ return cfg
+}
+
+func buildSQLServerDSN(cfg SQLServerConnectionConfig) (string, error) {
+ if strings.TrimSpace(cfg.DSN) != "" {
+ return cfg.DSN, nil
+ }
+ if cfg.Host == "" || cfg.Database == "" || cfg.User == "" {
+ return "", fmt.Errorf("host, database and user are required when dsn is not provided")
+ }
+ u := &url.URL{
+ Scheme: "sqlserver",
+ User: url.UserPassword(cfg.User, cfg.Password),
+ Host: fmt.Sprintf("%s:%d", cfg.Host, cfg.Port),
+ }
+ q := u.Query()
+ q.Set("database", cfg.Database)
+ if cfg.Encrypt != "" {
+ q.Set("encrypt", cfg.Encrypt)
+ }
+ q.Set("TrustServerCertificate", strconv.FormatBool(cfg.TrustServerCertificate))
+ timeoutSec := int(cfg.ConnectTimeout.Seconds())
+ if timeoutSec <= 0 {
+ timeoutSec = 5
+ }
+ q.Set("connection timeout", strconv.Itoa(timeoutSec))
+ q.Set("dial timeout", strconv.Itoa(timeoutSec))
+ if cfg.ApplicationName != "" {
+ q.Set("app name", cfg.ApplicationName)
+ }
+ u.RawQuery = q.Encode()
+ return u.String(), nil
+}
+
+// sqlServerSemicolonPasswordRe matches the password key in ADO/ODBC-style
+// (semicolon-separated) DSNs, e.g. "server=h;password=secret;..." — both
+// "password" and "pwd", any case — so the value can be redacted before logging.
+var sqlServerSemicolonPasswordRe = regexp.MustCompile(`(?i)\b(password|pwd)\s*=[^;]*`)
+
+func sanitizeSQLServerDSN(dsn string) string {
+ // go-mssqldb also accepts ADO ("server=...;password=...") and ODBC
+ // ("odbc:...;pwd=...") DSNs, which url.Parse does not understand. Redact the
+ // password token directly for those so it never reaches the logs.
+ if strings.Contains(dsn, ";") || !strings.Contains(dsn, "://") {
+ return sqlServerSemicolonPasswordRe.ReplaceAllString(dsn, "${1}=****")
+ }
+ u, err := url.Parse(dsn)
+ if err != nil {
+ return ""
+ }
+ if u.User != nil {
+ username := u.User.Username()
+ if username != "" {
+ u.User = url.UserPassword(username, "****")
+ }
+ }
+ // go-mssqldb also accepts the password as a URL query parameter
+ // (e.g. sqlserver://host?user id=sa&password=secret), which is not part of
+ // the userinfo above — redact those too.
+ if q := u.Query(); len(q) > 0 {
+ changed := false
+ for key := range q {
+ switch strings.ToLower(key) {
+ case "password", "pwd":
+ q.Set(key, "****")
+ changed = true
+ }
+ }
+ if changed {
+ u.RawQuery = q.Encode()
+ }
+ }
+ return u.String()
+}
+
+// isSQLServerUniqueConstraintError reports whether err is a unique-constraint or
+// duplicate-key violation reported by SQL Server.
+func isSQLServerUniqueConstraintError(err error) bool {
+ if err == nil {
+ return false
+ }
+ var mssqlErr mssql.Error
+ if errors.As(err, &mssqlErr) {
+ return mssqlErr.Number == sqlserverUniqueConstraintErr || mssqlErr.Number == sqlserverDuplicateKeyErr
+ }
+ return false
+}
diff --git a/gateway/gateway-controller/pkg/storage/sqlserver_test.go b/gateway/gateway-controller/pkg/storage/sqlserver_test.go
new file mode 100644
index 000000000..9eed91520
--- /dev/null
+++ b/gateway/gateway-controller/pkg/storage/sqlserver_test.go
@@ -0,0 +1,170 @@
+/*
+ * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com).
+ *
+ * WSO2 LLC. licenses this file to you under the Apache License,
+ * Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package storage
+
+import (
+ "errors"
+ "io"
+ "log/slog"
+ "os"
+ "strings"
+ "testing"
+
+ "github.com/wso2/api-platform/gateway/gateway-controller/pkg/metrics"
+ "gotest.tools/v3/assert"
+)
+
+func setupTestSQLServerStorage(t *testing.T) Storage {
+ t.Helper()
+
+ dsn := os.Getenv("SQLSERVER_TEST_DSN")
+ if dsn == "" {
+ t.Skip("SQLSERVER_TEST_DSN is not set; skipping sqlserver integration tests")
+ }
+
+ prevMetricsEnabled := metrics.IsEnabled()
+ t.Cleanup(func() { metrics.SetEnabled(prevMetricsEnabled) })
+ metrics.SetEnabled(false)
+ metrics.Init()
+
+ logger := slog.New(slog.NewTextHandler(io.Discard, nil))
+ ms, err := NewStorage(BackendConfig{Type: "sqlserver", SQLServer: SQLServerConnectionConfig{DSN: dsn}}, logger)
+ assert.NilError(t, err)
+ // Registered first so it runs last (t.Cleanup is LIFO) — after any
+ // per-test row cleanups that still need the connection.
+ t.Cleanup(func() { _ = ms.Close() })
+ return ms
+}
+
+func TestNewSQLServerStorage_Success(t *testing.T) {
+ ms := setupTestSQLServerStorage(t)
+ assert.Assert(t, ms != nil)
+}
+
+func TestSQLServerStorage_ConfigCRUD(t *testing.T) {
+ ms := setupTestSQLServerStorage(t)
+
+ cfg := createTestStoredConfig()
+ assert.NilError(t, ms.SaveConfig(cfg))
+ t.Cleanup(func() {
+ if err := ms.DeleteConfig(cfg.UUID); err != nil && !IsNotFoundError(err) {
+ t.Errorf("cleanup DeleteConfig(%s): %v", cfg.UUID, err)
+ }
+ })
+
+ stored, err := ms.GetConfig(cfg.UUID)
+ assert.NilError(t, err)
+ assert.Equal(t, stored.UUID, cfg.UUID)
+ assert.Equal(t, stored.Handle, cfg.Handle)
+
+ assert.NilError(t, ms.DeleteConfig(cfg.UUID))
+ _, err = ms.GetConfig(cfg.UUID)
+ assert.Assert(t, err != nil)
+ assert.Assert(t, IsNotFoundError(err))
+}
+
+func TestSQLServerStorage_TemplateAndAPIKeyCRUD(t *testing.T) {
+ ms := setupTestSQLServerStorage(t)
+
+ tmpl := createTestLLMProviderTemplate()
+ assert.NilError(t, ms.SaveLLMProviderTemplate(tmpl))
+ t.Cleanup(func() {
+ if err := ms.DeleteLLMProviderTemplate(tmpl.UUID); err != nil && !IsNotFoundError(err) {
+ t.Errorf("cleanup DeleteLLMProviderTemplate(%s): %v", tmpl.UUID, err)
+ }
+ })
+
+ loadedTemplate, err := ms.GetLLMProviderTemplate(tmpl.UUID)
+ assert.NilError(t, err)
+ assert.Equal(t, loadedTemplate.UUID, tmpl.UUID)
+ assert.Equal(t, loadedTemplate.GetHandle(), tmpl.GetHandle())
+
+ cfg := createTestStoredConfig()
+ assert.NilError(t, ms.SaveConfig(cfg))
+ t.Cleanup(func() {
+ if err := ms.DeleteConfig(cfg.UUID); err != nil && !IsNotFoundError(err) {
+ t.Errorf("cleanup DeleteConfig(%s): %v", cfg.UUID, err)
+ }
+ })
+
+ apiKey := createTestAPIKey()
+ apiKey.ArtifactUUID = cfg.UUID
+ apiKey.Source = "local"
+
+ preInsertCount, err := ms.CountActiveAPIKeysByUserAndAPI(apiKey.ArtifactUUID, apiKey.CreatedBy)
+ assert.NilError(t, err)
+
+ assert.NilError(t, ms.SaveAPIKey(apiKey))
+ t.Cleanup(func() {
+ if err := ms.RemoveAPIKeyAPIAndName(apiKey.ArtifactUUID, apiKey.Name); err != nil && !IsNotFoundError(err) {
+ t.Errorf("cleanup RemoveAPIKeyAPIAndName(%s, %s): %v", apiKey.ArtifactUUID, apiKey.Name, err)
+ }
+ })
+
+ loadedKey, err := ms.GetAPIKeyByID(apiKey.UUID)
+ assert.NilError(t, err)
+ assert.Equal(t, loadedKey.UUID, apiKey.UUID)
+ assert.Equal(t, loadedKey.ArtifactUUID, apiKey.ArtifactUUID)
+
+ postInsertCount, err := ms.CountActiveAPIKeysByUserAndAPI(apiKey.ArtifactUUID, apiKey.CreatedBy)
+ assert.NilError(t, err)
+ assert.Equal(t, postInsertCount, preInsertCount+1)
+}
+
+func TestSQLServerStorage_SaveLLMProviderTemplate_UniqueConstraintError(t *testing.T) {
+ ms := setupTestSQLServerStorage(t)
+
+ template := createTestLLMProviderTemplate()
+ assert.NilError(t, ms.SaveLLMProviderTemplate(template))
+ t.Cleanup(func() {
+ if err := ms.DeleteLLMProviderTemplate(template.UUID); err != nil && !IsNotFoundError(err) {
+ t.Errorf("cleanup DeleteLLMProviderTemplate(%s): %v", template.UUID, err)
+ }
+ })
+
+ conflictingTemplate := createTestLLMProviderTemplate()
+ conflictingTemplate.Configuration.Metadata.Name = template.Configuration.Metadata.Name
+
+ err := ms.SaveLLMProviderTemplate(conflictingTemplate)
+ assert.Assert(t, errors.Is(err, ErrConflict))
+}
+
+// TestSanitizeSQLServerDSN verifies passwords are redacted before logging across
+// the DSN formats go-mssqldb accepts: URL (userinfo and query), ADO and ODBC
+// (semicolon-separated). This is a pure-unit test (no database required).
+func TestSanitizeSQLServerDSN(t *testing.T) {
+ cases := []struct {
+ name string
+ dsn string
+ }{
+ {"url userinfo", "sqlserver://sa:secret@host:1433?database=db"},
+ {"url query password", "sqlserver://host:1433?user+id=sa&password=secret&database=db"},
+ {"ado semicolon", "server=host;user id=sa;password=secret;encrypt=disable"},
+ {"odbc semicolon", "odbc:server=host;uid=sa;pwd=secret;"},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ got := sanitizeSQLServerDSN(tc.dsn)
+ assert.Assert(t, !strings.Contains(got, "secret"),
+ "password leaked in sanitized DSN: %q", got)
+ assert.Assert(t, got != tc.dsn,
+ "DSN was returned unchanged (password not redacted): %q", got)
+ })
+ }
+}
diff --git a/gateway/it/Makefile b/gateway/it/Makefile
index eda872fc1..44cd0866c 100644
--- a/gateway/it/Makefile
+++ b/gateway/it/Makefile
@@ -16,7 +16,7 @@
# under the License.
# --------------------------------------------------------------------
-.PHONY: all test test-postgres test-vhosts-single test-vhosts-multi test-all test-verbose clean deps check-docker coverage-report build-coverage ensure-test-tags
+.PHONY: all test test-postgres test-sqlserver test-vhosts-single test-vhosts-multi test-all test-verbose clean deps check-docker coverage-report build-coverage ensure-test-tags
VERSION ?= $(shell cat ../VERSION 2>/dev/null | tr -d '[:space:]' || echo "0.0.1-SNAPSHOT")
DOCKER_REGISTRY ?= ghcr.io/wso2/api-platform
@@ -64,6 +64,10 @@ test-all: build-coverage test
test-postgres:
COMPOSE_FILE=docker-compose.test.postgres.yaml IT_GATEWAY_CONTROLLER_HA=true $(MAKE) test
+# Run integration tests against SQL Server-backed controller
+test-sqlserver:
+ COMPOSE_FILE=docker-compose.test.sqlserver.yaml $(MAKE) test
+
# Run vhost integration tests with single-domain gateway vhost config
test-vhosts-single:
COMPOSE_FILE=docker-compose.test.vhosts-single.yaml IT_FEATURE_PATHS=features/vhost-routing-single.feature $(MAKE) test
@@ -81,6 +85,7 @@ clean:
docker rm -f $$(docker ps -aq --filter "name=it-") 2>/dev/null || true
docker compose -f docker-compose.test.yaml -p gateway-it down -v --remove-orphans 2>/dev/null || true
docker compose -f docker-compose.test.postgres.yaml -p gateway-it down -v --remove-orphans 2>/dev/null || true
+ docker compose -f docker-compose.test.sqlserver.yaml -p gateway-it down -v --remove-orphans 2>/dev/null || true
docker compose -f docker-compose.test.vhosts-single.yaml -p gateway-it down -v --remove-orphans 2>/dev/null || true
docker compose -f docker-compose.test.vhosts-multi.yaml -p gateway-it down -v --remove-orphans 2>/dev/null || true
docker compose -f docker-compose.test.postgres.vhosts-single.yaml -p gateway-it down -v --remove-orphans 2>/dev/null || true
diff --git a/gateway/it/db_helpers.go b/gateway/it/db_helpers.go
index 925ce01a9..2e81f35a4 100644
--- a/gateway/it/db_helpers.go
+++ b/gateway/it/db_helpers.go
@@ -39,6 +39,10 @@ const (
// Present in docker-compose.test.postgres.yaml only. Has psql built-in.
postgresContainer = "it-postgres"
+ // sqlserverContainer is the SQL Server service used by the sqlserver compose.
+ // Present in docker-compose.test.sqlserver.yaml only. Has sqlcmd built-in.
+ sqlserverContainer = "it-sqlserver"
+
// gatewayDBPath is the SQLite database path inside dbReaderContainer.
gatewayDBPath = "/data/gateway.db"
@@ -47,6 +51,15 @@ const (
postgresDB = "gateway_test"
postgresUser = "gateway"
+ // sqlserverDB / sqlserverUser match the credentials in
+ // docker-compose.test.sqlserver.yaml. The SA password is read from the
+ // MSSQL_SA_PASSWORD env var, falling back to the same default the compose
+ // file uses so local runs work without exporting it.
+ sqlserverDB = "gateway_test"
+ sqlserverUser = "sa"
+ sqlserverDefaultPassword = "Gateway_Strong!Pass123"
+ sqlcmdPath = "/opt/mssql-tools18/bin/sqlcmd"
+
// defaultDBQueryTimeout caps the time allowed for a query (including
// retries) so a stuck reader container can't hang a scenario.
defaultDBQueryTimeout = 10 * time.Second
@@ -91,6 +104,8 @@ func detectDBDriver(ctx context.Context) string {
detected = "sqlite"
} else if containerRunning(ctx, postgresContainer) {
detected = "postgres"
+ } else if containerRunning(ctx, sqlserverContainer) {
+ detected = "sqlserver"
}
if detected != "" {
@@ -182,8 +197,23 @@ func executeQuery(ctx context.Context, query string) (string, error) {
// -A unaligned, -t tuples-only, -X no .psqlrc — produces just the value.
cmd = exec.CommandContext(ctx, "docker", "exec", postgresContainer,
"psql", "-U", postgresUser, "-d", postgresDB, "-AtX", "-c", query)
+ case "sqlserver":
+ // -h -1 no headers; -y 8000 widens the variable-length column display from
+ // sqlcmd's 256-char default to its maximum so the NVARCHAR(MAX) configuration
+ // JSON isn't truncated (test configs are far smaller than 8000); -w 65535
+ // stops long lines from wrapping; -b exits non-zero on error; -C trusts the
+ // self-signed server cert. (-y 0 / -W are rejected alongside -h/-y, and the
+ // caller already TrimSpaces, so neither is used.)
+ pw := os.Getenv("MSSQL_SA_PASSWORD")
+ if pw == "" {
+ pw = sqlserverDefaultPassword
+ }
+ cmd = exec.CommandContext(ctx, "docker", "exec", sqlserverContainer,
+ sqlcmdPath, "-C", "-S", "localhost", "-U", sqlserverUser, "-P", pw,
+ "-d", sqlserverDB, "-h", "-1", "-y", "8000", "-w", "65535", "-b",
+ "-Q", "SET NOCOUNT ON; "+query)
default:
- return "", fmt.Errorf("no DB reader container is running (looked for %q and %q)", dbReaderContainer, postgresContainer)
+ return "", fmt.Errorf("no DB reader container is running (looked for %q, %q and %q)", dbReaderContainer, postgresContainer, sqlserverContainer)
}
out, err := cmd.CombinedOutput()
diff --git a/gateway/it/docker-compose.test.sqlserver.yaml b/gateway/it/docker-compose.test.sqlserver.yaml
new file mode 100644
index 000000000..694cfee52
--- /dev/null
+++ b/gateway/it/docker-compose.test.sqlserver.yaml
@@ -0,0 +1,364 @@
+# --------------------------------------------------------------------
+# Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com).
+#
+# WSO2 LLC. licenses this file to you under the Apache License,
+# Version 2.0 (the "License"); you may not use this file except
+# in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+# --------------------------------------------------------------------
+
+# Integration test Docker Compose configuration for Gateway Runtime, with the
+# gateway-controller backed by SQL Server instead of the default SQLite.
+#
+# This is the SQL Server twin of docker-compose.test.postgres.yaml. Run with:
+# COMPOSE_FILE=docker-compose.test.sqlserver.yaml make test
+#
+# Image note: this uses the official mcr.microsoft.com/mssql/server image, which
+# is amd64-only and runs fine on the amd64 CI runners. On Apple Silicon it dies
+# under emulation — for local runs override the sqlserver/mssql-init images with
+# mcr.microsoft.com/azure-sql-edge (arm64-native) via an extra -f overlay.
+#
+# NOTE: gateway-controller and gateway-runtime are duplicated in docker-compose.test.yaml — keep in sync.
+
+services:
+ # Mock platform-api for subscription-validation IT (mimics platform-api WebSocket events).
+ # It reads the controller's `artifacts` table directly to resolve deployment UUIDs, so it
+ # must point at the same SQL Server database as the controller.
+ mock-platform-api:
+ container_name: it-mock-platform-api
+ image: ghcr.io/wso2/api-platform/mock-platform-api:test
+ build:
+ context: ../../tests/mock-servers/mock-platform-api
+ dockerfile: Dockerfile
+ ports:
+ - "9243:9243" # HTTPS/WebSocket (gateway connects)
+ - "9244:9244" # HTTP inject endpoint (IT triggers subscription.created)
+ environment:
+ - DB_TYPE=sqlserver
+ - DB_HOST=sqlserver
+ - DB_PORT=1433
+ - DB_NAME=gateway_test
+ - DB_USER=sa
+ - DB_PASSWORD=${MSSQL_SA_PASSWORD:-Gateway_Strong!Pass123}
+ - DB_ENCRYPT=disable
+ - TLS_CERT=/app/certs/default-listener.crt
+ - TLS_KEY=/app/certs/default-listener.key
+ volumes:
+ - ../gateway-controller/listener-certs:/app/certs:ro
+ depends_on:
+ sqlserver:
+ condition: service_healthy
+ mssql-init:
+ condition: service_completed_successfully
+ networks:
+ - it-gateway-runtime-network
+
+ sqlserver:
+ container_name: it-sqlserver
+ image: mcr.microsoft.com/mssql/server:2022-latest
+ environment:
+ - ACCEPT_EULA=Y
+ - MSSQL_SA_PASSWORD=${MSSQL_SA_PASSWORD:-Gateway_Strong!Pass123}
+ - MSSQL_PID=Developer
+ healthcheck:
+ test: ["CMD-SHELL", "/opt/mssql-tools18/bin/sqlcmd -C -S localhost -U sa -P \"$$MSSQL_SA_PASSWORD\" -Q 'SELECT 1' || exit 1"]
+ interval: 5s
+ timeout: 3s
+ retries: 20
+ start_period: 20s
+ networks:
+ - it-gateway-runtime-network
+
+ # One-shot: SQL Server does not auto-create an application database, so create
+ # gateway_test once the server is healthy. The controller auto-creates its schema.
+ mssql-init:
+ container_name: it-mssql-init
+ image: mcr.microsoft.com/mssql/server:2022-latest
+ depends_on:
+ sqlserver:
+ condition: service_healthy
+ environment:
+ - MSSQL_SA_PASSWORD=${MSSQL_SA_PASSWORD:-Gateway_Strong!Pass123}
+ - MSSQL_DB=gateway_test
+ entrypoint: ["/bin/bash", "-c"]
+ command:
+ - >
+ /opt/mssql-tools18/bin/sqlcmd -C -S sqlserver -U sa -P "$$MSSQL_SA_PASSWORD"
+ -Q "IF DB_ID('$$MSSQL_DB') IS NULL CREATE DATABASE [$$MSSQL_DB]"
+ restart: "no"
+ networks:
+ - it-gateway-runtime-network
+
+ gateway-controller:
+ container_name: it-gateway-controller
+ image: ghcr.io/wso2/api-platform/gateway-controller-coverage:test
+ mem_limit: 1000m
+ mem_reservation: 1000m
+ cpus: 0.5
+ command: ["-config", "/etc/gateway-controller/config.toml"]
+ ports:
+ - "9090:9090" # REST API
+ - "9092:9092" # Admin API
+ - "18000:18000" # xDS gRPC
+ - "18001:18001"
+ - "9091:9091" # Metrics
+ environment:
+ - APIP_GW_CONTROLLER_STORAGE_TYPE=sqlserver
+ - APIP_GW_CONTROLLER_STORAGE_DATABASE_DSN=sqlserver://sa:${MSSQL_SA_PASSWORD:-Gateway_Strong!Pass123}@sqlserver:1433?database=gateway_test&encrypt=disable&TrustServerCertificate=true&app+name=gateway-controller
+ - APIP_GW_CONTROLLER_LOGGING_LEVEL=debug
+ - GOCOVERDIR=/coverage
+ # Control plane (mock-platform-api) for subscription event propagation
+ - APIP_GW_CONTROLLER_CONTROLPLANE_HOST=it-mock-platform-api:9243
+ - APIP_GW_CONTROLLER_CONTROLPLANE_TOKEN=test-gateway-token
+ - APIP_GW_CONTROLLER_CONTROLPLANE_INSECURE_SKIP_VERIFY=true
+ # Used by template-functions IT to verify {{ env "..." }} resolution in spec fields
+ - IT_TEMPLATE_PATH=/anything
+ volumes:
+ - controller-data-tests:/app/data
+ - ./it-aesgcm-keys/default-aesgcm256-v1.bin:/app/data/aesgcm-keys/default-aesgcm256-v1.bin:ro
+ - ./test-config.toml:/etc/gateway-controller/config.toml:ro
+ - ../gateway-controller/certificates:/app/certificates
+ - ../gateway-controller/listener-certs:/app/listener-certs:ro
+ - ./coverage/gateway-controller:/coverage
+ depends_on:
+ sqlserver:
+ condition: service_healthy
+ mssql-init:
+ condition: service_completed_successfully
+ mock-platform-api:
+ condition: service_started
+ healthcheck:
+ test: ["CMD-SHELL", "wget -q -O /dev/null http://localhost:9092/api/admin/v0.9/health || exit 1"]
+ interval: 5s
+ timeout: 3s
+ retries: 10
+ start_period: 10s
+ networks:
+ - it-gateway-runtime-network
+
+ gateway-runtime:
+ container_name: it-gateway-runtime
+ image: ghcr.io/wso2/api-platform/gateway-runtime-coverage:test
+ mem_limit: 2000m
+ mem_reservation: 2000m
+ cpus: 1
+ command: ["--pol.config", "/etc/policy-engine/config.toml"]
+ ports:
+ # Router (Envoy)
+ - "8080:8080" # HTTP ingress
+ - "8443:8443" # HTTPS ingress
+ - "9901:9901" # Envoy admin
+ # Policy Engine
+ - "9002:9002" # Admin API
+ - "9003:9003" # Metrics
+ environment:
+ - GATEWAY_CONTROLLER_HOST=it-gateway-controller
+ - LOG_LEVEL=info
+ # Override AWS Bedrock Runtime endpoint for testing with mock service
+ - AWS_ENDPOINT_URL_BEDROCK_RUNTIME=http://mock-aws-bedrock-guardrail:8080
+ - GOCOVERDIR=/coverage
+ volumes:
+ - ./coverage/gateway-runtime:/coverage
+ - ./test-config.toml:/etc/policy-engine/config.toml:ro
+ depends_on:
+ gateway-controller:
+ condition: service_healthy
+ mock-openapi:
+ condition: service_healthy
+ healthcheck:
+ test: ["CMD", "health-check.sh"]
+ interval: 5s
+ timeout: 3s
+ retries: 10
+ start_period: 15s
+ networks:
+ - it-gateway-runtime-network
+
+ sample-backend:
+ container_name: it-sample-backend
+ image: ghcr.io/wso2/api-platform/sample-service:latest
+ ports:
+ - "9080:9080"
+ command: ["-addr", ":9080", "-pretty"]
+ networks:
+ - it-gateway-runtime-network
+
+ mcp-server-backend:
+ image: rakhitharr/mcp-everything:v3
+ pull_policy: missing
+ container_name: mcp-server-backend
+ ports:
+ - "3001:3001"
+ networks:
+ - it-gateway-runtime-network
+
+ # Echo backend for testing cost extraction - returns request body in response JSON
+ echo-backend:
+ container_name: it-echo-backend
+ image: kennethreitz/httpbin:latest
+ ports:
+ - "9081:80"
+ networks:
+ - it-gateway-runtime-network
+
+ # Mock JWKS server for JWT authentication testing
+ mock-jwks:
+ container_name: it-mock-jwks
+ image: ghcr.io/wso2/api-platform/mock-jwks:latest
+ build:
+ context: ../../tests/mock-servers/mock-jwks
+ dockerfile: Dockerfile
+ ports:
+ - "8082:8080"
+ networks:
+ - it-gateway-runtime-network
+
+ # Generic mock server for various test scenarios
+ mock-openapi:
+ container_name: it-mock-openapi
+ image: stoplight/prism:5.14.3
+ command:
+ [
+ "mock",
+ "/api/openapi.yaml",
+ "--host",
+ "0.0.0.0",
+ "--errors",
+ "--verboseLevel",
+ "info"
+ ]
+ ports:
+ - "4010:4010"
+ volumes:
+ - ./mock-api:/api:ro
+ healthcheck:
+ test: ["CMD", "wget", "--spider", "--quiet", "--tries=1", "http://127.0.0.1:4010/health"]
+ interval: 5s
+ timeout: 3s
+ retries: 10
+ start_period: 5s
+ networks:
+ - it-gateway-runtime-network
+
+ # Wrap the mock-openapi with an HTTPS endpoint using Nginx
+ mock-openapi-https:
+ image: nginx:1.25
+ container_name: it-mock-openapi-https
+ ports:
+ - "9443:8443"
+ volumes:
+ - ./mock-api/nginx.conf:/etc/nginx/nginx.conf:ro
+ - ../gateway-controller/listener-certs:/etc/nginx/certs:ro
+ depends_on:
+ mock-openapi:
+ condition: service_healthy
+ networks:
+ - it-gateway-runtime-network
+
+ # Mock Azure Content Safety for content moderation policy testing
+ mock-azure-content-safety:
+ container_name: it-mock-azure-content-safety
+ image: ghcr.io/wso2/api-platform/mock-azure-content-safety:latest
+ build:
+ context: ../../tests/mock-servers/mock-azure-content-safety
+ dockerfile: Dockerfile
+ ports:
+ - "8084:8080"
+ networks:
+ - it-gateway-runtime-network
+
+ # Mock AWS Bedrock Guardrail for guardrail policy testing
+ mock-aws-bedrock-guardrail:
+ container_name: it-mock-aws-bedrock-guardrail
+ image: ghcr.io/wso2/api-platform/mock-aws-bedrock-guardrail:latest
+ build:
+ context: ../../tests/mock-servers/mock-aws-bedrock-guardrail
+ dockerfile: Dockerfile
+ ports:
+ - "8083:8080"
+ networks:
+ - it-gateway-runtime-network
+
+ # Mock Embedding Provider for semantic cache testing
+ mock-embedding-provider:
+ container_name: it-mock-embedding-provider
+ image: ghcr.io/wso2/api-platform/mock-embedding-provider:latest
+ build:
+ context: ../../tests/mock-servers/mock-embedding-provider
+ dockerfile: Dockerfile
+ ports:
+ - "8085:8080"
+ networks:
+ - it-gateway-runtime-network
+
+ # Mock Analytics Collector for analytics testing
+ mock-analytics-collector:
+ container_name: it-mock-analytics-collector
+ image: ghcr.io/wso2/api-platform/mock-analytics-collector:latest
+ build:
+ context: ../../tests/mock-servers/mock-analytics-collector
+ dockerfile: Dockerfile
+ ports:
+ - "8086:8080"
+ networks:
+ - it-gateway-runtime-network
+
+ # Mock Interceptor Service for interceptor-service policy testing
+ mock-interceptor-service:
+ container_name: it-mock-interceptor-service
+ image: ghcr.io/wso2/api-platform/mock-interceptor-service:latest
+ build:
+ context: ../../tests/mock-servers/mock-interceptor-service
+ dockerfile: Dockerfile
+ ports:
+ - "8087:8080"
+ healthcheck:
+ test: ["CMD", "wget", "--spider", "--quiet", "--tries=1", "http://127.0.0.1:8080/health"]
+ interval: 5s
+ timeout: 3s
+ retries: 10
+ start_period: 5s
+ networks:
+ - it-gateway-runtime-network
+
+ # Redis with RediSearch for semantic cache vector storage
+ redis:
+ container_name: it-redis
+ image: redis/redis-stack-server:latest
+ ports:
+ - "6379:6379"
+ environment:
+ - REDIS_ARGS=--requirepass redis
+ networks:
+ - it-gateway-runtime-network
+ healthcheck:
+ test: ["CMD", "redis-cli", "-a", "redis", "ping"]
+ interval: 5s
+ timeout: 3s
+ retries: 10
+ start_period: 5s
+
+ echo-backend-multi-arch:
+ container_name: it-echo-backend-multi-arch
+ image: mccutchen/go-httpbin:latest
+ ports:
+ - "9082:8080"
+ networks:
+ - it-gateway-runtime-network
+
+volumes:
+ controller-data-tests:
+ driver: local
+
+networks:
+ it-gateway-runtime-network:
+ driver: bridge
diff --git a/go.work b/go.work
index 1958e111a..e2da29f61 100644
--- a/go.work
+++ b/go.work
@@ -19,5 +19,6 @@ use (
./samples/sample-service
./sdk/ai
./sdk/core
+ ./tests/integration-e2e
./tests/mock-servers/mock-platform-api
)
diff --git a/go.work.sum b/go.work.sum
index 170c3c26c..c4517d6be 100644
--- a/go.work.sum
+++ b/go.work.sum
@@ -1967,6 +1967,8 @@ github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.0 h1:B/dfvscEQtew9dVuoxqxr
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.0/go.mod h1:fiPSssYvltE08HJchL04dOy+RD4hgrjph0cwGGMntdI=
github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.0 h1:+m0M/LFxN43KvULkDNfdXOgrjtg6UYJPFBJyuEcRCAw=
github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.0/go.mod h1:PwOyop78lveYMRs6oCxjiVyBdyCgIYH6XHIVZO9/SFQ=
+github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2 h1:yz1bePFlP5Vws5+8ez6T3HWXPmwOK7Yvq8QxDBD3SKY=
+github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2/go.mod h1:Pa9ZNPuoNu/GztvBSKk9J1cDJW6vk/n0zLtV4mgd8N8=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.9.0 h1:H+U3Gk9zY56G3u872L82bk4thcsy2Gghb9ExT4Zvm1o=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.9.0/go.mod h1:mgrmMSgaLp9hmax62XQTd0N4aAqSE5E0DulSpVYK7vc=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 h1:ywEEhmNahHBihViHepv3xPBn1663uRv2t2q/ESv9seY=
@@ -2749,6 +2751,10 @@ github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2z
github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
+github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
+github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
+github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI=
+github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grafana/pyroscope-go/godeltaprof v0.1.8 h1:iwOtYXeeVSAeYefJNaxDytgjKtUuKQbJqgAIjlnicKg=
@@ -2803,6 +2809,8 @@ github.com/hashicorp/go-syslog v1.0.0 h1:KaodqZuhUoZereWVIYmpUgZysurB1kBLX2j0MwM
github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4=
github.com/hashicorp/go-uuid v1.0.1 h1:fv1ep09latC32wFoVwnqcnKJGnMSdBanPczbHAYm1BE=
github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
+github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
+github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-version v1.2.0 h1:3vNe/fWF5CBgRIguda1meWhsZHy3m8gCJ5wx+dIzX/E=
github.com/hashicorp/go.net v0.0.1 h1:sNCoNyDEvN1xa+X0baata4RdcpKwcMS6DH+xwfqPgjw=
github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90=
@@ -2863,6 +2871,18 @@ github.com/iris-contrib/schema v0.0.6 h1:CPSBLyx2e91H2yJzPuhGuifVRnZBBJ3pCOMbOvP
github.com/iris-contrib/schema v0.0.6/go.mod h1:iYszG0IOsuIsfzjymw1kMzTL8YQcCWlm65f3wX8J5iA=
github.com/jawher/mow.cli v1.2.0 h1:e6ViPPy+82A/NFF/cfbq3Lr6q4JHKT9tyHwTCcUQgQw=
github.com/jawher/mow.cli v1.2.0/go.mod h1:y+pcA3jBAdo/GIZx/0rFjw/K2bVEODP9rfZOfaiq8Ko=
+github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
+github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
+github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
+github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
+github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg=
+github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
+github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o=
+github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
+github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8=
+github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
+github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
+github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
github.com/jessevdk/go-flags v1.6.1 h1:Cvu5U8UGrLay1rZfv/zP7iLpSHGUZ/Ou68T0iX1bBK4=
github.com/jessevdk/go-flags v1.6.1/go.mod h1:Mk8T1hIAWpOiJiHa9rJASDK2UGWji0EuPGBnNLMooyc=
github.com/jezek/xgb v1.0.0/go.mod h1:nrhwO0FX/enq75I7Y7G8iN1ubpSGZEiA3v9e9GyRFlk=
@@ -3030,6 +3050,7 @@ github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1f
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
github.com/microcosm-cc/bluemonday v1.0.25 h1:4NEwSfiJ+Wva0VxN5B8OwMicaJvD8r9tlJWm9rtloEg=
github.com/microcosm-cc/bluemonday v1.0.25/go.mod h1:ZIOjCQp1OrzBBPIJmfX4qDYFuhU02nx4bn030ixfHLE=
+github.com/microsoft/go-mssqldb v1.9.4/go.mod h1:GBbW9ASTiDC+mpgWDGKdm3FnFLTUsLYN3iFL90lQ+PA=
github.com/miekg/dns v1.0.14 h1:9jZdLNd/P4+SfEJ0TNyxYpsK8N4GtfylBLqtbYN1sbA=
github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
github.com/milvus-io/milvus-proto/go-api/v2 v2.6.8-0.20251223041313-25746c47c1a7/go.mod h1:/6UT4zZl6awVeXLeE7UGDWZvXj3IWkRsh3mqsn0DiAs=
@@ -3173,8 +3194,6 @@ github.com/pingcap/kvproto v0.0.0-20221129023506-621ec37aac7a/go.mod h1:OYtxs078
github.com/pingcap/log v1.1.1-0.20221015072633-39906604fb81 h1:URLoJ61DmmY++Sa/yyPEQHG2s/ZBeV1FbIswHEMrdoY=
github.com/pingcap/log v1.1.1-0.20221015072633-39906604fb81/go.mod h1:DWQW5jICDR7UJh4HtxXSM20Churx4CQL0fwL/SoOSA4=
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI=
-github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
-github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e h1:aoZm08cpOy4WuID//EZDgcC4zIxODThtZNPirFr42+A=
github.com/pkg/profile v1.2.1/go.mod h1:hJw3o1OdXxsrSjjVksARp5W95eeEaEfptyVZyv6JUPA=
github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA=
@@ -3613,6 +3632,7 @@ golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4/go.mod h1:IxCIyHEi3zRg3s0
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw=
+golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0=
golang.org/x/crypto v0.10.0/go.mod h1:o4eNf7Ede1fv+hwOwZsTHl9EsPFO6q6ZvYR8vYfY45I=
@@ -3646,7 +3666,6 @@ golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
-golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190125153040-c74c464bbbf2/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
@@ -3751,6 +3770,8 @@ golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ=
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
+golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
+golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190125091013-d26f9f9a57f3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -3839,6 +3860,7 @@ golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
golang.org/x/net v0.46.1-0.20251013234738-63d1a5100f82/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
+golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -4019,7 +4041,6 @@ golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
-golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/telemetry v0.0.0-20240521205824-bda55230c457/go.mod h1:pRgIJT+bRLFKnoM1ldnzKoxTIn14Yxz928LQRYYgIN0=
golang.org/x/telemetry v0.0.0-20250710130107-8d8967aff50b/go.mod h1:4ZwOYna0/zsOKwuR5X/m0QFOJpSZvAxFfkQT+Erd9D4=
@@ -4032,6 +4053,9 @@ golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2 h1:O1cMQHRfwNpDfDJerqR
golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2/go.mod h1:b7fPSJ0pKZ3ccUh8gnTONJxhn3c/PS6tyzQvyqw4iA8=
golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4 h1:bTLqdHv7xrGlFbvf5/TXNxy/iUwwdkjhqQTJDjW7aj0=
golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4/go.mod h1:g5NllXBEermZrmR51cJDQxmJUHUOfRAaNyWBM+R+548=
+golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c h1:6a8FdnNk6bTXBjR4AGKFgUKuo+7GnR3FX5L7CbveeZc=
+golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c/go.mod h1:TpUTTEp9frx7rTdLpC9gFG9kdI7zVLFTFFlqaH2Cncw=
+golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa h1:efT73AJZfAAUV7SOip6pWGkwJDzIGiKBZGVzHYa+ve4=
golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa/go.mod h1:kHjTxDEnAu6/Nl9lDkzjWpR+bmKfxeiRuSDlsMb70gE=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -4070,6 +4094,7 @@ golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0=
golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA=
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
+golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -4102,7 +4127,6 @@ golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
-golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
@@ -4208,6 +4232,7 @@ golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
+golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM=
golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
diff --git a/kubernetes/helm/gateway-helm-chart/templates/gateway/controller/deployment.yaml b/kubernetes/helm/gateway-helm-chart/templates/gateway/controller/deployment.yaml
index 9ae26645d..eb81b7dc9 100644
--- a/kubernetes/helm/gateway-helm-chart/templates/gateway/controller/deployment.yaml
+++ b/kubernetes/helm/gateway-helm-chart/templates/gateway/controller/deployment.yaml
@@ -106,6 +106,13 @@ spec:
name: {{ $controller.postgres.passwordSecretRef.name }}
key: {{ $controller.postgres.passwordSecretRef.key | default "password" }}
{{- end }}
+ {{- if and (eq .Values.gateway.config.controller.storage.type "sqlserver") $controller.sqlserver.passwordSecretRef.name }}
+ - name: APIP_GW_CONTROLLER_STORAGE_DATABASE_PASSWORD
+ valueFrom:
+ secretKeyRef:
+ name: {{ $controller.sqlserver.passwordSecretRef.name }}
+ key: {{ $controller.sqlserver.passwordSecretRef.key | default "password" }}
+ {{- end }}
{{- range $deployment.extraEnv }}
- {{- toYaml . | nindent 14 }}
{{- end }}
diff --git a/kubernetes/helm/gateway-helm-chart/templates/gateway/gateway-config.yaml b/kubernetes/helm/gateway-helm-chart/templates/gateway/gateway-config.yaml
index 5dd0bb164..3e66c74b1 100644
--- a/kubernetes/helm/gateway-helm-chart/templates/gateway/gateway-config.yaml
+++ b/kubernetes/helm/gateway-helm-chart/templates/gateway/gateway-config.yaml
@@ -3,6 +3,8 @@
{{- $router := .Values.gateway.config.router -}}
{{- $pe := .Values.gateway.config.policy_engine -}}
{{- $pg := $gc.storage.postgres -}}
+{{- $ss := $gc.storage.sqlserver -}}
+{{- $db := default (dict) $gc.storage.database -}}
{{- $controllerHost := printf "%s-controller" (include "gateway-operator.fullname" .) -}}
apiVersion: v1
kind: ConfigMap
@@ -58,6 +60,29 @@ data:
{{- end }}
{{- end }}
+ {{- if eq $gc.storage.type "sqlserver" }}
+ [controller.storage.database]
+ driver = {{ coalesce $db.driver "sqlserver" | quote }}
+ {{- $sqlDsn := coalesce $db.dsn $ss.dsn }}
+ {{- if $sqlDsn }}
+ dsn = {{ $sqlDsn | quote }}
+ {{- else }}
+ host = {{ required "gateway.config.controller.storage.database.host (or storage.sqlserver.host) is required when storage.type is \"sqlserver\" and dsn is unset" (coalesce $db.host $ss.host) | quote }}
+ port = {{ coalesce $db.port $ss.port 1433 | int }}
+ database = {{ required "gateway.config.controller.storage.database.database (or storage.sqlserver.database) is required when storage.type is \"sqlserver\" and dsn is unset" (coalesce $db.database $ss.database) | quote }}
+ user = {{ required "gateway.config.controller.storage.database.user (or storage.sqlserver.user) is required when storage.type is \"sqlserver\" and dsn is unset" (coalesce $db.user $ss.user) | quote }}
+ connect_timeout = {{ coalesce $db.connect_timeout $ss.connect_timeout "5s" | quote }}
+ max_open_conns = {{ coalesce $db.max_open_conns $ss.max_open_conns 25 | int }}
+ max_idle_conns = {{ coalesce $db.max_idle_conns $ss.max_idle_conns 5 | int }}
+ conn_max_lifetime = {{ coalesce $db.conn_max_lifetime $ss.conn_max_lifetime "30m" | quote }}
+ conn_max_idle_time = {{ coalesce $db.conn_max_idle_time $ss.conn_max_idle_time "5m" | quote }}
+ application_name = {{ coalesce $db.application_name $ss.application_name "gateway-controller" | quote }}
+ [controller.storage.database.options]
+ encrypt = {{ coalesce (index $db.options "encrypt") $ss.encrypt "disable" | quote }}
+ trust_server_certificate = {{ (coalesce (index $db.options "trust_server_certificate") (toString (default true $ss.trust_server_certificate))) | quote }}
+ {{- end }}
+ {{- end }}
+
[controller.policies]
definitions_path = {{ $gc.policies.definitions_path | quote }}
diff --git a/kubernetes/helm/gateway-helm-chart/values.yaml b/kubernetes/helm/gateway-helm-chart/values.yaml
index c4cf07a2f..8b7a4c01f 100644
--- a/kubernetes/helm/gateway-helm-chart/values.yaml
+++ b/kubernetes/helm/gateway-helm-chart/values.yaml
@@ -92,9 +92,10 @@ gateway:
# Storage configuration
storage:
- # Storage type: "sqlite", "postgres", or "memory"
+ # Storage type: "sqlite", "postgres", "sqlserver", or "memory"
# - sqlite: Single-instance embedded database backed by a PersistentVolumeClaim
# - postgres: External PostgreSQL database; enables multi-replica controller deployments
+ # - sqlserver: External SQL Server database; enables multi-replica controller deployments
# - memory: No persistence; all state is lost on restart (useful for testing only)
type: sqlite
@@ -125,6 +126,49 @@ gateway:
conn_max_idle_time: 5m
application_name: gateway-controller
+ # Global database configuration (used for type=sqlserver).
+ # Password is injected separately via gateway.controller.sqlserver.passwordSecretRef.
+ # This shape maps to [controller.storage.database] in config.toml.
+ database:
+ driver: sqlserver
+ # Full DSN takes precedence over individual fields when set.
+ # Example: "sqlserver://user:password@host:1433?database=gateway&encrypt=disable&TrustServerCertificate=true"
+ dsn: ""
+
+ host: ""
+ port: 1433
+ database: ""
+ user: ""
+
+ 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:
+ # Encryption mode: disable, false, true, strict
+ encrypt: disable
+ trust_server_certificate: "true"
+
+ # Legacy SQL Server configuration (backward compatible fallback).
+ # Prefer gateway.config.controller.storage.database for new deployments.
+ sqlserver:
+ dsn: ""
+ host: ""
+ port: 1433
+ database: ""
+ user: ""
+ encrypt: disable
+ trust_server_certificate: true
+ connect_timeout: 5s
+ max_open_conns: 25
+ max_idle_conns: 5
+ conn_max_lifetime: 30m
+ conn_max_idle_time: 5m
+ application_name: gateway-controller
+
# Policy configuration
policies:
# Directory containing policy definitions
@@ -487,6 +531,13 @@ gateway:
passwordSecretRef:
name: ""
key: password
+ # SQL Server password secret reference (used when gateway.config.controller.storage.type=sqlserver).
+ # The password is injected as an env var from a Kubernetes secret rather than stored in the ConfigMap.
+ # Create with: kubectl create secret generic --from-literal=password=''
+ sqlserver:
+ passwordSecretRef:
+ name: ""
+ key: password
metrics:
port: 9091
persistence:
diff --git a/platform-api/Dockerfile b/platform-api/Dockerfile
index 0169b8f77..9350b0e5a 100644
--- a/platform-api/Dockerfile
+++ b/platform-api/Dockerfile
@@ -89,6 +89,7 @@ COPY --from=builder /build/platform-api .
# Schema files and LLM provider templates
COPY src/internal/database/schema.sql ./schema.sql
COPY src/internal/database/schema.postgres.sql ./schema.postgres.sql
+COPY src/internal/database/schema.sqlserver.sql ./schema.sqlserver.sql
COPY src/internal/database/schema.sqlite.sql ./schema.sqlite.sql
COPY src/resources/default-llm-provider-templates ./default-llm-provider-templates
COPY src/resources/openapi.yaml ./resources/openapi.yaml
diff --git a/platform-api/Makefile b/platform-api/Makefile
index 4d4b7b6d6..d83302598 100644
--- a/platform-api/Makefile
+++ b/platform-api/Makefile
@@ -28,7 +28,13 @@ DOCKER_REGISTRY ?= ghcr.io/wso2/api-platform
IMAGE_NAME := $(DOCKER_REGISTRY)/platform-api
PORT ?= 9243
-.PHONY: help build test push build-and-push-multiarch
+# Integration test configuration
+IT_PROJECT ?= platform-api-it
+MSSQL_PASSWORD ?= Strong!Passw0rd
+# On Apple Silicon, override with: MSSQL_IMAGE=mcr.microsoft.com/azure-sql-edge:latest
+MSSQL_IMAGE ?= mcr.microsoft.com/mssql/server:2022-latest
+
+.PHONY: help build test push build-and-push-multiarch it it-sqlite it-postgres it-sqlserver it-all-dbs
help: ## Show this help message
@echo 'Platform API Build System (Version: $(VERSION))'
@@ -40,6 +46,44 @@ test: ## Run tests
@echo "Running tests..."
@cd src && go test -v ./...
+it: it-sqlite ## Run cross-database integration tests on SQLite (alias of it-sqlite)
+
+it-sqlite: ## Run cross-database integration tests on SQLite
+ @echo "Running integration tests on SQLite..."
+ @cd src && IT_DB=sqlite go test -tags integration -count=1 -v ./internal/integration/...
+
+it-postgres: ## Run cross-database integration tests on PostgreSQL (spins up a container)
+ @echo "Starting PostgreSQL for integration tests..."
+ @docker compose -p $(IT_PROJECT) -f it/docker-compose.postgres.yaml up -d --wait
+ @cd src && IT_DB=postgres IT_DB_HOST=localhost IT_DB_PORT=55432 IT_DB_USER=postgres \
+ IT_DB_PASSWORD=postgres IT_DB_NAME=platform_api_it \
+ go test -tags integration -count=1 -v ./internal/integration/...; rc=$$?; \
+ docker compose -p $(IT_PROJECT) -f it/docker-compose.postgres.yaml down -v >/dev/null 2>&1; \
+ exit $$rc
+
+it-sqlserver: ## Run cross-database integration tests on SQL Server (spins up a container)
+ @echo "Starting SQL Server ($(MSSQL_IMAGE)) for integration tests..."
+ @MSSQL_IMAGE=$(MSSQL_IMAGE) MSSQL_PASSWORD='$(MSSQL_PASSWORD)' \
+ docker compose -p $(IT_PROJECT) -f it/docker-compose.sqlserver.yaml up -d
+ @cd src && IT_DB=sqlserver IT_DB_HOST=localhost IT_DB_PORT=14333 IT_DB_USER=sa \
+ IT_DB_PASSWORD='$(MSSQL_PASSWORD)' IT_DB_NAME=platform_api_it \
+ go test -tags integration -count=1 -v ./internal/integration/...; rc=$$?; \
+ docker compose -p $(IT_PROJECT) -f it/docker-compose.sqlserver.yaml down -v >/dev/null 2>&1; \
+ exit $$rc
+
+it-all-dbs: ## Run integration tests across SQLite, PostgreSQL and SQL Server
+ @$(MAKE) it-sqlite
+ @$(MAKE) it-postgres
+ @$(MAKE) it-sqlserver
+
+e2e: ## Run the combined platform-api + gateway Cucumber e2e (E2E_DB=postgres|sqlite|sqlserver)
+ @cd ../tests/integration-e2e && E2E_DB=$${E2E_DB:-postgres} go test -run TestFeatures -count=1 -v -timeout 25m ./...
+
+e2e-all-dbs: ## Run the combined Cucumber e2e across all three databases
+ @cd ../tests/integration-e2e && E2E_DB=sqlite go test -run TestFeatures -count=1 -v -timeout 25m ./...
+ @cd ../tests/integration-e2e && E2E_DB=postgres go test -run TestFeatures -count=1 -v -timeout 25m ./...
+ @cd ../tests/integration-e2e && E2E_DB=sqlserver go test -run TestFeatures -count=1 -v -timeout 25m ./...
+
build: ## Build Docker image
@echo "Building Docker image ($(IMAGE_NAME):$(VERSION))..."
docker buildx build -f Dockerfile \
diff --git a/platform-api/README.md b/platform-api/README.md
index cd7da69ef..b16852a05 100644
--- a/platform-api/README.md
+++ b/platform-api/README.md
@@ -20,6 +20,24 @@ cd platform-api/src
go run ./cmd/main.go
```
+### Database Configuration
+
+Platform API supports `sqlite3` (default), `postgres`, and `sqlserver`.
+
+```bash
+# SQL Server example
+export DATABASE_DRIVER=sqlserver
+export DATABASE_HOST=sqlserver.example.internal
+export DATABASE_PORT=1433
+export DATABASE_NAME=platform_api
+export DATABASE_USER=sa
+export DATABASE_PASSWORD=''
+export DATABASE_SSL_MODE=disable
+
+cd platform-api/src
+go run ./cmd/main.go
+```
+
### Step-by-Step Workflow
**1. Register an Organization**
diff --git a/platform-api/it/README.md b/platform-api/it/README.md
new file mode 100644
index 000000000..60b7209bf
--- /dev/null
+++ b/platform-api/it/README.md
@@ -0,0 +1,87 @@
+# Platform-API cross-database integration tests
+
+These tests run the **real** platform-api schema and data-access layer against a
+real database engine — **SQLite, PostgreSQL and SQL Server** — so backend-specific
+behavior (pagination, multi-table writes, delete cascades) is exercised on every
+supported store instead of only on the SQLite path used by the unit tests.
+
+## Layout
+
+| Path | Purpose |
+|------|---------|
+| `src/internal/integration/` | The tests (Go, build tag `integration`). |
+| `src/internal/integration/harness_test.go` | DB selection + schema bootstrap, driven by `IT_DB`. |
+| `src/internal/integration/cascade_test.go` | Delete-cascade behavior across the real foreign keys. |
+| `src/internal/integration/lifecycle_test.go` | Create + paginated list through the real repository layer. |
+| `it/docker-compose.postgres.yaml` | Throwaway PostgreSQL for the tests. |
+| `it/docker-compose.sqlserver.yaml` | Throwaway SQL Server for the tests. |
+
+The tests run on the host and connect to the database over a published port, so
+the same test binary covers all three engines.
+
+## Running
+
+```bash
+# from platform-api/
+make it # SQLite (no container needed)
+make it-postgres # spins up PostgreSQL, runs, tears down
+make it-sqlserver # spins up SQL Server, runs, tears down
+make it-all-dbs # all three in sequence
+```
+
+On Apple Silicon the default SQL Server image fails under emulation; use Azure
+SQL Edge instead:
+
+```bash
+MSSQL_IMAGE=mcr.microsoft.com/azure-sql-edge:latest make it-sqlserver
+```
+
+### Selecting the engine manually
+
+The suite is parameterized purely by environment variables, so it can also point
+at an already-running database:
+
+```bash
+cd src
+IT_DB=sqlserver IT_DB_HOST=localhost IT_DB_PORT=1433 \
+ IT_DB_USER=sa IT_DB_PASSWORD='Strong!Passw0rd' IT_DB_NAME=platform_api_it \
+ go test -tags integration -v ./internal/integration/...
+```
+
+`IT_DB` ∈ `sqlite | postgres | sqlserver`. For postgres/sqlserver, set
+`IT_DB_HOST`, `IT_DB_PORT`, `IT_DB_USER`, `IT_DB_PASSWORD`, `IT_DB_NAME`
+(the SQL Server database is auto-created if missing).
+
+## What is covered
+
+- **Pagination** through the real repositories — the path that previously failed
+ on SQL Server with `Incorrect syntax near 'LIMIT'` (SQL Server uses
+ `OFFSET/FETCH`, not `LIMIT`).
+- **Delete cascades** on the real foreign keys — confirms the SQL Server schema's
+ `NO ACTION` edges (added to avoid SQL Server's multiple-cascade-paths
+ restriction) preserve the same cleanup behavior as PostgreSQL/SQLite:
+ deleting a REST API removes its subscriptions, deleting a gateway removes its
+ deployments and deployment status, deleting a devportal removes its
+ publications, deleting an application removes its mappings, and deleting a
+ project removes its applications.
+
+## Relationship to the gateway integration tests
+
+This covers the **platform-api** (control plane) store. The **gateway** has its
+own storage and its own cross-database integration suite under `gateway/it`,
+already runnable per engine:
+
+```bash
+cd gateway/it
+make test # SQLite
+make test-postgres # PostgreSQL
+make test-sqlserver # SQL Server
+```
+
+Together these give a per-engine matrix for both components. A combined
+end-to-end suite — real platform-api driving the real gateway data plane on a
+shared engine — is the next layer; it would reuse the `gateway/it`
+docker-compose services with the real platform-api image substituted for the
+mock platform-api, and assert an API created in platform-api is served by the
+gateway. The DB-selection and bootstrap conventions here are designed to extend
+to that.
diff --git a/platform-api/it/docker-compose.postgres.yaml b/platform-api/it/docker-compose.postgres.yaml
new file mode 100644
index 000000000..7d4b9b628
--- /dev/null
+++ b/platform-api/it/docker-compose.postgres.yaml
@@ -0,0 +1,17 @@
+# PostgreSQL backend for platform-api cross-database integration tests.
+# The tests run on the host and connect to the published port; see
+# `make it-postgres` in platform-api/Makefile.
+services:
+ postgres:
+ image: ${POSTGRES_IMAGE:-postgres:16}
+ environment:
+ POSTGRES_USER: postgres
+ POSTGRES_PASSWORD: postgres
+ POSTGRES_DB: platform_api_it
+ ports:
+ - "55432:5432"
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U postgres -d platform_api_it"]
+ interval: 2s
+ timeout: 3s
+ retries: 40
diff --git a/platform-api/it/docker-compose.sqlserver.yaml b/platform-api/it/docker-compose.sqlserver.yaml
new file mode 100644
index 000000000..03dbcac13
--- /dev/null
+++ b/platform-api/it/docker-compose.sqlserver.yaml
@@ -0,0 +1,16 @@
+# SQL Server backend for platform-api cross-database integration tests.
+# The tests run on the host and connect to the published port; see
+# `make it-sqlserver` in platform-api/Makefile.
+#
+# CI (amd64) uses the default mcr.microsoft.com/mssql/server image. On Apple
+# Silicon that image fails under emulation, so override with Azure SQL Edge:
+# MSSQL_IMAGE=mcr.microsoft.com/azure-sql-edge:latest make it-sqlserver
+services:
+ sqlserver:
+ image: ${MSSQL_IMAGE:-mcr.microsoft.com/mssql/server:2022-latest}
+ environment:
+ ACCEPT_EULA: "Y"
+ MSSQL_SA_PASSWORD: "${MSSQL_PASSWORD:-Strong!Passw0rd}"
+ MSSQL_PID: "Developer"
+ ports:
+ - "14333:1433"
diff --git a/platform-api/src/config/config.go b/platform-api/src/config/config.go
index 22d5955eb..74cadfd26 100644
--- a/platform-api/src/config/config.go
+++ b/platform-api/src/config/config.go
@@ -161,6 +161,7 @@ type WebSocket struct {
// Database holds database-specific configuration.
type Database struct {
+ // Driver supports: sqlite3, postgres/postgresql/pgx, sqlserver/mssql.
Driver string `koanf:"driver"`
// Path is the file path for SQLite databases.
Path string `koanf:"path"`
diff --git a/platform-api/src/go.mod b/platform-api/src/go.mod
index 30b52e6a6..44556002c 100644
--- a/platform-api/src/go.mod
+++ b/platform-api/src/go.mod
@@ -16,10 +16,11 @@ require (
github.com/knadh/koanf/providers/file v1.2.1
github.com/knadh/koanf/v2 v2.3.2
github.com/mattn/go-sqlite3 v1.14.41
+ github.com/microsoft/go-mssqldb v1.10.0
github.com/oapi-codegen/runtime v1.1.2
github.com/pb33f/libopenapi v0.28.2
github.com/wso2/api-platform/common v0.0.0
- golang.org/x/crypto v0.47.0
+ golang.org/x/crypto v0.50.0
gopkg.in/yaml.v3 v3.0.1
)
@@ -40,6 +41,8 @@ require (
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.1 // indirect
+ github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
+ github.com/golang-sql/sqlexp v0.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
@@ -58,14 +61,15 @@ require (
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.57.1 // indirect
+ github.com/shopspring/decimal v1.4.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
go.yaml.in/yaml/v4 v4.0.0-rc.2 // indirect
golang.org/x/arch v0.23.0 // indirect
- golang.org/x/net v0.49.0 // indirect
- golang.org/x/sync v0.19.0 // indirect
- golang.org/x/sys v0.40.0 // indirect
- golang.org/x/text v0.33.0 // indirect
+ golang.org/x/net v0.53.0 // indirect
+ golang.org/x/sync v0.20.0 // indirect
+ golang.org/x/sys v0.43.0 // indirect
+ golang.org/x/text v0.36.0 // indirect
golang.org/x/time v0.12.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)
diff --git a/platform-api/src/go.sum b/platform-api/src/go.sum
index 1ecf0bb5f..74f560fe9 100644
--- a/platform-api/src/go.sum
+++ b/platform-api/src/go.sum
@@ -1,5 +1,17 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
+github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1 h1:jHb/wfvRikGdxMXYV3QG/SzUOPYN9KEUUuC0Yd0/vC0=
+github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1/go.mod h1:pzBXCYn05zvYIrwLgtK8Ap8QcjRg+0i76tMQdWN6wOk=
+github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4=
+github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0=
+github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 h1:fhqpLE3UEXi9lPaBRpQ6XuRW0nU7hgg4zlmZZa+a9q4=
+github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0/go.mod h1:7dCRMLwisfRH3dBupKeNCioWYUZ4SS09Z14H+7i8ZoY=
+github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.4.0 h1:E4MgwLBGeVB5f2MdcIVD3ELVAWpr+WD6MUe1i+tM/PA=
+github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.4.0/go.mod h1:Y2b/1clN4zsAoUd/pgNAQHjLDnTis/6ROkUfyob6psM=
+github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 h1:nCYfgcSyHZXJI8J0IWE5MsCGlb2xp9fJiXyxWgmOFg4=
+github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0/go.mod h1:ucUjca2JtSZboY8IoUqyQyuuXvwbMBVwFOm0vdQPNhA=
+github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs=
+github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk=
github.com/MicahParks/jwkset v0.11.0 h1:yc0zG+jCvZpWgFDFmvs8/8jqqVBG9oyIbmBtmjOhoyQ=
github.com/MicahParks/jwkset v0.11.0/go.mod h1:U2oRhRaLgDCLjtpGL2GseNKGmZtLs/3O7p+OZaL5vo0=
github.com/MicahParks/keyfunc/v3 v3.7.0 h1:pdafUNyq+p3ZlvjJX1HWFP7MA3+cLpDtg69U3kITJGM=
@@ -52,6 +64,10 @@ github.com/goccy/go-yaml v1.19.1 h1:3rG3+v8pkhRqoQ/88NYNMHYVGYztCOCIZ7UQhu7H+NE=
github.com/goccy/go-yaml v1.19.1/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
+github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
+github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
+github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
+github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
@@ -88,6 +104,8 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
+github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
@@ -97,6 +115,8 @@ github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mattn/go-sqlite3 v1.14.41 h1:8p7Pwz5NHkEbWSqc/ygU4CBGubhFFkpgP9KwcdkAHNA=
github.com/mattn/go-sqlite3 v1.14.41/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
+github.com/microsoft/go-mssqldb v1.10.0 h1:pHEt+Qz6YFPWqREq10mqSE524QQo+/QremwTCQht7TY=
+github.com/microsoft/go-mssqldb v1.10.0/go.mod h1:mnG7lGa9iYJbzJqGCXyuQCegStKMr3kogDLD6+bmggg=
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
@@ -116,6 +136,8 @@ github.com/pb33f/ordered-map/v2 v2.3.0 h1:k2OhVEQkhTCQMhAicQ3Z6iInzoZNQ7L9MVomwK
github.com/pb33f/ordered-map/v2 v2.3.0/go.mod h1:oe5ue+6ZNhy7QN9cPZvPA23Hx0vMHnNVeMg4fGdCANw=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
+github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
+github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@@ -125,6 +147,8 @@ github.com/quic-go/quic-go v0.57.1 h1:25KAAR9QR8KZrCZRThWMKVAwGoiHIrNbT72ULHTuI1
github.com/quic-go/quic-go v0.57.1/go.mod h1:ly4QBAjHA2VhdnxhojRsCUOeJwKYg+taDlos92xb1+s=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
+github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
+github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
@@ -148,17 +172,17 @@ go.yaml.in/yaml/v4 v4.0.0-rc.2 h1:/FrI8D64VSr4HtGIlUtlFMGsm7H7pWTbj6vOLVZcA6s=
go.yaml.in/yaml/v4 v4.0.0-rc.2/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
-golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
-golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
-golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
-golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
-golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
-golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
+golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
+golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
+golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
+golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
+golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
+golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
-golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
-golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
-golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
+golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
+golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
+golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
diff --git a/platform-api/src/internal/database/connection.go b/platform-api/src/internal/database/connection.go
index 27d3d3b2f..cdc0f63d6 100644
--- a/platform-api/src/internal/database/connection.go
+++ b/platform-api/src/internal/database/connection.go
@@ -19,8 +19,10 @@ package database
import (
"database/sql"
+ "errors"
"fmt"
"log/slog"
+ "net/url"
"os"
"path/filepath"
"regexp"
@@ -29,8 +31,10 @@ import (
"platform-api/src/config"
+ "github.com/jackc/pgx/v5/pgconn"
_ "github.com/jackc/pgx/v5/stdlib" // PostgreSQL driver (pgx stdlib)
- _ "github.com/mattn/go-sqlite3" // SQLite3 driver
+ sqlite3 "github.com/mattn/go-sqlite3"
+ mssql "github.com/microsoft/go-mssqldb"
)
const (
@@ -42,6 +46,10 @@ const (
DriverPGX = "pgx"
// DriverPostgreSQL is an alternate name for PostgreSQL (accepted in config)
DriverPostgreSQL = "postgresql"
+ // DriverSQLServer is the canonical driver name for SQL Server
+ DriverSQLServer = "sqlserver"
+ // DriverMSSQL is an alternate SQL Server driver name (accepted in config)
+ DriverMSSQL = "mssql"
)
// DB holds the database connection
@@ -55,6 +63,11 @@ func isPostgresDriver(driver string) bool {
return driver == DriverPostgres
}
+// isSQLServerDriver returns true if the driver is SQL Server.
+func isSQLServerDriver(driver string) bool {
+ return driver == DriverSQLServer
+}
+
// Driver returns the underlying database driver name (e.g., sqlite3, postgres).
func (db *DB) Driver() string {
return db.driver
@@ -97,6 +110,14 @@ func NewConnection(cfg *config.Database, slogger *slog.Logger) (*DB, error) {
return nil, fmt.Errorf("failed to open postgres database: %w", err)
}
slogger.Info("Successfully opened PostgreSQL database connection", "host", cfg.Host, "port", cfg.Port, "dbname", cfg.Name)
+ case DriverSQLServer, DriverMSSQL:
+ dsn := buildSQLServerDSN(cfg)
+
+ db, err = sql.Open(DriverSQLServer, dsn)
+ if err != nil {
+ return nil, fmt.Errorf("failed to open sqlserver database: %w", err)
+ }
+ slogger.Info("Successfully opened SQL Server database connection", "host", cfg.Host, "port", cfg.Port, "dbname", cfg.Name)
default:
return nil, fmt.Errorf("unsupported database driver: %s", cfg.Driver)
}
@@ -123,6 +144,9 @@ func NewConnection(cfg *config.Database, slogger *slog.Logger) (*DB, error) {
if normalizedDriver == DriverPGX || normalizedDriver == DriverPostgreSQL {
normalizedDriver = DriverPostgres
}
+ if normalizedDriver == DriverMSSQL {
+ normalizedDriver = DriverSQLServer
+ }
return &DB{DB: db, driver: normalizedDriver}, nil
}
@@ -148,6 +172,8 @@ func (db *DB) InitSchema(dbSchemaPath string, slogger *slog.Logger) error {
schemaFile = "schema.sqlite.sql"
case DriverPostgres, DriverPostgreSQL:
schemaFile = "schema.postgres.sql"
+ case DriverSQLServer, DriverMSSQL:
+ schemaFile = "schema.sqlserver.sql"
default:
return fmt.Errorf("unsupported database driver for schema initialization: %s", db.driver)
}
@@ -160,6 +186,8 @@ func (db *DB) InitSchema(dbSchemaPath string, slogger *slog.Logger) error {
schemaPath = "./internal/database/schema.sqlite.sql"
case DriverPostgres, DriverPostgreSQL:
schemaPath = "./internal/database/schema.postgres.sql"
+ case DriverSQLServer, DriverMSSQL:
+ schemaPath = "./internal/database/schema.sqlserver.sql"
default:
return fmt.Errorf("unsupported database driver for schema initialization: %s", db.driver)
}
@@ -171,10 +199,10 @@ func (db *DB) InitSchema(dbSchemaPath string, slogger *slog.Logger) error {
return fmt.Errorf("failed to read schema file %s: %w", schemaPath, err)
}
- // For PostgreSQL, we need to execute statements individually
- // because PostgreSQL driver doesn't handle multi-statement Exec() well
- if isPostgresDriver(db.driver) {
- return db.initSchemaPostgres(string(schemaSQL))
+ // PostgreSQL and SQL Server drivers are more reliable when schema statements
+ // are executed one-by-one inside a transaction.
+ if isPostgresDriver(db.driver) || isSQLServerDriver(db.driver) {
+ return db.initSchemaTransactional(string(schemaSQL))
}
// For SQLite, execute as a single statement (it handles multi-statement well)
@@ -186,9 +214,9 @@ func (db *DB) InitSchema(dbSchemaPath string, slogger *slog.Logger) error {
return nil
}
-// initSchemaPostgres splits SQL statements and executes them individually within a transaction
+// initSchemaTransactional splits SQL statements and executes them individually within a transaction
// This ensures all tables are created before foreign key constraints are validated
-func (db *DB) initSchemaPostgres(schemaSQL string) error {
+func (db *DB) initSchemaTransactional(schemaSQL string) error {
// Split SQL statements by semicolon, but be careful with semicolons in strings/comments
statements := splitSQLStatements(schemaSQL)
@@ -370,6 +398,214 @@ func (db *DB) Rebind(query string) string {
}
return result.String()
}
+ if isSQLServerDriver(db.driver) {
+ // Convert ? placeholders to @p1, @p2, @p3, etc.
+ parts := strings.Split(query, "?")
+ if len(parts) == 1 {
+ return query // No placeholders
+ }
+
+ var result strings.Builder
+ for i, part := range parts {
+ if i > 0 {
+ result.WriteString(fmt.Sprintf("@p%d", i))
+ }
+ result.WriteString(part)
+ }
+ return result.String()
+ }
// For SQLite and other drivers, return as-is
return query
}
+
+// PaginationClause returns a dialect-appropriate row-limiting clause to append
+// after an ORDER BY, together with its bind arguments in the order the clause
+// expects them. SQL Server has no LIMIT keyword and instead uses ANSI
+// OFFSET/FETCH, which (a) requires an ORDER BY in the statement and (b) lists
+// OFFSET before the row count — the reverse of "LIMIT ? OFFSET ?". The returned
+// clause uses ? placeholders; pass the assembled query through Rebind as usual.
+func (db *DB) PaginationClause(limit, offset int) (string, []any) {
+ if isSQLServerDriver(db.driver) {
+ return "OFFSET ? ROWS FETCH NEXT ? ROWS ONLY", []any{offset, limit}
+ }
+ return "LIMIT ? OFFSET ?", []any{limit, offset}
+}
+
+// FetchFirstClause returns a row-limiting clause for a fixed number of rows,
+// safe to embed directly into a query string (n is an integer constant, not
+// user input). As with PaginationClause, SQL Server requires the statement to
+// carry an ORDER BY; add "ORDER BY (SELECT NULL)" when ordering is irrelevant.
+func (db *DB) FetchFirstClause(n int) string {
+ if isSQLServerDriver(db.driver) {
+ return fmt.Sprintf("OFFSET 0 ROWS FETCH NEXT %d ROWS ONLY", n)
+ }
+ return fmt.Sprintf("LIMIT %d", n)
+}
+
+// BuildUpsertQuery generates a dialect-appropriate INSERT … ON CONFLICT … DO UPDATE query.
+// insertCols are all columns being inserted (each maps to one ? placeholder).
+// conflictCols are the columns that define uniqueness.
+// updateExprs control what happens on conflict: "col" → col = excluded.col, "col=NULL" → col = NULL.
+func (db *DB) BuildUpsertQuery(table string, insertCols []string, conflictCols []string, updateExprs []string) string {
+ if isSQLServerDriver(db.driver) {
+ return buildSQLServerUpsertQuery(table, insertCols, conflictCols, updateExprs)
+ }
+
+ placeholders := strings.TrimSuffix(strings.Repeat("?, ", len(insertCols)), ", ")
+
+ setClauses := make([]string, 0, len(updateExprs))
+ for _, expr := range updateExprs {
+ if idx := strings.Index(strings.ToUpper(expr), "=NULL"); idx >= 0 {
+ setClauses = append(setClauses, expr[:idx]+" = NULL")
+ } else {
+ setClauses = append(setClauses, expr+" = excluded."+expr)
+ }
+ }
+
+ return fmt.Sprintf(
+ "INSERT INTO %s (%s) VALUES (%s)\nON CONFLICT (%s)\nDO UPDATE SET %s",
+ table,
+ strings.Join(insertCols, ", "),
+ placeholders,
+ strings.Join(conflictCols, ", "),
+ strings.Join(setClauses, ", "),
+ )
+}
+
+// InsertAndReturnID executes an INSERT query and returns the generated row ID.
+func (db *DB) InsertAndReturnID(query string, args ...any) (int64, error) {
+ if isPostgresDriver(db.driver) {
+ var id int64
+ err := db.QueryRow(db.Rebind(query+" RETURNING id"), args...).Scan(&id)
+ return id, err
+ }
+ if isSQLServerDriver(db.driver) {
+ queryWithOutput, err := injectSQLOutputInsertedID(query)
+ if err != nil {
+ return 0, err
+ }
+
+ var id int64
+ err = db.QueryRow(db.Rebind(queryWithOutput), args...).Scan(&id)
+ return id, err
+ }
+ result, err := db.Exec(db.Rebind(query), args...)
+ if err != nil {
+ return 0, err
+ }
+ return result.LastInsertId()
+}
+
+// IsDuplicateKeyError reports whether err is a unique-constraint or duplicate-key
+// violation for the current database driver.
+func (db *DB) IsDuplicateKeyError(err error) bool {
+ if err == nil {
+ return false
+ }
+
+ var pgErr *pgconn.PgError
+ if errors.As(err, &pgErr) && pgErr.Code == "23505" {
+ return true
+ }
+
+ var sqliteErr sqlite3.Error
+ if errors.As(err, &sqliteErr) {
+ return sqliteErr.ExtendedCode == sqlite3.ErrConstraintPrimaryKey ||
+ sqliteErr.ExtendedCode == sqlite3.ErrConstraintUnique
+ }
+
+ var msErr mssql.Error
+ if errors.As(err, &msErr) {
+ // 2601: Cannot insert duplicate key row in object with unique index
+ // 2627: Violation of PRIMARY KEY or UNIQUE KEY constraint
+ return msErr.Number == 2601 || msErr.Number == 2627
+ }
+
+ lowerMsg := strings.ToLower(err.Error())
+ return strings.Contains(lowerMsg, "duplicate key") ||
+ strings.Contains(lowerMsg, "unique constraint failed")
+}
+
+func buildSQLServerDSN(cfg *config.Database) string {
+ encrypt := "disable"
+ trustServerCertificate := "true"
+
+ switch strings.ToLower(cfg.SSLMode) {
+ case "", "disable", "false", "off":
+ encrypt = "disable"
+ trustServerCertificate = "true"
+ case "strict":
+ encrypt = "strict"
+ trustServerCertificate = "false"
+ default:
+ encrypt = "true"
+ trustServerCertificate = "false"
+ }
+
+ q := url.Values{}
+ q.Set("database", cfg.Name)
+ q.Set("encrypt", encrypt)
+ q.Set("TrustServerCertificate", trustServerCertificate)
+
+ host := cfg.Host
+ if host == "" {
+ host = "localhost"
+ }
+
+ u := &url.URL{
+ Scheme: DriverSQLServer,
+ Host: fmt.Sprintf("%s:%d", host, cfg.Port),
+ RawQuery: q.Encode(),
+ }
+ if cfg.User != "" {
+ u.User = url.UserPassword(cfg.User, cfg.Password)
+ }
+
+ return u.String()
+}
+
+func buildSQLServerUpsertQuery(table string, insertCols []string, conflictCols []string, updateExprs []string) string {
+ placeholders := strings.TrimSuffix(strings.Repeat("?, ", len(insertCols)), ", ")
+ sourceCols := strings.Join(insertCols, ", ")
+
+ onParts := make([]string, 0, len(conflictCols))
+ for _, col := range conflictCols {
+ onParts = append(onParts, fmt.Sprintf("target.%s = src.%s", col, col))
+ }
+
+ setClauses := make([]string, 0, len(updateExprs))
+ for _, expr := range updateExprs {
+ if idx := strings.Index(strings.ToUpper(expr), "=NULL"); idx >= 0 {
+ col := strings.TrimSpace(expr[:idx])
+ setClauses = append(setClauses, fmt.Sprintf("target.%s = NULL", col))
+ } else {
+ setClauses = append(setClauses, fmt.Sprintf("target.%s = src.%s", expr, expr))
+ }
+ }
+
+ insertValues := make([]string, 0, len(insertCols))
+ for _, col := range insertCols {
+ insertValues = append(insertValues, "src."+col)
+ }
+
+ return fmt.Sprintf(
+ "MERGE INTO %s AS target\nUSING (VALUES (%s)) AS src (%s)\nON %s\nWHEN MATCHED THEN UPDATE SET %s\nWHEN NOT MATCHED THEN INSERT (%s) VALUES (%s);",
+ table,
+ placeholders,
+ sourceCols,
+ strings.Join(onParts, " AND "),
+ strings.Join(setClauses, ", "),
+ sourceCols,
+ strings.Join(insertValues, ", "),
+ )
+}
+
+func injectSQLOutputInsertedID(query string) (string, error) {
+ matcher := regexp.MustCompile(`(?is)\)\s*VALUES`)
+ loc := matcher.FindStringIndex(query)
+ if loc == nil {
+ return "", fmt.Errorf("failed to inject OUTPUT inserted.id: expected INSERT query with VALUES")
+ }
+
+ return query[:loc[0]] + ") OUTPUT inserted.id VALUES" + query[loc[1]:], nil
+}
diff --git a/platform-api/src/internal/database/schema.sqlserver.sql b/platform-api/src/internal/database/schema.sqlserver.sql
new file mode 100644
index 000000000..add7b7a84
--- /dev/null
+++ b/platform-api/src/internal/database/schema.sqlserver.sql
@@ -0,0 +1,579 @@
+/*
+ * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+-- SQL Server schema derived from schema.postgres.sql
+-- Organizations table
+IF OBJECT_ID(N'dbo.organizations', N'U') IS NULL
+CREATE TABLE dbo.organizations (
+ uuid VARCHAR(40) PRIMARY KEY,
+ handle VARCHAR(255) UNIQUE NOT NULL,
+ name VARCHAR(255) NOT NULL,
+ region VARCHAR(63) NOT NULL,
+ created_at DATETIME2(7) DEFAULT SYSUTCDATETIME(),
+ updated_at DATETIME2(7) DEFAULT SYSUTCDATETIME()
+);
+
+-- Projects table
+IF OBJECT_ID(N'dbo.projects', N'U') IS NULL
+CREATE TABLE dbo.projects (
+ uuid VARCHAR(40) PRIMARY KEY,
+ name VARCHAR(255) NOT NULL,
+ organization_uuid VARCHAR(40) NOT NULL,
+ description VARCHAR(1023),
+ created_at DATETIME2(7) DEFAULT SYSUTCDATETIME(),
+ updated_at DATETIME2(7) DEFAULT SYSUTCDATETIME(),
+ FOREIGN KEY (organization_uuid) REFERENCES organizations(uuid) ON DELETE CASCADE,
+ UNIQUE(name, organization_uuid)
+);
+
+-- Applications table
+IF OBJECT_ID(N'dbo.applications', N'U') IS NULL
+CREATE TABLE dbo.applications (
+ uuid VARCHAR(40) PRIMARY KEY,
+ handle VARCHAR(255) NOT NULL,
+ project_uuid VARCHAR(40) NOT NULL,
+ organization_uuid VARCHAR(40) NOT NULL,
+ created_by VARCHAR(255),
+ name VARCHAR(255) NOT NULL,
+ description VARCHAR(1023),
+ type VARCHAR(50) NOT NULL,
+ created_at DATETIME2(7) DEFAULT SYSUTCDATETIME(),
+ updated_at DATETIME2(7) DEFAULT SYSUTCDATETIME(),
+ FOREIGN KEY (project_uuid) REFERENCES projects(uuid) ON DELETE CASCADE,
+ -- NO ACTION (not CASCADE) to avoid the SQL Server multiple-cascade-paths
+ -- restriction (error 1785). Deleting an organization still removes its
+ -- applications via organizations -> projects -> applications, so no
+ -- cleanup behavior is lost relative to the Postgres schema.
+ FOREIGN KEY (organization_uuid) REFERENCES organizations(uuid) ON DELETE NO ACTION,
+ UNIQUE(project_uuid, organization_uuid, name),
+ UNIQUE(handle, organization_uuid)
+);
+
+-- Artifacts table
+IF OBJECT_ID(N'dbo.artifacts', N'U') IS NULL
+CREATE TABLE dbo.artifacts (
+ uuid VARCHAR(40) PRIMARY KEY,
+ handle VARCHAR(255) NOT NULL,
+ name VARCHAR(255) NOT NULL,
+ version VARCHAR(30) NOT NULL,
+ kind VARCHAR(20) NOT NULL,
+ organization_uuid VARCHAR(40) NOT NULL,
+ created_at DATETIME2(7) DEFAULT SYSUTCDATETIME(),
+ updated_at DATETIME2(7) DEFAULT SYSUTCDATETIME(),
+ FOREIGN KEY (organization_uuid) REFERENCES organizations(uuid) ON DELETE NO ACTION,
+ UNIQUE(handle, organization_uuid),
+ UNIQUE(name, version, organization_uuid),
+ -- Ensure (uuid, organization_uuid) pairs are unique so they can be safely
+ -- referenced from subscriptions to enforce API–organization consistency.
+ UNIQUE(uuid, organization_uuid)
+);
+
+-- REST APIs table
+IF OBJECT_ID(N'dbo.rest_apis', N'U') IS NULL
+CREATE TABLE dbo.rest_apis (
+ uuid VARCHAR(40) PRIMARY KEY,
+ description VARCHAR(1023),
+ created_by VARCHAR(200),
+ project_uuid VARCHAR(40) NOT NULL,
+ lifecycle_status VARCHAR(20) DEFAULT 'CREATED',
+ transport VARCHAR(255), -- JSON array as NVARCHAR(MAX)
+ configuration NVARCHAR(MAX) NOT NULL,
+ FOREIGN KEY (uuid) REFERENCES artifacts(uuid) ON DELETE CASCADE,
+ FOREIGN KEY (project_uuid) REFERENCES projects(uuid) ON DELETE CASCADE
+);
+
+-- Subscription plans table (organization-scoped rate/billing plans)
+IF OBJECT_ID(N'dbo.subscription_plans', N'U') IS NULL
+CREATE TABLE dbo.subscription_plans (
+ uuid VARCHAR(40) PRIMARY KEY,
+ plan_name VARCHAR(40) NOT NULL,
+ billing_plan VARCHAR(255),
+ stop_on_quota_reach BIT DEFAULT 1,
+ throttle_limit_count INT,
+ throttle_limit_unit VARCHAR(20),
+ expiry_time DATETIME2(7),
+ organization_uuid VARCHAR(40) NOT NULL,
+ status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
+ created_at DATETIME2(7) DEFAULT SYSUTCDATETIME(),
+ updated_at DATETIME2(7) DEFAULT SYSUTCDATETIME(),
+ FOREIGN KEY (organization_uuid) REFERENCES organizations(uuid) ON DELETE CASCADE,
+ UNIQUE(organization_uuid, plan_name),
+ UNIQUE(uuid, organization_uuid),
+ CHECK (status IN ('ACTIVE', 'INACTIVE')),
+ CONSTRAINT chk_plan_throttle_pair CHECK (
+ (throttle_limit_count IS NULL AND throttle_limit_unit IS NULL) OR
+ (throttle_limit_count IS NOT NULL AND throttle_limit_unit IS NOT NULL)
+ )
+);
+
+-- Subscriptions table (application-level subscriptions for REST APIs)
+-- subscription_token: encrypted value (AES-256-GCM) for retrieval (legacy rows have hash)
+-- subscription_token_hash: SHA-256 hash for uniqueness and gateway sync
+IF OBJECT_ID(N'dbo.subscriptions', N'U') IS NULL
+CREATE TABLE dbo.subscriptions (
+ uuid VARCHAR(40) PRIMARY KEY,
+ api_uuid VARCHAR(40) NOT NULL,
+ subscriber_id VARCHAR(255) NOT NULL,
+ application_id VARCHAR(255),
+ subscription_token VARCHAR(512) NOT NULL,
+ subscription_token_hash VARCHAR(64) NOT NULL,
+ subscription_plan_uuid VARCHAR(40),
+ organization_uuid VARCHAR(40) NOT NULL,
+ status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
+ created_at DATETIME2(7) DEFAULT SYSUTCDATETIME(),
+ updated_at DATETIME2(7) DEFAULT SYSUTCDATETIME(),
+ FOREIGN KEY (api_uuid) REFERENCES rest_apis(uuid) ON DELETE CASCADE,
+ -- NO ACTION on the organization and artifacts edges to avoid the SQL Server
+ -- multiple-cascade-paths restriction (error 1785). Subscriptions are still
+ -- removed via the api_uuid -> rest_apis CASCADE edge (which itself cascades
+ -- from artifacts/projects/organizations), so cleanup behavior is preserved.
+ FOREIGN KEY (organization_uuid) REFERENCES organizations(uuid) ON DELETE NO ACTION,
+ FOREIGN KEY (subscription_plan_uuid, organization_uuid)
+ REFERENCES subscription_plans(uuid, organization_uuid) ON DELETE NO ACTION,
+ FOREIGN KEY (api_uuid, organization_uuid)
+ REFERENCES artifacts(uuid, organization_uuid) ON DELETE NO ACTION,
+ UNIQUE(api_uuid, subscription_token_hash),
+ UNIQUE(api_uuid, subscriber_id, organization_uuid),
+ CHECK (status IN ('ACTIVE', 'INACTIVE', 'REVOKED'))
+);
+IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_subscriptions_token' AND object_id = OBJECT_ID(N'dbo.subscriptions'))
+CREATE INDEX idx_subscriptions_token ON dbo.subscriptions(subscription_token_hash);
+-- Supports list/count filters: WHERE organization_uuid = ? AND subscriber_id = ? (no api_uuid).
+-- The unique constraint on (api_uuid, subscriber_id, organization_uuid) is not ordered for this access path.
+IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_subscriptions_org_subscriber' AND object_id = OBJECT_ID(N'dbo.subscriptions'))
+CREATE INDEX idx_subscriptions_org_subscriber ON dbo.subscriptions(organization_uuid, subscriber_id);
+
+-- Gateways table (scoped to organizations)
+-- Must be created before deployments which references it
+IF OBJECT_ID(N'dbo.gateways', N'U') IS NULL
+CREATE TABLE dbo.gateways (
+ uuid VARCHAR(40) PRIMARY KEY,
+ organization_uuid VARCHAR(40) NOT NULL,
+ name VARCHAR(255) NOT NULL,
+ version VARCHAR(64) NOT NULL DEFAULT '1.0',
+ display_name VARCHAR(255) NOT NULL,
+ description VARCHAR(1023),
+ properties NVARCHAR(MAX) NOT NULL DEFAULT N'{}',
+ vhost VARCHAR(255) NOT NULL,
+ is_critical BIT DEFAULT 0,
+ gateway_functionality_type VARCHAR(20) DEFAULT 'regular' NOT NULL,
+ is_active BIT DEFAULT 0,
+ manifest NVARCHAR(MAX),
+ created_at DATETIME2(7) DEFAULT SYSUTCDATETIME(),
+ updated_at DATETIME2(7) DEFAULT SYSUTCDATETIME(),
+ FOREIGN KEY (organization_uuid) REFERENCES organizations(uuid) ON DELETE CASCADE,
+ UNIQUE(organization_uuid, name),
+ CHECK (gateway_functionality_type IN ('regular', 'ai', 'event'))
+);
+
+-- Gateway Custom Policies table (org-scoped custom policies synced from gateway manifests)
+IF OBJECT_ID(N'dbo.gateway_custom_policies', N'U') IS NULL
+CREATE TABLE dbo.gateway_custom_policies (
+ uuid VARCHAR(40) PRIMARY KEY,
+ organization_uuid VARCHAR(40) NOT NULL,
+ name VARCHAR(255) NOT NULL,
+ display_name VARCHAR(255),
+ version VARCHAR(15) NOT NULL,
+ description NVARCHAR(MAX),
+ policy_definition NVARCHAR(MAX),
+ created_at DATETIME2(7) DEFAULT SYSUTCDATETIME(),
+ updated_at DATETIME2(7) DEFAULT SYSUTCDATETIME(),
+ FOREIGN KEY (organization_uuid) REFERENCES organizations(uuid) ON DELETE CASCADE,
+ UNIQUE(organization_uuid, name, version)
+);
+
+-- Gateway Custom Policy Usages table (tracks which APIs use each custom policy)
+IF OBJECT_ID(N'dbo.gateway_custom_policy_usages', N'U') IS NULL
+CREATE TABLE dbo.gateway_custom_policy_usages (
+ policy_uuid VARCHAR(40) NOT NULL,
+ api_uuid VARCHAR(40) NOT NULL,
+ PRIMARY KEY (policy_uuid, api_uuid),
+ FOREIGN KEY (policy_uuid) REFERENCES gateway_custom_policies(uuid) ON DELETE CASCADE,
+ FOREIGN KEY (api_uuid) REFERENCES artifacts(uuid) ON DELETE CASCADE
+);
+
+-- Gateway Tokens table
+IF OBJECT_ID(N'dbo.gateway_tokens', N'U') IS NULL
+CREATE TABLE dbo.gateway_tokens (
+ uuid VARCHAR(40) PRIMARY KEY,
+ gateway_uuid VARCHAR(40) NOT NULL,
+ token_hash VARCHAR(255) NOT NULL,
+ salt VARCHAR(255) NOT NULL,
+ status VARCHAR(10) NOT NULL DEFAULT 'active',
+ created_at DATETIME2(7) DEFAULT SYSUTCDATETIME(),
+ revoked_at DATETIME2(7),
+ FOREIGN KEY (gateway_uuid) REFERENCES gateways(uuid) ON DELETE CASCADE,
+ CHECK (status IN ('active', 'revoked')),
+ CHECK (revoked_at IS NULL OR status = 'revoked')
+);
+
+-- Artifact Deployments table (immutable deployment artifacts)
+IF OBJECT_ID(N'dbo.deployments', N'U') IS NULL
+CREATE TABLE dbo.deployments (
+ deployment_id VARCHAR(40) PRIMARY KEY,
+ name VARCHAR(255) NOT NULL,
+ artifact_uuid VARCHAR(40) NOT NULL,
+ organization_uuid VARCHAR(40) NOT NULL,
+ gateway_uuid VARCHAR(40) NOT NULL,
+ base_deployment_id VARCHAR(40),
+ content VARBINARY(MAX) NOT NULL,
+ metadata NVARCHAR(MAX), -- JSON object as NVARCHAR(MAX)
+ created_at DATETIME2(7) DEFAULT SYSUTCDATETIME(),
+ FOREIGN KEY (artifact_uuid) REFERENCES artifacts(uuid) ON DELETE CASCADE,
+ -- NO ACTION to avoid the SQL Server multiple-cascade-paths restriction
+ -- (error 1785). Organization deletes still reach deployments through
+ -- organizations -> gateways -> deployments.
+ FOREIGN KEY (organization_uuid) REFERENCES organizations(uuid) ON DELETE NO ACTION,
+ FOREIGN KEY (gateway_uuid) REFERENCES gateways(uuid) ON DELETE CASCADE,
+ -- NO ACTION (not SET NULL): SQL Server forbids cascade actions on a
+ -- self-referencing FK (error 1785, "may cause cycles"). Deployments for an
+ -- artifact/gateway are deleted together in a single statement (or via the
+ -- artifact/gateway CASCADE), so the referenced base row is removed in the
+ -- same operation and no dangling reference remains.
+ FOREIGN KEY (base_deployment_id) REFERENCES deployments(deployment_id) ON DELETE NO ACTION
+);
+
+-- Artifact Deployment Status table (current deployment state per artifact+Gateway)
+IF OBJECT_ID(N'dbo.deployment_status', N'U') IS NULL
+CREATE TABLE dbo.deployment_status (
+ artifact_uuid VARCHAR(40) NOT NULL,
+ organization_uuid VARCHAR(40) NOT NULL,
+ gateway_uuid VARCHAR(40) NOT NULL,
+ deployment_id VARCHAR(40) NOT NULL,
+ status VARCHAR(20) NOT NULL DEFAULT 'DEPLOYED',
+ status_desired VARCHAR(20),
+ performed_at DATETIME2(7) DEFAULT SYSUTCDATETIME(),
+ status_reason VARCHAR(50),
+ updated_at DATETIME2(7) DEFAULT SYSUTCDATETIME(),
+ PRIMARY KEY (artifact_uuid, organization_uuid, gateway_uuid),
+ -- Only the deployment_id edge cascades. The artifact/organization/gateway
+ -- edges are NO ACTION to avoid the SQL Server multiple-cascade-paths
+ -- restriction (error 1785). A status row is always removed when its
+ -- referenced deployment is deleted, and deletes of an artifact, gateway or
+ -- organization funnel through deployments
+ -- (artifact/gateway -> deployments -> deployment_status), so no cleanup is lost.
+ FOREIGN KEY (artifact_uuid) REFERENCES artifacts(uuid) ON DELETE NO ACTION,
+ FOREIGN KEY (organization_uuid) REFERENCES organizations(uuid) ON DELETE NO ACTION,
+ FOREIGN KEY (gateway_uuid) REFERENCES gateways(uuid) ON DELETE NO ACTION,
+ FOREIGN KEY (deployment_id) REFERENCES deployments(deployment_id) ON DELETE CASCADE
+);
+
+-- Artifact Associations table (for both gateways and dev portals)
+IF OBJECT_ID(N'dbo.association_mappings', N'U') IS NULL
+CREATE TABLE dbo.association_mappings (
+ id INT IDENTITY(1,1) PRIMARY KEY,
+ artifact_uuid VARCHAR(40) NOT NULL,
+ organization_uuid VARCHAR(40) NOT NULL,
+ resource_uuid VARCHAR(40) NOT NULL,
+ association_type VARCHAR(20) NOT NULL,
+ created_at DATETIME2(7) DEFAULT SYSUTCDATETIME(),
+ updated_at DATETIME2(7) DEFAULT SYSUTCDATETIME(),
+ FOREIGN KEY (artifact_uuid) REFERENCES artifacts(uuid) ON DELETE CASCADE,
+ FOREIGN KEY (organization_uuid) REFERENCES organizations(uuid) ON DELETE CASCADE,
+ UNIQUE(artifact_uuid, resource_uuid, association_type, organization_uuid),
+ CHECK (association_type IN ('gateway', 'dev_portal'))
+);
+
+-- DevPortals table
+IF OBJECT_ID(N'dbo.devportals', N'U') IS NULL
+CREATE TABLE dbo.devportals (
+ uuid VARCHAR(40) PRIMARY KEY,
+ organization_uuid VARCHAR(40) NOT NULL,
+ name VARCHAR(100) NOT NULL,
+ identifier VARCHAR(100) NOT NULL,
+ api_url VARCHAR(255) NOT NULL,
+ hostname VARCHAR(255) NOT NULL,
+ api_key VARCHAR(255) NOT NULL,
+ header_key_name VARCHAR(100) DEFAULT 'x-wso2-api-key',
+ is_active BIT DEFAULT 0,
+ is_enabled BIT DEFAULT 0,
+ is_default BIT DEFAULT 0,
+ visibility VARCHAR(20) NOT NULL DEFAULT 'private',
+ description VARCHAR(500),
+ created_at DATETIME2(7) DEFAULT SYSUTCDATETIME(),
+ updated_at DATETIME2(7) DEFAULT SYSUTCDATETIME(),
+ FOREIGN KEY (organization_uuid) REFERENCES organizations(uuid) ON DELETE CASCADE,
+ UNIQUE(organization_uuid, api_url),
+ UNIQUE(organization_uuid, hostname)
+);
+
+-- API-DevPortal Publication Tracking Table
+-- This table tracks which APIs are published to which DevPortals
+
+IF OBJECT_ID(N'dbo.publication_mappings', N'U') IS NULL
+CREATE TABLE dbo.publication_mappings (
+ api_uuid VARCHAR(40) NOT NULL,
+ devportal_uuid VARCHAR(40) NOT NULL,
+ organization_uuid VARCHAR(40) NOT NULL,
+ status VARCHAR(20) NOT NULL CHECK (status IN ('published', 'failed', 'publishing')),
+ api_version VARCHAR(50),
+ devportal_ref_id VARCHAR(100),
+
+ -- Gateway endpoints for sandbox and production
+ sandbox_endpoint_url VARCHAR(500) NOT NULL,
+ production_endpoint_url VARCHAR(500) NOT NULL,
+
+ -- Timestamps
+ created_at DATETIME2(7) DEFAULT SYSUTCDATETIME(),
+ updated_at DATETIME2(7) DEFAULT SYSUTCDATETIME(),
+
+ -- Foreign key constraints
+ PRIMARY KEY (api_uuid, devportal_uuid, organization_uuid),
+ -- Only the devportal edge cascades. The api and organization edges are
+ -- NO ACTION to avoid the SQL Server multiple-cascade-paths restriction
+ -- (error 1785). API deletion removes publication rows explicitly in
+ -- application code (APIRepo.DeleteAPI), and organization deletes reach them
+ -- through organizations -> devportals -> publication_mappings.
+ FOREIGN KEY (api_uuid) REFERENCES rest_apis(uuid) ON DELETE NO ACTION,
+ FOREIGN KEY (devportal_uuid) REFERENCES devportals(uuid) ON DELETE CASCADE,
+ FOREIGN KEY (organization_uuid) REFERENCES organizations(uuid) ON DELETE NO ACTION,
+ UNIQUE (api_uuid, devportal_uuid, organization_uuid)
+);
+
+-- LLM Provider Templates table
+IF OBJECT_ID(N'dbo.llm_provider_templates', N'U') IS NULL
+CREATE TABLE dbo.llm_provider_templates (
+ uuid VARCHAR(40) PRIMARY KEY,
+ organization_uuid VARCHAR(40) NOT NULL,
+ handle VARCHAR(255) NOT NULL,
+ name VARCHAR(253) NOT NULL,
+ description VARCHAR(1023),
+ created_by VARCHAR(255),
+ configuration NVARCHAR(MAX) NOT NULL,
+ created_at DATETIME2(7) DEFAULT SYSUTCDATETIME(),
+ updated_at DATETIME2(7) DEFAULT SYSUTCDATETIME(),
+ FOREIGN KEY (organization_uuid) REFERENCES organizations(uuid) ON DELETE CASCADE,
+ UNIQUE(organization_uuid, handle)
+);
+
+-- LLM Providers table
+IF OBJECT_ID(N'dbo.llm_providers', N'U') IS NULL
+CREATE TABLE dbo.llm_providers (
+ uuid VARCHAR(40) PRIMARY KEY,
+ description VARCHAR(1023),
+ created_by VARCHAR(255),
+ template_uuid VARCHAR(40) NOT NULL,
+ openapi_spec NVARCHAR(MAX),
+ model_list NVARCHAR(MAX),
+ status VARCHAR(20) NOT NULL DEFAULT 'CREATED',
+ configuration NVARCHAR(MAX) NOT NULL,
+ FOREIGN KEY (uuid) REFERENCES artifacts(uuid) ON DELETE CASCADE,
+ FOREIGN KEY (template_uuid) REFERENCES llm_provider_templates(uuid) ON DELETE NO ACTION
+);
+
+-- LLM Proxies table
+IF OBJECT_ID(N'dbo.llm_proxies', N'U') IS NULL
+CREATE TABLE dbo.llm_proxies (
+ uuid VARCHAR(40) PRIMARY KEY,
+ project_uuid VARCHAR(40) NOT NULL,
+ description VARCHAR(1023),
+ created_by VARCHAR(255),
+ provider_uuid VARCHAR(40) NOT NULL,
+ openapi_spec NVARCHAR(MAX),
+ status VARCHAR(20) NOT NULL DEFAULT 'CREATED',
+ configuration NVARCHAR(MAX) NOT NULL,
+ FOREIGN KEY (uuid) REFERENCES artifacts(uuid) ON DELETE CASCADE,
+ FOREIGN KEY (project_uuid) REFERENCES projects(uuid) ON DELETE CASCADE,
+ FOREIGN KEY (provider_uuid) REFERENCES llm_providers(uuid) ON DELETE NO ACTION
+);
+
+-- MCP Proxies table
+IF OBJECT_ID(N'dbo.mcp_proxies', N'U') IS NULL
+CREATE TABLE dbo.mcp_proxies (
+ uuid VARCHAR(40) PRIMARY KEY,
+ project_uuid VARCHAR(40),
+ description VARCHAR(1023),
+ created_by VARCHAR(255),
+ status VARCHAR(20) NOT NULL DEFAULT 'CREATED',
+ configuration NVARCHAR(MAX) NOT NULL,
+ FOREIGN KEY (uuid) REFERENCES artifacts(uuid) ON DELETE CASCADE,
+ FOREIGN KEY (project_uuid) REFERENCES projects(uuid) ON DELETE CASCADE
+);
+
+-- WEBSUB APIs table
+IF OBJECT_ID(N'dbo.websub_apis', N'U') IS NULL
+CREATE TABLE dbo.websub_apis (
+ uuid VARCHAR(40) PRIMARY KEY,
+ project_uuid VARCHAR(40) NOT NULL,
+ description VARCHAR(1023),
+ created_by VARCHAR(255),
+ lifecycle_status VARCHAR(20) NOT NULL DEFAULT 'CREATED',
+ transport VARCHAR(255),
+ configuration NVARCHAR(MAX) NOT NULL,
+ FOREIGN KEY (uuid) REFERENCES artifacts(uuid) ON DELETE CASCADE,
+ FOREIGN KEY (project_uuid) REFERENCES projects(uuid) ON DELETE CASCADE
+);
+IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_websub_apis_project' AND object_id = OBJECT_ID(N'dbo.websub_apis'))
+CREATE INDEX idx_websub_apis_project ON dbo.websub_apis(project_uuid);
+
+-- WEBBROKER APIs table
+IF OBJECT_ID(N'dbo.webbroker_apis', N'U') IS NULL
+CREATE TABLE dbo.webbroker_apis (
+ uuid VARCHAR(40) PRIMARY KEY,
+ project_uuid VARCHAR(40) NOT NULL,
+ description VARCHAR(1023),
+ created_by VARCHAR(255),
+ lifecycle_status VARCHAR(20) NOT NULL DEFAULT 'CREATED',
+ transport VARCHAR(255),
+ configuration NVARCHAR(MAX) NOT NULL,
+ FOREIGN KEY (uuid) REFERENCES artifacts(uuid) ON DELETE CASCADE,
+ FOREIGN KEY (project_uuid) REFERENCES projects(uuid) ON DELETE CASCADE
+);
+IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_webbroker_apis_project' AND object_id = OBJECT_ID(N'dbo.webbroker_apis'))
+CREATE INDEX idx_webbroker_apis_project ON dbo.webbroker_apis(project_uuid);
+
+-- API Keys table (stores API keys for artifacts with hashes as JSON string)
+IF OBJECT_ID(N'dbo.api_keys', N'U') IS NULL
+CREATE TABLE dbo.api_keys (
+ uuid VARCHAR(40) PRIMARY KEY,
+ artifact_uuid VARCHAR(40) NOT NULL,
+ name VARCHAR(63) NOT NULL,
+ masked_api_key VARCHAR(8) NOT NULL,
+ api_key_hashes NVARCHAR(MAX) NOT NULL DEFAULT '{}',
+ status VARCHAR(20) NOT NULL DEFAULT 'active',
+ created_at DATETIME2(7) DEFAULT SYSUTCDATETIME(),
+ created_by VARCHAR(255),
+ updated_at DATETIME2(7) DEFAULT SYSUTCDATETIME(),
+ expires_at DATETIME2(7),
+ issuer NVARCHAR(MAX) NULL DEFAULT NULL,
+ allowed_targets NVARCHAR(MAX) NOT NULL DEFAULT 'ALL',
+ FOREIGN KEY (artifact_uuid) REFERENCES artifacts(uuid) ON DELETE CASCADE,
+ UNIQUE(artifact_uuid, name)
+);
+
+-- Application API Key mappings table
+IF OBJECT_ID(N'dbo.application_api_keys', N'U') IS NULL
+CREATE TABLE dbo.application_api_keys (
+ application_uuid VARCHAR(40) NOT NULL,
+ api_key_id VARCHAR(40) NOT NULL,
+ created_at DATETIME2(7) DEFAULT SYSUTCDATETIME(),
+ updated_at DATETIME2(7) DEFAULT SYSUTCDATETIME(),
+ PRIMARY KEY (application_uuid, api_key_id),
+ FOREIGN KEY (application_uuid) REFERENCES applications(uuid) ON DELETE CASCADE,
+ FOREIGN KEY (api_key_id) REFERENCES api_keys(uuid) ON DELETE CASCADE
+);
+
+-- Application to artifacts mapping table
+IF OBJECT_ID(N'dbo.application_artifacts', N'U') IS NULL
+CREATE TABLE dbo.application_artifacts (
+ application_uuid VARCHAR(40) NOT NULL,
+ artifact_uuid VARCHAR(40) NOT NULL,
+ created_at DATETIME2(7) DEFAULT SYSUTCDATETIME(),
+ updated_at DATETIME2(7) DEFAULT SYSUTCDATETIME(),
+ PRIMARY KEY (application_uuid, artifact_uuid),
+ FOREIGN KEY (application_uuid) REFERENCES applications(uuid) ON DELETE CASCADE,
+ FOREIGN KEY (artifact_uuid) REFERENCES artifacts(uuid) ON DELETE CASCADE
+);
+
+-- Indexes for better performance
+IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_projects_organization_id' AND object_id = OBJECT_ID(N'dbo.projects'))
+CREATE INDEX idx_projects_organization_id ON dbo.projects(organization_uuid);
+IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_rest_apis_project_id' AND object_id = OBJECT_ID(N'dbo.rest_apis'))
+CREATE INDEX idx_rest_apis_project_id ON dbo.rest_apis(project_uuid);
+IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_subscriptions_api_uuid' AND object_id = OBJECT_ID(N'dbo.subscriptions'))
+CREATE INDEX idx_subscriptions_api_uuid ON dbo.subscriptions(api_uuid);
+IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_subscriptions_application_id' AND object_id = OBJECT_ID(N'dbo.subscriptions'))
+CREATE INDEX idx_subscriptions_application_id ON dbo.subscriptions(application_id);
+IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_subscriptions_organization_uuid' AND object_id = OBJECT_ID(N'dbo.subscriptions'))
+CREATE INDEX idx_subscriptions_organization_uuid ON dbo.subscriptions(organization_uuid);
+IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_subscriptions_status' AND object_id = OBJECT_ID(N'dbo.subscriptions'))
+CREATE INDEX idx_subscriptions_status ON dbo.subscriptions(status);
+IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_gateways_org' AND object_id = OBJECT_ID(N'dbo.gateways'))
+CREATE INDEX idx_gateways_org ON dbo.gateways(organization_uuid);
+IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_gateway_tokens_status' AND object_id = OBJECT_ID(N'dbo.gateway_tokens'))
+CREATE INDEX idx_gateway_tokens_status ON dbo.gateway_tokens(gateway_uuid, status);
+IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_artifact_deployments_artifact_gateway' AND object_id = OBJECT_ID(N'dbo.deployments'))
+CREATE INDEX idx_artifact_deployments_artifact_gateway ON dbo.deployments(artifact_uuid, gateway_uuid);
+IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_artifact_deployments_created_at' AND object_id = OBJECT_ID(N'dbo.deployments'))
+CREATE INDEX idx_artifact_deployments_created_at ON dbo.deployments(artifact_uuid, gateway_uuid, created_at);
+IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_artifact_gw_created' AND object_id = OBJECT_ID(N'dbo.deployments'))
+CREATE INDEX idx_artifact_gw_created ON dbo.deployments(artifact_uuid, organization_uuid, gateway_uuid, created_at DESC);
+IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_deployment_status_deployment' AND object_id = OBJECT_ID(N'dbo.deployment_status'))
+CREATE INDEX idx_deployment_status_deployment ON dbo.deployment_status(deployment_id);
+IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_deployment_status_status' AND object_id = OBJECT_ID(N'dbo.deployment_status'))
+CREATE INDEX idx_deployment_status_status ON dbo.deployment_status(status);
+IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_devportals_org' AND object_id = OBJECT_ID(N'dbo.devportals'))
+CREATE INDEX idx_devportals_org ON dbo.devportals(organization_uuid);
+IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_devportals_active' AND object_id = OBJECT_ID(N'dbo.devportals'))
+CREATE INDEX idx_devportals_active ON dbo.devportals(organization_uuid, is_active);
+IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_publication_mappings_api' AND object_id = OBJECT_ID(N'dbo.publication_mappings'))
+CREATE INDEX idx_publication_mappings_api ON dbo.publication_mappings(api_uuid);
+IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_publication_mappings_devportal' AND object_id = OBJECT_ID(N'dbo.publication_mappings'))
+CREATE INDEX idx_publication_mappings_devportal ON dbo.publication_mappings(devportal_uuid);
+IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_publication_mappings_org' AND object_id = OBJECT_ID(N'dbo.publication_mappings'))
+CREATE INDEX idx_publication_mappings_org ON dbo.publication_mappings(organization_uuid);
+IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_devportals_default_per_org' AND object_id = OBJECT_ID(N'dbo.devportals'))
+CREATE UNIQUE INDEX idx_devportals_default_per_org ON dbo.devportals(organization_uuid) WHERE is_default = 1;
+IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_artifact_associations_artifact_resource_type' AND object_id = OBJECT_ID(N'dbo.association_mappings'))
+CREATE INDEX idx_artifact_associations_artifact_resource_type ON dbo.association_mappings(artifact_uuid, association_type, organization_uuid);
+IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_association_mappings_resource' AND object_id = OBJECT_ID(N'dbo.association_mappings'))
+CREATE INDEX idx_association_mappings_resource ON dbo.association_mappings(association_type, resource_uuid, organization_uuid);
+IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_association_mappings_org' AND object_id = OBJECT_ID(N'dbo.association_mappings'))
+CREATE INDEX idx_association_mappings_org ON dbo.association_mappings(organization_uuid);
+IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_artifacts_org' AND object_id = OBJECT_ID(N'dbo.artifacts'))
+CREATE INDEX idx_artifacts_org ON dbo.artifacts(organization_uuid);
+IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_llm_provider_templates_org' AND object_id = OBJECT_ID(N'dbo.llm_provider_templates'))
+CREATE INDEX idx_llm_provider_templates_org ON dbo.llm_provider_templates(organization_uuid);
+IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_llm_providers_template' AND object_id = OBJECT_ID(N'dbo.llm_providers'))
+CREATE INDEX idx_llm_providers_template ON dbo.llm_providers(template_uuid);
+IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_llm_proxies_project' AND object_id = OBJECT_ID(N'dbo.llm_proxies'))
+CREATE INDEX idx_llm_proxies_project ON dbo.llm_proxies(project_uuid);
+IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_llm_proxies_provider_uuid' AND object_id = OBJECT_ID(N'dbo.llm_proxies'))
+CREATE INDEX idx_llm_proxies_provider_uuid ON dbo.llm_proxies(provider_uuid);
+IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_api_keys_artifact' AND object_id = OBJECT_ID(N'dbo.api_keys'))
+CREATE INDEX idx_api_keys_artifact ON dbo.api_keys(artifact_uuid);
+IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_applications_project_id' AND object_id = OBJECT_ID(N'dbo.applications'))
+CREATE INDEX idx_applications_project_id ON dbo.applications(project_uuid, organization_uuid);
+IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_applications_name_project' AND object_id = OBJECT_ID(N'dbo.applications'))
+CREATE INDEX idx_applications_name_project ON dbo.applications(name, project_uuid, organization_uuid);
+IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_applications_handle_org' AND object_id = OBJECT_ID(N'dbo.applications'))
+CREATE INDEX idx_applications_handle_org ON dbo.applications(handle, organization_uuid);
+IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_application_api_keys_app_id' AND object_id = OBJECT_ID(N'dbo.application_api_keys'))
+CREATE INDEX idx_application_api_keys_app_id ON dbo.application_api_keys(application_uuid);
+IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_application_api_keys_key_id' AND object_id = OBJECT_ID(N'dbo.application_api_keys'))
+CREATE INDEX idx_application_api_keys_key_id ON dbo.application_api_keys(api_key_id);
+IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_application_artifacts_app_id' AND object_id = OBJECT_ID(N'dbo.application_artifacts'))
+CREATE INDEX idx_application_artifacts_app_id ON dbo.application_artifacts(application_uuid);
+IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_application_artifacts_artifact_id' AND object_id = OBJECT_ID(N'dbo.application_artifacts'))
+CREATE INDEX idx_application_artifacts_artifact_id ON dbo.application_artifacts(artifact_uuid);
+
+-- EventHub tables for multi-replica HA sync and gateway event propagation.
+-- Counterpart of the gateway_states / events tables in schema.postgres.sql.
+-- Keyed columns are bounded NVARCHAR to stay within SQL Server index-key limits.
+IF OBJECT_ID(N'dbo.gateway_states', N'U') IS NULL
+CREATE TABLE dbo.gateway_states (
+ gateway_id NVARCHAR(64) PRIMARY KEY,
+ version_id NVARCHAR(255) NOT NULL DEFAULT '',
+ updated_at DATETIME2(7) NOT NULL DEFAULT SYSUTCDATETIME()
+);
+
+IF OBJECT_ID(N'dbo.events', N'U') IS NULL
+CREATE TABLE dbo.events (
+ gateway_id NVARCHAR(64) NOT NULL,
+ processed_timestamp DATETIME2(7) NOT NULL DEFAULT SYSUTCDATETIME(),
+ originated_timestamp DATETIME2(7) NOT NULL,
+ entity_type NVARCHAR(255) NOT NULL,
+ action NVARCHAR(20) NOT NULL CHECK(action IN ('CREATE', 'UPDATE', 'DELETE')),
+ entity_id NVARCHAR(255) NOT NULL,
+ event_id NVARCHAR(64) NOT NULL,
+ event_data NVARCHAR(MAX) NOT NULL,
+ PRIMARY KEY (gateway_id, event_id),
+ FOREIGN KEY (gateway_id) REFERENCES dbo.gateway_states(gateway_id) ON DELETE CASCADE
+);
+
+IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_events_gateway_id_processed_timestamp' AND object_id = OBJECT_ID(N'dbo.events'))
+CREATE INDEX idx_events_gateway_id_processed_timestamp ON dbo.events(gateway_id, processed_timestamp);
diff --git a/platform-api/src/internal/integration/cascade_test.go b/platform-api/src/internal/integration/cascade_test.go
new file mode 100644
index 000000000..6cfa866ec
--- /dev/null
+++ b/platform-api/src/internal/integration/cascade_test.go
@@ -0,0 +1,193 @@
+/*
+ * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com).
+ *
+ * WSO2 LLC. licenses this file to you under the Apache License,
+ * Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+//go:build integration
+
+package integration
+
+import (
+ "testing"
+
+ "github.com/google/uuid"
+)
+
+// graph holds the identifiers seeded into a single organization.
+type graph struct {
+ org, project, app string
+ apiArtifact, depArtifact string
+ plan, sub, gateway, deploy string
+ devportal, apiKey string
+}
+
+// seedOrgGraph inserts a representative object graph for one organization that
+// touches every table whose foreign keys were changed for SQL Server
+// (applications, subscriptions, deployments, deployment_status,
+// publication_mappings) plus their parents.
+func seedOrgGraph(t *testing.T, it *itDB) graph {
+ t.Helper()
+ g := graph{
+ org: id(), project: id(), app: id(),
+ apiArtifact: id(), depArtifact: id(),
+ plan: id(), sub: id(), gateway: id(), deploy: id(),
+ devportal: id(), apiKey: id(),
+ }
+
+ it.exec(t, `INSERT INTO organizations (uuid, handle, name, region) VALUES (?, ?, ?, ?)`,
+ g.org, "h-"+g.org[:8], "it org", "us")
+ it.exec(t, `INSERT INTO projects (uuid, name, organization_uuid) VALUES (?, ?, ?)`,
+ g.project, "proj", g.org)
+ it.exec(t, `INSERT INTO applications (uuid, handle, project_uuid, organization_uuid, name, type) VALUES (?, ?, ?, ?, ?, ?)`,
+ g.app, "app-"+g.app[:8], g.project, g.org, "app", "standard")
+
+ // REST API: an artifact + its rest_apis row (shared uuid).
+ it.exec(t, `INSERT INTO artifacts (uuid, handle, name, version, kind, organization_uuid) VALUES (?, ?, ?, ?, ?, ?)`,
+ g.apiArtifact, "api-"+g.apiArtifact[:8], "api", "1.0", "rest_api", g.org)
+ it.exec(t, `INSERT INTO rest_apis (uuid, project_uuid, configuration) VALUES (?, ?, ?)`,
+ g.apiArtifact, g.project, "{}")
+
+ it.exec(t, `INSERT INTO subscription_plans (uuid, plan_name, organization_uuid) VALUES (?, ?, ?)`,
+ g.plan, "plan-"+g.plan[:8], g.org)
+ it.exec(t, `INSERT INTO subscriptions (uuid, api_uuid, subscriber_id, subscription_token, subscription_token_hash, subscription_plan_uuid, organization_uuid)
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
+ g.sub, g.apiArtifact, "subscriber", "tok-"+g.sub[:8], "hash-"+g.sub[:8], g.plan, g.org)
+
+ // Gateway + a deployment + its current status.
+ it.exec(t, `INSERT INTO gateways (uuid, organization_uuid, name, display_name, vhost) VALUES (?, ?, ?, ?, ?)`,
+ g.gateway, g.org, "gw", "gw", "localhost")
+ it.exec(t, `INSERT INTO artifacts (uuid, handle, name, version, kind, organization_uuid) VALUES (?, ?, ?, ?, ?, ?)`,
+ g.depArtifact, "dep-"+g.depArtifact[:8], "dep", "1.0", "rest_api", g.org)
+ it.exec(t, `INSERT INTO deployments (deployment_id, name, artifact_uuid, organization_uuid, gateway_uuid, content) VALUES (?, ?, ?, ?, ?, ?)`,
+ g.deploy, "d", g.depArtifact, g.org, g.gateway, []byte("x"))
+ it.exec(t, `INSERT INTO deployment_status (artifact_uuid, organization_uuid, gateway_uuid, deployment_id) VALUES (?, ?, ?, ?)`,
+ g.depArtifact, g.org, g.gateway, g.deploy)
+
+ // DevPortal + a publication of the API to it.
+ it.exec(t, `INSERT INTO devportals (uuid, organization_uuid, name, identifier, api_url, hostname, api_key) VALUES (?, ?, ?, ?, ?, ?, ?)`,
+ g.devportal, g.org, "dp", "dp-"+g.devportal[:8], "http://dp", "dp.local", "k")
+ it.exec(t, `INSERT INTO publication_mappings (api_uuid, devportal_uuid, organization_uuid, status, sandbox_endpoint_url, production_endpoint_url)
+ VALUES (?, ?, ?, ?, ?, ?)`,
+ g.apiArtifact, g.devportal, g.org, "published", "http://sb", "http://prod")
+
+ // An API key on the deployment artifact + its application mapping.
+ it.exec(t, `INSERT INTO api_keys (uuid, artifact_uuid, name, masked_api_key) VALUES (?, ?, ?, ?)`,
+ g.apiKey, g.depArtifact, "key", "ab12")
+ it.exec(t, `INSERT INTO application_api_keys (application_uuid, api_key_id) VALUES (?, ?)`, g.app, g.apiKey)
+ it.exec(t, `INSERT INTO application_artifacts (application_uuid, artifact_uuid) VALUES (?, ?)`, g.app, g.depArtifact)
+ return g
+}
+
+// TestCascade_DeleteRestAPIRemovesSubscriptions verifies the kept
+// api_uuid -> rest_apis CASCADE still removes subscriptions (the
+// subscriptions.organization_uuid / artifacts edges are now NO ACTION on
+// SQL Server, so cleanup must flow through rest_apis).
+func TestCascade_DeleteRestAPIRemovesSubscriptions(t *testing.T) {
+ it := openITDB(t)
+ defer it.db.Close()
+ g := seedOrgGraph(t, it)
+
+ if got := it.count(t, "subscriptions", "uuid", g.sub); got != 1 {
+ t.Fatalf("precondition: want 1 subscription, got %d", got)
+ }
+ // Mirrors APIRepo.DeleteAPI ordering: publications/deployments are removed
+ // explicitly, rest_apis + artifacts cascade the rest.
+ it.exec(t, `DELETE FROM publication_mappings WHERE api_uuid = ? AND organization_uuid = ?`, g.apiArtifact, g.org)
+ it.exec(t, `DELETE FROM rest_apis WHERE uuid = ?`, g.apiArtifact)
+ it.exec(t, `DELETE FROM artifacts WHERE uuid = ?`, g.apiArtifact)
+
+ if got := it.count(t, "subscriptions", "uuid", g.sub); got != 0 {
+ t.Fatalf("[%s] subscription not cascade-deleted after REST API delete: %d remain", it.driver, got)
+ }
+}
+
+// TestCascade_DeleteGatewayRemovesDeployments verifies gateway deletion still
+// removes its deployments and deployment_status (deployment_status now cascades
+// only via deployment_id on SQL Server).
+func TestCascade_DeleteGatewayRemovesDeployments(t *testing.T) {
+ it := openITDB(t)
+ defer it.db.Close()
+ g := seedOrgGraph(t, it)
+
+ if got := it.count(t, "deployment_status", "deployment_id", g.deploy); got != 1 {
+ t.Fatalf("precondition: want 1 deployment_status, got %d", got)
+ }
+ it.exec(t, `DELETE FROM gateways WHERE uuid = ? AND organization_uuid = ?`, g.gateway, g.org)
+
+ if got := it.count(t, "deployments", "deployment_id", g.deploy); got != 0 {
+ t.Fatalf("[%s] deployment not removed after gateway delete: %d remain", it.driver, got)
+ }
+ if got := it.count(t, "deployment_status", "deployment_id", g.deploy); got != 0 {
+ t.Fatalf("[%s] deployment_status not removed after gateway delete: %d remain", it.driver, got)
+ }
+}
+
+// TestCascade_DeleteDevPortalRemovesPublications verifies the devportal edge of
+// publication_mappings still cascades. (For SQL Server we kept devportals ->
+// publication_mappings as CASCADE and relaxed the api_uuid / organization
+// edges, so this confirms we kept the load-bearing edge.)
+func TestCascade_DeleteDevPortalRemovesPublications(t *testing.T) {
+ it := openITDB(t)
+ defer it.db.Close()
+ g := seedOrgGraph(t, it)
+
+ if got := it.count(t, "publication_mappings", "devportal_uuid", g.devportal); got != 1 {
+ t.Fatalf("precondition: want 1 publication, got %d", got)
+ }
+ it.exec(t, `DELETE FROM devportals WHERE uuid = ? AND organization_uuid = ?`, g.devportal, g.org)
+ if got := it.count(t, "publication_mappings", "devportal_uuid", g.devportal); got != 0 {
+ t.Fatalf("[%s] publication not removed after devportal delete: %d remain", it.driver, got)
+ }
+}
+
+// TestCascade_DeleteApplicationRemovesMappings verifies application deletion
+// still cascade-removes its key and artifact mappings (these edges are
+// unchanged, but the app's organization edge changed, so it is worth pinning).
+func TestCascade_DeleteApplicationRemovesMappings(t *testing.T) {
+ it := openITDB(t)
+ defer it.db.Close()
+ g := seedOrgGraph(t, it)
+
+ it.exec(t, `DELETE FROM applications WHERE uuid = ? AND organization_uuid = ?`, g.app, g.org)
+ if got := it.count(t, "application_api_keys", "api_key_id", g.apiKey); got != 0 {
+ t.Fatalf("[%s] application_api_keys not removed after application delete: %d remain", it.driver, got)
+ }
+ if got := it.count(t, "application_artifacts", "application_uuid", g.app); got != 0 {
+ t.Fatalf("[%s] application_artifacts not removed after application delete: %d remain", it.driver, got)
+ }
+}
+
+// TestCascade_DeleteProjectRemovesApplications verifies the kept projects ->
+// applications CASCADE (the org-direct application edge is now NO ACTION on
+// SQL Server, so applications must be cleaned via the project edge). Project
+// deletion is guarded against associated APIs in the service layer, so we
+// remove the API artifacts first to mirror a legitimate project delete.
+func TestCascade_DeleteProjectRemovesApplications(t *testing.T) {
+ it := openITDB(t)
+ defer it.db.Close()
+ g := seedOrgGraph(t, it)
+
+ // Remove API-side rows first (as the service guard requires no APIs).
+ it.exec(t, `DELETE FROM publication_mappings WHERE api_uuid = ?`, g.apiArtifact)
+ it.exec(t, `DELETE FROM artifacts WHERE uuid = ?`, g.apiArtifact)
+
+ it.exec(t, `DELETE FROM projects WHERE uuid = ?`, g.project)
+ if got := it.count(t, "applications", "uuid", g.app); got != 0 {
+ t.Fatalf("[%s] application not removed after project delete: %d remain", it.driver, got)
+ }
+}
+
+func id() string { return uuid.NewString() }
diff --git a/platform-api/src/internal/integration/harness_test.go b/platform-api/src/internal/integration/harness_test.go
new file mode 100644
index 000000000..ef65a7652
--- /dev/null
+++ b/platform-api/src/internal/integration/harness_test.go
@@ -0,0 +1,174 @@
+/*
+ * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com).
+ *
+ * WSO2 LLC. licenses this file to you under the Apache License,
+ * Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+// Package integration holds cross-database integration tests for platform-api.
+// They run against a real database engine (SQLite, PostgreSQL or SQL Server)
+// selected by the IT_DB environment variable and exercise the real schema and
+// data-access behavior — pagination, multi-table writes and delete cascades —
+// so backend-specific bugs (e.g. SQL Server LIMIT/cascade-path issues) are
+// caught instead of being hidden behind the SQLite unit-test path.
+//
+// Build-tagged `integration` so it is excluded from the default `go test ./...`.
+//
+//go:build integration
+
+package integration
+
+import (
+ "fmt"
+ "io"
+ "log/slog"
+ "os"
+ "regexp"
+ "strconv"
+ "testing"
+ "time"
+
+ "platform-api/src/config"
+ "platform-api/src/internal/database"
+)
+
+// itDB describes the database engine under test.
+type itDB struct {
+ driver string
+ db *database.DB
+}
+
+// openITDB opens the database selected by IT_DB (default: sqlite) and applies
+// the matching schema. Supported values: sqlite, postgres, sqlserver.
+func openITDB(t *testing.T) *itDB {
+ t.Helper()
+ driver := envOr("IT_DB", "sqlite")
+ logger := slog.New(slog.NewTextHandler(io.Discard, nil))
+
+ var cfg *config.Database
+ switch driver {
+ case "sqlite", "sqlite3":
+ dir := t.TempDir()
+ cfg = &config.Database{Driver: "sqlite3", Path: dir + "/it.db", MaxOpenConns: 1, MaxIdleConns: 1}
+ driver = "sqlite"
+ case "postgres", "postgresql":
+ cfg = &config.Database{
+ Driver: "postgres",
+ Host: envOr("IT_DB_HOST", "localhost"),
+ Port: atoiOr("IT_DB_PORT", 5432),
+ Name: envOr("IT_DB_NAME", "platform_api_it"),
+ User: envOr("IT_DB_USER", "postgres"),
+ Password: os.Getenv("IT_DB_PASSWORD"),
+ SSLMode: envOr("IT_DB_SSLMODE", "disable"),
+ MaxOpenConns: 10, MaxIdleConns: 5,
+ }
+ driver = "postgres"
+ case "sqlserver", "mssql":
+ name := envOr("IT_DB_NAME", "platform_api_it")
+ ensureSQLServerDB(t, logger, name)
+ cfg = &config.Database{
+ Driver: "sqlserver",
+ Host: envOr("IT_DB_HOST", "localhost"),
+ Port: atoiOr("IT_DB_PORT", 1433),
+ Name: name,
+ User: envOr("IT_DB_USER", "sa"),
+ Password: os.Getenv("IT_DB_PASSWORD"),
+ SSLMode: envOr("IT_DB_SSLMODE", "disable"),
+ MaxOpenConns: 10, MaxIdleConns: 5,
+ }
+ driver = "sqlserver"
+ default:
+ t.Fatalf("unsupported IT_DB %q (want sqlite|postgres|sqlserver)", driver)
+ }
+
+ db := connectITDB(t, cfg, logger)
+ if err := db.InitSchema("../database/schema.sql", logger); err != nil {
+ db.Close()
+ t.Fatalf("InitSchema (%s) failed: %v", driver, err)
+ }
+ return &itDB{driver: driver, db: db}
+}
+
+func connectITDB(t *testing.T, cfg *config.Database, logger *slog.Logger) *database.DB {
+ t.Helper()
+ deadline := time.Now().Add(90 * time.Second)
+ var lastErr error
+ for time.Now().Before(deadline) {
+ db, err := database.NewConnection(cfg, logger)
+ if err == nil {
+ return db
+ }
+ lastErr = err
+ time.Sleep(2 * time.Second)
+ }
+ t.Fatalf("could not connect to %s within timeout: %v", cfg.Driver, lastErr)
+ return nil
+}
+
+// validDBName guards the database name that is interpolated into the
+// CREATE DATABASE statement below (identifiers cannot be bound as parameters).
+// The name comes from IT_DB_NAME; restrict it to a safe identifier charset.
+var validDBName = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]{0,62}$`)
+
+// ensureSQLServerDB creates the SQL Server test database (via master) if absent.
+func ensureSQLServerDB(t *testing.T, logger *slog.Logger, name string) {
+ t.Helper()
+ if !validDBName.MatchString(name) {
+ t.Fatalf("invalid IT_DB_NAME %q: must match %s", name, validDBName.String())
+ }
+ master := connectITDB(t, &config.Database{
+ Driver: "sqlserver", Host: envOr("IT_DB_HOST", "localhost"), Port: atoiOr("IT_DB_PORT", 1433),
+ Name: "master", User: envOr("IT_DB_USER", "sa"), Password: os.Getenv("IT_DB_PASSWORD"),
+ SSLMode: "disable", MaxOpenConns: 2, MaxIdleConns: 1,
+ }, logger)
+ defer master.Close()
+ if _, err := master.Exec(fmt.Sprintf("IF DB_ID(N'%s') IS NULL CREATE DATABASE [%s]", name, name)); err != nil {
+ t.Fatalf("failed to ensure sqlserver database %q: %v", name, err)
+ }
+}
+
+func envOr(key, def string) string {
+ if v := os.Getenv(key); v != "" {
+ return v
+ }
+ return def
+}
+
+func atoiOr(key string, def int) int {
+ if v := os.Getenv(key); v != "" {
+ if n, err := strconv.Atoi(v); err == nil {
+ return n
+ }
+ }
+ return def
+}
+
+// count returns the number of rows in table matching the org filter.
+func (it *itDB) count(t *testing.T, table, col, val string) int {
+ t.Helper()
+ var n int
+ q := it.db.Rebind(fmt.Sprintf("SELECT COUNT(*) FROM %s WHERE %s = ?", table, col))
+ if err := it.db.QueryRow(q, val).Scan(&n); err != nil {
+ t.Fatalf("count(%s) failed: %v", table, err)
+ }
+ return n
+}
+
+// exec runs a `?`-placeholder statement, rebinding for the active driver.
+func (it *itDB) exec(t *testing.T, query string, args ...any) {
+ t.Helper()
+ if _, err := it.db.Exec(it.db.Rebind(query), args...); err != nil {
+ t.Fatalf("exec failed on %s: %v\nquery: %s", it.driver, err, query)
+ }
+}
diff --git a/platform-api/src/internal/integration/lifecycle_test.go b/platform-api/src/internal/integration/lifecycle_test.go
new file mode 100644
index 000000000..5a974d8fc
--- /dev/null
+++ b/platform-api/src/internal/integration/lifecycle_test.go
@@ -0,0 +1,313 @@
+/*
+ * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com).
+ *
+ * WSO2 LLC. licenses this file to you under the Apache License,
+ * Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+//go:build integration
+
+package integration
+
+import (
+ "fmt"
+ "testing"
+
+ "platform-api/src/internal/model"
+ "platform-api/src/internal/repository"
+)
+
+// TestLifecycle_OrganizationPagination drives the real repository layer through
+// create + paginated list, exercising DB.PaginationClause across the active
+// engine. On SQL Server this is the code path that previously failed with
+// "Incorrect syntax near 'LIMIT'".
+func TestLifecycle_OrganizationPagination(t *testing.T) {
+ it := openITDB(t)
+ defer it.db.Close()
+ orgRepo := repository.NewOrganizationRepo(it.db)
+
+ baseline, err := orgRepo.ListOrganizations(1_000_000, 0)
+ if err != nil {
+ t.Fatalf("[%s] baseline list failed: %v", it.driver, err)
+ }
+
+ const n = 5
+ for i := range n {
+ org := &model.Organization{ID: id(), Handle: "lc-" + id()[:8], Name: fmt.Sprintf("org %d", i), Region: "us"}
+ if err := orgRepo.CreateOrganization(org); err != nil {
+ t.Fatalf("[%s] create org failed: %v", it.driver, err)
+ }
+ }
+ total := len(baseline) + n
+
+ // Page through in steps of 2 and confirm full, non-overlapping coverage.
+ seen := map[string]bool{}
+ for offset := 0; offset < total; offset += 2 {
+ page, err := orgRepo.ListOrganizations(2, offset)
+ if err != nil {
+ t.Fatalf("[%s] ListOrganizations(2,%d) failed: %v", it.driver, offset, err)
+ }
+ want := 2
+ if rem := total - offset; rem < want {
+ want = rem
+ }
+ if len(page) != want {
+ t.Fatalf("[%s] page at offset %d: want %d rows, got %d", it.driver, offset, want, len(page))
+ }
+ for _, o := range page {
+ if seen[o.ID] {
+ t.Fatalf("[%s] pagination overlap at offset %d: id %s seen twice", it.driver, offset, o.ID)
+ }
+ seen[o.ID] = true
+ }
+ }
+ if len(seen) != total {
+ t.Fatalf("[%s] paging covered %d rows, want %d", it.driver, len(seen), total)
+ }
+}
+
+// TestLifecycle_SubscriptionPlanExistsAndList exercises FetchFirstClause (the
+// SELECT 1 ... FETCH NEXT 1 existence check) and a filtered paginated list
+// through the real repository, across the active engine.
+func TestLifecycle_SubscriptionPlanExistsAndList(t *testing.T) {
+ it := openITDB(t)
+ defer it.db.Close()
+ orgRepo := repository.NewOrganizationRepo(it.db)
+ planRepo := repository.NewSubscriptionPlanRepo(it.db)
+
+ org := &model.Organization{ID: id(), Handle: "pl-" + id()[:8], Name: "plan org", Region: "us"}
+ if err := orgRepo.CreateOrganization(org); err != nil {
+ t.Fatalf("[%s] create org failed: %v", it.driver, err)
+ }
+
+ exists, err := planRepo.ExistsByNameAndOrg("nope-"+id(), org.ID)
+ if err != nil {
+ t.Fatalf("[%s] ExistsByNameAndOrg failed: %v", it.driver, err)
+ }
+ if exists {
+ t.Fatalf("[%s] ExistsByNameAndOrg: want false for missing plan", it.driver)
+ }
+
+ count := 5
+ for i := range 3 {
+ // Fully populated: the list repository scans billing_plan / throttle
+ // columns into plain (non-nullable) fields (a pre-existing detail).
+ plan := &model.SubscriptionPlan{
+ UUID: id(), PlanName: fmt.Sprintf("plan-%d-%s", i, id()[:6]),
+ BillingPlan: "free", StopOnQuotaReach: true,
+ ThrottleLimitCount: &count, ThrottleLimitUnit: "min",
+ OrganizationUUID: org.ID, Status: model.SubscriptionPlanStatus("ACTIVE"),
+ }
+ if err := planRepo.Create(plan); err != nil {
+ t.Fatalf("[%s] create plan failed: %v", it.driver, err)
+ }
+ }
+ plans, err := planRepo.ListByOrganization(org.ID, 2, 0)
+ if err != nil {
+ t.Fatalf("[%s] ListByOrganization failed: %v", it.driver, err)
+ }
+ if len(plans) != 2 {
+ t.Fatalf("[%s] ListByOrganization(2,0): want 2, got %d", it.driver, len(plans))
+ }
+}
+
+// TestLifecycle_ProjectPagination exercises ProjectRepo.ListProjects pagination
+// (the project.go LIMIT ? OFFSET ? query) through the real repository.
+func TestLifecycle_ProjectPagination(t *testing.T) {
+ it := openITDB(t)
+ defer it.db.Close()
+ orgRepo := repository.NewOrganizationRepo(it.db)
+ projectRepo := repository.NewProjectRepo(it.db)
+
+ org := &model.Organization{ID: id(), Handle: "pr-" + id()[:8], Name: "proj org", Region: "us"}
+ if err := orgRepo.CreateOrganization(org); err != nil {
+ t.Fatalf("[%s] create org failed: %v", it.driver, err)
+ }
+
+ const n = 5
+ for i := range n {
+ p := &model.Project{ID: id(), Name: fmt.Sprintf("proj-%d-%s", i, id()[:6]), OrganizationID: org.ID, Description: "p"}
+ if err := projectRepo.CreateProject(p); err != nil {
+ t.Fatalf("[%s] create project failed: %v", it.driver, err)
+ }
+ }
+
+ seen := map[string]bool{}
+ for offset := 0; offset < n; offset += 2 {
+ page, err := projectRepo.ListProjects(org.ID, 2, offset)
+ if err != nil {
+ t.Fatalf("[%s] ListProjects(2,%d) failed: %v", it.driver, offset, err)
+ }
+ want := 2
+ if rem := n - offset; rem < want {
+ want = rem
+ }
+ if len(page) != want {
+ t.Fatalf("[%s] ListProjects offset %d: want %d, got %d", it.driver, offset, want, len(page))
+ }
+ for _, p := range page {
+ if seen[p.ID] {
+ t.Fatalf("[%s] project pagination overlap at offset %d: %s", it.driver, offset, p.ID)
+ }
+ seen[p.ID] = true
+ }
+ }
+ if len(seen) != n {
+ t.Fatalf("[%s] project paging covered %d, want %d", it.driver, len(seen), n)
+ }
+}
+
+// TestLifecycle_SubscriptionListByFilters exercises SubscriptionRepo.ListByFilters
+// (the subscription_repository.go LIMIT ? OFFSET ? query) including the status
+// filter, reusing the seeded org graph (which creates one ACTIVE subscription).
+func TestLifecycle_SubscriptionListByFilters(t *testing.T) {
+ it := openITDB(t)
+ defer it.db.Close()
+ g := seedOrgGraph(t, it)
+ subRepo := repository.NewSubscriptionRepo(it.db)
+
+ all, err := subRepo.ListByFilters(g.org, nil, nil, nil, nil, 10, 0)
+ if err != nil {
+ t.Fatalf("[%s] ListByFilters (no filter) failed: %v", it.driver, err)
+ }
+ if len(all) != 1 {
+ t.Fatalf("[%s] ListByFilters: want 1 subscription, got %d", it.driver, len(all))
+ }
+
+ active := "ACTIVE"
+ got, err := subRepo.ListByFilters(g.org, nil, nil, nil, &active, 10, 0)
+ if err != nil {
+ t.Fatalf("[%s] ListByFilters (status=ACTIVE) failed: %v", it.driver, err)
+ }
+ if len(got) != 1 {
+ t.Fatalf("[%s] ListByFilters(status=ACTIVE): want 1, got %d", it.driver, len(got))
+ }
+
+ revoked := "REVOKED"
+ none, err := subRepo.ListByFilters(g.org, nil, nil, nil, &revoked, 10, 0)
+ if err != nil {
+ t.Fatalf("[%s] ListByFilters (status=REVOKED) failed: %v", it.driver, err)
+ }
+ if len(none) != 0 {
+ t.Fatalf("[%s] ListByFilters(status=REVOKED): want 0, got %d", it.driver, len(none))
+ }
+}
+
+// TestLifecycle_DevPortalDefault exercises the devportal default-flag queries
+// (GetDefaultByOrganizationUUID + SetAsDefault). These used the `is_default =
+// TRUE`/`FALSE` boolean literals that are invalid on SQL Server; the fix binds a
+// Go bool instead, which this verifies works on every engine.
+func TestLifecycle_DevPortalDefault(t *testing.T) {
+ it := openITDB(t)
+ defer it.db.Close()
+ orgRepo := repository.NewOrganizationRepo(it.db)
+ dpRepo := repository.NewDevPortalRepository(it.db)
+
+ org := &model.Organization{ID: id(), Handle: "dp-" + id()[:8], Name: "dp org", Region: "us"}
+ if err := orgRepo.CreateOrganization(org); err != nil {
+ t.Fatalf("[%s] create org failed: %v", it.driver, err)
+ }
+
+ dp1 := newDevPortal(org.ID, "dp1", true)
+ dp2 := newDevPortal(org.ID, "dp2", false)
+ if err := dpRepo.Create(dp1); err != nil {
+ t.Fatalf("[%s] create dp1 failed: %v", it.driver, err)
+ }
+ if err := dpRepo.Create(dp2); err != nil {
+ t.Fatalf("[%s] create dp2 failed: %v", it.driver, err)
+ }
+
+ def, err := dpRepo.GetDefaultByOrganizationUUID(org.ID)
+ if err != nil {
+ t.Fatalf("[%s] GetDefaultByOrganizationUUID failed: %v", it.driver, err)
+ }
+ if def == nil || def.UUID != dp1.UUID {
+ t.Fatalf("[%s] default devportal: want dp1, got %+v", it.driver, def)
+ }
+
+ // Switch the default to dp2 (unsets dp1, sets dp2 — the boolean UPDATE path).
+ if err := dpRepo.SetAsDefault(dp2.UUID, org.ID); err != nil {
+ t.Fatalf("[%s] SetAsDefault(dp2) failed: %v", it.driver, err)
+ }
+ def, err = dpRepo.GetDefaultByOrganizationUUID(org.ID)
+ if err != nil {
+ t.Fatalf("[%s] GetDefaultByOrganizationUUID (after switch) failed: %v", it.driver, err)
+ }
+ if def == nil || def.UUID != dp2.UUID {
+ t.Fatalf("[%s] default devportal after switch: want dp2, got %+v", it.driver, def)
+ }
+}
+
+// TestLifecycle_ApplicationByIDOrHandle exercises GetApplicationByIDOrHandle,
+// whose `ORDER BY CASE … FetchFirstClause(1)` query was part of the LIMIT-1 fix
+// (a single-row lookup that resolves by UUID or handle). Verified on every engine.
+func TestLifecycle_ApplicationByIDOrHandle(t *testing.T) {
+ it := openITDB(t)
+ defer it.db.Close()
+ orgRepo := repository.NewOrganizationRepo(it.db)
+ projectRepo := repository.NewProjectRepo(it.db)
+ appRepo := repository.NewApplicationRepo(it.db)
+
+ org := &model.Organization{ID: id(), Handle: "ap-" + id()[:8], Name: "app org", Region: "us"}
+ if err := orgRepo.CreateOrganization(org); err != nil {
+ t.Fatalf("[%s] create org failed: %v", it.driver, err)
+ }
+ proj := &model.Project{ID: id(), Name: "p-" + id()[:6], OrganizationID: org.ID}
+ if err := projectRepo.CreateProject(proj); err != nil {
+ t.Fatalf("[%s] create project failed: %v", it.driver, err)
+ }
+ app := &model.Application{
+ UUID: id(), Handle: "app-" + id()[:8], ProjectUUID: proj.ID,
+ OrganizationUUID: org.ID, Name: "app", Type: "standard",
+ }
+ if err := appRepo.CreateApplication(app); err != nil {
+ t.Fatalf("[%s] create application failed: %v", it.driver, err)
+ }
+
+ byUUID, err := appRepo.GetApplicationByIDOrHandle(app.UUID, org.ID)
+ if err != nil {
+ t.Fatalf("[%s] GetApplicationByIDOrHandle(uuid) failed: %v", it.driver, err)
+ }
+ if byUUID == nil || byUUID.UUID != app.UUID {
+ t.Fatalf("[%s] lookup by uuid: want %s, got %+v", it.driver, app.UUID, byUUID)
+ }
+
+ byHandle, err := appRepo.GetApplicationByIDOrHandle(app.Handle, org.ID)
+ if err != nil {
+ t.Fatalf("[%s] GetApplicationByIDOrHandle(handle) failed: %v", it.driver, err)
+ }
+ if byHandle == nil || byHandle.UUID != app.UUID {
+ t.Fatalf("[%s] lookup by handle: want %s, got %+v", it.driver, app.UUID, byHandle)
+ }
+
+ missing, err := appRepo.GetApplicationByIDOrHandle("does-not-exist-"+id(), org.ID)
+ if err != nil {
+ t.Fatalf("[%s] GetApplicationByIDOrHandle(missing) failed: %v", it.driver, err)
+ }
+ if missing != nil {
+ t.Fatalf("[%s] lookup of missing app: want nil, got %+v", it.driver, missing)
+ }
+}
+
+func newDevPortal(orgID, name string, isDefault bool) *model.DevPortal {
+ u := id()
+ return &model.DevPortal{
+ UUID: u, OrganizationUUID: orgID, Name: name,
+ Identifier: name + "-" + u[:8],
+ APIUrl: "http://" + name + "-" + u[:8],
+ Hostname: name + "-" + u[:8] + ".local",
+ APIKey: "k", HeaderKeyName: "x-wso2-api-key",
+ IsDefault: isDefault, Visibility: "private",
+ }
+}
diff --git a/platform-api/src/internal/repository/api.go b/platform-api/src/internal/repository/api.go
index 8d61d2c15..73e028637 100644
--- a/platform-api/src/internal/repository/api.go
+++ b/platform-api/src/internal/repository/api.go
@@ -552,36 +552,17 @@ func (r *APIRepo) CheckAPIExistsByNameAndVersionInOrganization(name, version, or
// CreateAPIAssociation creates an association between an API and resource (e.g., gateway or dev portal)
func (r *APIRepo) CreateAPIAssociation(association *model.APIAssociation) error {
- if r.db.Driver() == "postgres" || r.db.Driver() == "postgresql" {
- // PostgreSQL: use RETURNING to get the generated ID
- query := `
- INSERT INTO association_mappings (artifact_uuid, organization_uuid, resource_uuid, association_type, created_at, updated_at)
- VALUES (?, ?, ?, ?, ?, ?)
- RETURNING id
- `
- if err := r.db.QueryRow(r.db.Rebind(query), association.ArtifactID, association.OrganizationID, association.ResourceID,
- association.AssociationType, association.CreatedAt, association.UpdatedAt).Scan(&association.ID); err != nil {
- return err
- }
- } else {
- // SQLite: use LastInsertId
- query := `
- INSERT INTO association_mappings (artifact_uuid, organization_uuid, resource_uuid, association_type, created_at, updated_at)
- VALUES (?, ?, ?, ?, ?, ?)
- `
- result, err := r.db.Exec(r.db.Rebind(query), association.ArtifactID, association.OrganizationID, association.ResourceID,
- association.AssociationType, association.CreatedAt, association.UpdatedAt)
- if err != nil {
- return err
- }
-
- lastID, err := result.LastInsertId()
- if err != nil {
- return err
- }
- association.ID = lastID
+ query := `
+ INSERT INTO association_mappings (artifact_uuid, organization_uuid, resource_uuid, association_type, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?)
+ `
+ id, err := r.db.InsertAndReturnID(query,
+ association.ArtifactID, association.OrganizationID, association.ResourceID,
+ association.AssociationType, association.CreatedAt, association.UpdatedAt)
+ if err != nil {
+ return err
}
-
+ association.ID = id
return nil
}
diff --git a/platform-api/src/internal/repository/application.go b/platform-api/src/internal/repository/application.go
index 687f99dcd..b39b24c20 100644
--- a/platform-api/src/internal/repository/application.go
+++ b/platform-api/src/internal/repository/application.go
@@ -20,12 +20,8 @@ package repository
import (
"database/sql"
"errors"
- "strings"
"time"
- "github.com/jackc/pgx/v5/pgconn"
- sqlite3 "github.com/mattn/go-sqlite3"
-
"platform-api/src/internal/constants"
"platform-api/src/internal/database"
"platform-api/src/internal/model"
@@ -80,8 +76,8 @@ func (r *ApplicationRepo) GetApplicationByIDOrHandle(appIDOrHandle, orgID string
FROM applications
WHERE organization_uuid = ? AND (uuid = ? OR handle = ?)
ORDER BY CASE WHEN uuid = ? THEN 0 ELSE 1 END
- LIMIT 1
- `), orgID, appIDOrHandle, appIDOrHandle, appIDOrHandle)
+ `+r.db.FetchFirstClause(1)),
+ orgID, appIDOrHandle, appIDOrHandle, appIDOrHandle)
app, err := scanApplication(row)
if errors.Is(err, sql.ErrNoRows) {
@@ -124,8 +120,8 @@ func (r *ApplicationRepo) GetAssociationTargetByIDOrHandle(targetIDOrHandle, org
FROM artifacts
WHERE organization_uuid = ? AND (uuid = ? OR handle = ?)
ORDER BY CASE WHEN uuid = ? THEN 0 ELSE 1 END
- LIMIT 1
- `), orgID, targetIDOrHandle, targetIDOrHandle, targetIDOrHandle)
+ `+r.db.FetchFirstClause(1)),
+ orgID, targetIDOrHandle, targetIDOrHandle, targetIDOrHandle)
target := &model.Artifact{}
err := row.Scan(
@@ -154,8 +150,8 @@ func (r *ApplicationRepo) GetAssociationTargetByIDOrHandleAndKind(targetIDOrHand
FROM artifacts
WHERE organization_uuid = ? AND kind = ? AND (uuid = ? OR handle = ?)
ORDER BY CASE WHEN uuid = ? THEN 0 ELSE 1 END
- LIMIT 1
- `), orgID, kind, targetIDOrHandle, targetIDOrHandle, targetIDOrHandle)
+ `+r.db.FetchFirstClause(1)),
+ orgID, kind, targetIDOrHandle, targetIDOrHandle, targetIDOrHandle)
target := &model.Artifact{}
err := row.Scan(
@@ -484,7 +480,7 @@ func (r *ApplicationRepo) AddApplicationAssociations(applicationUUID string, tar
INSERT INTO application_artifacts (application_uuid, artifact_uuid, created_at, updated_at)
VALUES (?, ?, ?, ?)
`), applicationUUID, targetUUID, now, now); err != nil {
- if isDuplicateKeyError(err) {
+ if r.db.IsDuplicateKeyError(err) {
continue
}
return err
@@ -494,26 +490,6 @@ func (r *ApplicationRepo) AddApplicationAssociations(applicationUUID string, tar
return tx.Commit()
}
-func isDuplicateKeyError(err error) bool {
- if err == nil {
- return false
- }
-
- var pgErr *pgconn.PgError
- if errors.As(err, &pgErr) && pgErr.Code == "23505" {
- return true
- }
-
- var sqliteErr sqlite3.Error
- if errors.As(err, &sqliteErr) {
- return sqliteErr.ExtendedCode == sqlite3.ErrConstraintPrimaryKey ||
- sqliteErr.ExtendedCode == sqlite3.ErrConstraintUnique
- }
-
- lowerMsg := strings.ToLower(err.Error())
- return strings.Contains(lowerMsg, "duplicate key") ||
- strings.Contains(lowerMsg, "unique constraint failed")
-}
func (r *ApplicationRepo) RemoveApplicationAPIKey(applicationUUID, apiKeyID string) error {
_, err := r.db.Exec(r.db.Rebind(`
diff --git a/platform-api/src/internal/repository/custom_policy.go b/platform-api/src/internal/repository/custom_policy.go
index 9fe869864..d02707dec 100644
--- a/platform-api/src/internal/repository/custom_policy.go
+++ b/platform-api/src/internal/repository/custom_policy.go
@@ -40,15 +40,12 @@ func NewCustomPolicyRepo(db *database.DB) CustomPolicyRepository {
// InsertCustomPolicy inserts or updates a custom policy by (organization_uuid, name, version).
func (r *CustomPolicyRepo) InsertCustomPolicy(policy *model.CustomPolicy) error {
now := time.Now()
- query := `
- INSERT INTO gateway_custom_policies (uuid, organization_uuid, name, display_name, version, description, policy_definition, created_at, updated_at)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
- ON CONFLICT(organization_uuid, name, version) DO UPDATE SET
- display_name = excluded.display_name,
- description = excluded.description,
- policy_definition = excluded.policy_definition,
- updated_at = excluded.updated_at
- `
+ query := r.db.BuildUpsertQuery(
+ "gateway_custom_policies",
+ []string{"uuid", "organization_uuid", "name", "display_name", "version", "description", "policy_definition", "created_at", "updated_at"},
+ []string{"organization_uuid", "name", "version"},
+ []string{"display_name", "description", "policy_definition", "updated_at"},
+ )
_, err := r.db.Exec(r.db.Rebind(query),
policy.UUID, policy.OrganizationUUID, policy.Name, policy.DisplayName, policy.Version,
policy.Description, policy.PolicyDefinition, now, now,
diff --git a/platform-api/src/internal/repository/deployment.go b/platform-api/src/internal/repository/deployment.go
index 8bb23d9fb..ce76a8fb3 100644
--- a/platform-api/src/internal/repository/deployment.go
+++ b/platform-api/src/internal/repository/deployment.go
@@ -97,7 +97,7 @@ func (r *DeploymentRepo) CreateWithLimitEnforcement(deployment *model.Deployment
WHERE d.artifact_uuid = ? AND d.gateway_uuid = ? AND d.organization_uuid = ?
AND s.deployment_id IS NULL
ORDER BY d.created_at ASC
- LIMIT 5
+ ` + r.db.FetchFirstClause(5) + `
`
rows, err := tx.Query(r.db.Rebind(getOldestQuery), deployment.ArtifactID, deployment.GatewayID, deployment.OrganizationID)
@@ -158,22 +158,12 @@ func (r *DeploymentRepo) CreateWithLimitEnforcement(deployment *model.Deployment
}
// 4. Insert or update deployment status (UPSERT)
- var statusQuery string
- if r.db.Driver() == "postgres" || r.db.Driver() == "postgresql" {
- statusQuery = `
- INSERT INTO deployment_status (artifact_uuid, organization_uuid, gateway_uuid, deployment_id, status, status_desired, performed_at, status_reason, updated_at)
- VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?)
- ON CONFLICT (artifact_uuid, organization_uuid, gateway_uuid)
- DO UPDATE SET deployment_id = EXCLUDED.deployment_id, status = EXCLUDED.status,
- status_desired = EXCLUDED.status_desired, performed_at = EXCLUDED.performed_at,
- status_reason = NULL, updated_at = EXCLUDED.updated_at
- `
- } else {
- statusQuery = `
- REPLACE INTO deployment_status (artifact_uuid, organization_uuid, gateway_uuid, deployment_id, status, status_desired, performed_at, status_reason, updated_at)
- VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?)
- `
- }
+ statusQuery := r.db.BuildUpsertQuery(
+ "deployment_status",
+ []string{"artifact_uuid", "organization_uuid", "gateway_uuid", "deployment_id", "status", "status_desired", "performed_at", "status_reason", "updated_at"},
+ []string{"artifact_uuid", "organization_uuid", "gateway_uuid"},
+ []string{"deployment_id", "status", "status_desired", "performed_at", "status_reason=NULL", "updated_at"},
+ )
// Status and UpdatedAt are guaranteed to be non-nil by initialization at function start
_, err = tx.Exec(r.db.Rebind(statusQuery),
@@ -184,6 +174,7 @@ func (r *DeploymentRepo) CreateWithLimitEnforcement(deployment *model.Deployment
*deployment.Status,
string(*deployment.Status),
*deployment.UpdatedAt,
+ nil,
*deployment.UpdatedAt,
)
if err != nil {
@@ -273,7 +264,7 @@ func (r *DeploymentRepo) GetCurrentByGateway(artifactUUID, gatewayID, orgUUID st
WHERE d.artifact_uuid = ? AND d.gateway_uuid = ? AND d.organization_uuid = ?
AND s.status_desired = 'DEPLOYED'
ORDER BY d.created_at DESC
- LIMIT 1
+ ` + r.db.FetchFirstClause(1) + `
`
var baseDeploymentID sql.NullString
@@ -343,26 +334,15 @@ func (r *DeploymentRepo) SetCurrentWithDetails(artifactUUID, orgUUID, gatewayID,
reasonVal = statusReason
}
- if r.db.Driver() == "postgres" || r.db.Driver() == "postgresql" {
- query := `
- INSERT INTO deployment_status (artifact_uuid, organization_uuid, gateway_uuid, deployment_id, status, status_desired, performed_at, status_reason, updated_at)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
- ON CONFLICT (artifact_uuid, organization_uuid, gateway_uuid)
- DO UPDATE SET deployment_id = ?, status = ?, status_desired = ?, performed_at = ?, status_reason = ?, updated_at = ?
- `
- _, err := r.db.Exec(r.db.Rebind(query),
- artifactUUID, orgUUID, gatewayID, deploymentID, status, statusDesired, pat, reasonVal, updatedAt,
- deploymentID, status, statusDesired, pat, reasonVal, updatedAt)
- return updatedAt, err
- } else {
- query := `
- REPLACE INTO deployment_status (artifact_uuid, organization_uuid, gateway_uuid, deployment_id, status, status_desired, performed_at, status_reason, updated_at)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
- `
- _, err := r.db.Exec(r.db.Rebind(query),
- artifactUUID, orgUUID, gatewayID, deploymentID, status, statusDesired, pat, reasonVal, updatedAt)
- return updatedAt, err
- }
+ query := r.db.BuildUpsertQuery(
+ "deployment_status",
+ []string{"artifact_uuid", "organization_uuid", "gateway_uuid", "deployment_id", "status", "status_desired", "performed_at", "status_reason", "updated_at"},
+ []string{"artifact_uuid", "organization_uuid", "gateway_uuid"},
+ []string{"deployment_id", "status", "status_desired", "performed_at", "status_reason", "updated_at"},
+ )
+ _, err := r.db.Exec(r.db.Rebind(query),
+ artifactUUID, orgUUID, gatewayID, deploymentID, status, statusDesired, pat, reasonVal, updatedAt)
+ return updatedAt, err
}
// GetStatus retrieves the current deployment status for an artifact on a gateway (lightweight - no content)
diff --git a/platform-api/src/internal/repository/devportal.go b/platform-api/src/internal/repository/devportal.go
index 29f3d47af..d7e3d9f6f 100644
--- a/platform-api/src/internal/repository/devportal.go
+++ b/platform-api/src/internal/repository/devportal.go
@@ -130,8 +130,8 @@ func (r *devPortalRepository) checkForConflictsTx(tx *sql.Tx, devPortal *model.D
if devPortal.IsDefault {
err = tx.QueryRow(r.db.Rebind(`
SELECT COUNT(*) FROM devportals
- WHERE organization_uuid = ? AND is_default = TRUE`),
- devPortal.OrganizationUUID).Scan(&count)
+ WHERE organization_uuid = ? AND is_default = ?`),
+ devPortal.OrganizationUUID, true).Scan(&count)
if err != nil {
return fmt.Errorf("failed to check for existing default devportal: %w", err)
}
@@ -218,8 +218,9 @@ func (r *devPortalRepository) GetByOrganizationUUID(orgUUID string, isDefault, i
args = append(args, *isActive)
}
- query += " ORDER BY is_default DESC, created_at ASC LIMIT ? OFFSET ?"
- args = append(args, limit, offset)
+ pageClause, pageArgs := r.db.PaginationClause(limit, offset)
+ query += " ORDER BY is_default DESC, created_at ASC " + pageClause
+ args = append(args, pageArgs...)
rows, err := r.db.Query(r.db.Rebind(query), args...)
if err != nil {
@@ -331,9 +332,9 @@ func (r *devPortalRepository) GetDefaultByOrganizationUUID(orgUUID string) (*mod
var devPortal model.DevPortal
query := `SELECT uuid, organization_uuid, name, identifier, api_url,
hostname, is_active, is_enabled, api_key, header_key_name, is_default, visibility, description, created_at, updated_at
- FROM devportals WHERE organization_uuid = ? AND is_default = TRUE`
+ FROM devportals WHERE organization_uuid = ? AND is_default = ?`
- err := r.db.QueryRow(r.db.Rebind(query), orgUUID).Scan(
+ err := r.db.QueryRow(r.db.Rebind(query), orgUUID, true).Scan(
&devPortal.UUID, &devPortal.OrganizationUUID, &devPortal.Name, &devPortal.Identifier,
&devPortal.APIUrl, &devPortal.Hostname,
&devPortal.IsActive, &devPortal.IsEnabled, &devPortal.APIKey, &devPortal.HeaderKeyName,
@@ -411,15 +412,15 @@ func (r *devPortalRepository) SetAsDefault(uuid, orgUUID string) error {
}
defer tx.Rollback()
- _, err = tx.Exec(r.db.Rebind(`UPDATE devportals SET is_default = FALSE WHERE organization_uuid = ? AND is_default = TRUE`),
- devPortal.OrganizationUUID)
+ _, err = tx.Exec(r.db.Rebind(`UPDATE devportals SET is_default = ? WHERE organization_uuid = ? AND is_default = ?`),
+ false, devPortal.OrganizationUUID, true)
if err != nil {
return fmt.Errorf("failed to unset previous default devportal for organization %s: %w", devPortal.OrganizationUUID, err)
}
// Set the new default
- result, err := tx.Exec(r.db.Rebind(`UPDATE devportals SET is_default = TRUE, updated_at = ? WHERE uuid = ? AND organization_uuid = ?`),
- time.Now(), uuid, orgUUID)
+ result, err := tx.Exec(r.db.Rebind(`UPDATE devportals SET is_default = ?, updated_at = ? WHERE uuid = ? AND organization_uuid = ?`),
+ true, time.Now(), uuid, orgUUID)
if err != nil {
return fmt.Errorf("failed to set devportal %s as default for organization %s: %w", uuid, orgUUID, err)
}
diff --git a/platform-api/src/internal/repository/gateway.go b/platform-api/src/internal/repository/gateway.go
index 224407681..e4d7621b4 100644
--- a/platform-api/src/internal/repository/gateway.go
+++ b/platform-api/src/internal/repository/gateway.go
@@ -322,8 +322,8 @@ func (r *GatewayRepo) GetActiveTokenByHash(tokenHash string) (*model.GatewayToke
SELECT uuid, gateway_uuid, token_hash, salt, status, created_at, revoked_at
FROM gateway_tokens
WHERE token_hash = ? AND status = 'active'
- LIMIT 1
- `
+ ORDER BY (SELECT NULL)
+ ` + r.db.FetchFirstClause(1)
err := r.db.QueryRow(r.db.Rebind(query), tokenHash).Scan(
&token.ID, &token.GatewayID, &token.TokenHash, &token.Salt, &token.Status, &token.CreatedAt, &token.RevokedAt,
)
diff --git a/platform-api/src/internal/repository/llm.go b/platform-api/src/internal/repository/llm.go
index 9c2022527..7955244d9 100644
--- a/platform-api/src/internal/repository/llm.go
+++ b/platform-api/src/internal/repository/llm.go
@@ -163,13 +163,14 @@ func (r *LLMProviderTemplateRepo) GetByUUID(uuid, orgUUID string) (*model.LLMPro
}
func (r *LLMProviderTemplateRepo) List(orgUUID string, limit, offset int) ([]*model.LLMProviderTemplate, error) {
- rows, err := r.db.Query(r.db.Rebind(`
+ pageClause, pageArgs := r.db.PaginationClause(limit, offset)
+ query := `
SELECT uuid, organization_uuid, handle, name, description, created_by, configuration, created_at, updated_at
FROM llm_provider_templates
WHERE organization_uuid = ?
ORDER BY created_at DESC
- LIMIT ? OFFSET ?
- `), orgUUID, limit, offset)
+ ` + pageClause
+ rows, err := r.db.Query(r.db.Rebind(query), append([]any{orgUUID}, pageArgs...)...)
if err != nil {
return nil, err
}
@@ -392,6 +393,8 @@ func (r *LLMProviderRepo) GetByID(providerID, orgUUID string) (*model.LLMProvide
}
func (r *LLMProviderRepo) List(orgUUID string, limit, offset int) ([]*model.LLMProvider, error) {
+ pageClause, pageArgs := r.db.PaginationClause(limit, offset)
+ args := append([]any{orgUUID, constants.LLMProvider}, pageArgs...)
query := `
SELECT
a.uuid, a.handle, a.name, a.version, a.organization_uuid, a.created_at, a.updated_at,
@@ -400,8 +403,8 @@ func (r *LLMProviderRepo) List(orgUUID string, limit, offset int) ([]*model.LLMP
JOIN llm_providers p ON a.uuid = p.uuid
WHERE a.organization_uuid = ? AND a.kind = ?
ORDER BY a.created_at DESC
- LIMIT ? OFFSET ?`
- rows, err := r.db.Query(r.db.Rebind(query), orgUUID, constants.LLMProvider, limit, offset)
+ ` + pageClause
+ rows, err := r.db.Query(r.db.Rebind(query), args...)
if err != nil {
return nil, err
}
@@ -653,6 +656,8 @@ func (r *LLMProxyRepo) GetByID(proxyID, orgUUID string) (*model.LLMProxy, error)
}
func (r *LLMProxyRepo) List(orgUUID string, limit, offset int) ([]*model.LLMProxy, error) {
+ pageClause, pageArgs := r.db.PaginationClause(limit, offset)
+ args := append([]any{orgUUID, constants.LLMProxy}, pageArgs...)
query := `
SELECT
a.uuid, a.handle, a.name, a.version, a.organization_uuid, a.created_at, a.updated_at,
@@ -662,8 +667,8 @@ func (r *LLMProxyRepo) List(orgUUID string, limit, offset int) ([]*model.LLMProx
JOIN llm_proxies p ON a.uuid = p.uuid
WHERE a.organization_uuid = ? AND a.kind = ?
ORDER BY a.created_at DESC
- LIMIT ? OFFSET ?`
- rows, err := r.db.Query(r.db.Rebind(query), orgUUID, constants.LLMProxy, limit, offset)
+ ` + pageClause
+ rows, err := r.db.Query(r.db.Rebind(query), args...)
if err != nil {
return nil, err
}
@@ -697,6 +702,8 @@ func (r *LLMProxyRepo) List(orgUUID string, limit, offset int) ([]*model.LLMProx
}
func (r *LLMProxyRepo) ListByProject(orgUUID, projectUUID string, limit, offset int) ([]*model.LLMProxy, error) {
+ pageClause, pageArgs := r.db.PaginationClause(limit, offset)
+ args := append([]any{orgUUID, projectUUID, constants.LLMProxy}, pageArgs...)
query := `
SELECT
a.uuid, a.handle, a.name, a.version, a.organization_uuid, a.created_at, a.updated_at,
@@ -706,8 +713,8 @@ func (r *LLMProxyRepo) ListByProject(orgUUID, projectUUID string, limit, offset
JOIN llm_proxies p ON a.uuid = p.uuid
WHERE a.organization_uuid = ? AND p.project_uuid = ? AND a.kind = ?
ORDER BY a.created_at DESC
- LIMIT ? OFFSET ?`
- rows, err := r.db.Query(r.db.Rebind(query), orgUUID, projectUUID, constants.LLMProxy, limit, offset)
+ ` + pageClause
+ rows, err := r.db.Query(r.db.Rebind(query), args...)
if err != nil {
return nil, err
}
@@ -741,6 +748,8 @@ func (r *LLMProxyRepo) ListByProject(orgUUID, projectUUID string, limit, offset
}
func (r *LLMProxyRepo) ListByProvider(orgUUID, providerUUID string, limit, offset int) ([]*model.LLMProxy, error) {
+ pageClause, pageArgs := r.db.PaginationClause(limit, offset)
+ args := append([]any{orgUUID, providerUUID, constants.LLMProxy}, pageArgs...)
query := `
SELECT
a.uuid, a.handle, a.name, a.version, a.organization_uuid, a.created_at, a.updated_at,
@@ -750,8 +759,8 @@ func (r *LLMProxyRepo) ListByProvider(orgUUID, providerUUID string, limit, offse
JOIN llm_proxies p ON a.uuid = p.uuid
WHERE a.organization_uuid = ? AND p.provider_uuid = ? AND a.kind = ?
ORDER BY a.created_at DESC
- LIMIT ? OFFSET ?`
- rows, err := r.db.Query(r.db.Rebind(query), orgUUID, providerUUID, constants.LLMProxy, limit, offset)
+ ` + pageClause
+ rows, err := r.db.Query(r.db.Rebind(query), args...)
if err != nil {
return nil, err
}
diff --git a/platform-api/src/internal/repository/organization.go b/platform-api/src/internal/repository/organization.go
index 39adb8158..ef3e1c2e1 100644
--- a/platform-api/src/internal/repository/organization.go
+++ b/platform-api/src/internal/repository/organization.go
@@ -125,13 +125,13 @@ func (r *OrganizationRepo) DeleteOrganization(orgId string) error {
}
func (r *OrganizationRepo) ListOrganizations(limit, offset int) ([]*model.Organization, error) {
+ pageClause, pageArgs := r.db.PaginationClause(limit, offset)
query := `
SELECT uuid, handle, name, region, created_at, updated_at
FROM organizations
ORDER BY created_at DESC
- LIMIT ? OFFSET ?
- `
- rows, err := r.db.Query(r.db.Rebind(query), limit, offset)
+ ` + pageClause
+ rows, err := r.db.Query(r.db.Rebind(query), pageArgs...)
if err != nil {
return nil, err
}
diff --git a/platform-api/src/internal/repository/project.go b/platform-api/src/internal/repository/project.go
index 1208a47c7..2c9403647 100644
--- a/platform-api/src/internal/repository/project.go
+++ b/platform-api/src/internal/repository/project.go
@@ -143,14 +143,14 @@ func (r *ProjectRepo) DeleteProject(projectId string) error {
// ListProjects retrieves projects with pagination
func (r *ProjectRepo) ListProjects(orgID string, limit, offset int) ([]*model.Project, error) {
+ pageClause, pageArgs := r.db.PaginationClause(limit, offset)
query := `
SELECT uuid, name, organization_uuid, description, created_at, updated_at
FROM projects
WHERE organization_uuid = ?
ORDER BY created_at DESC
- LIMIT ? OFFSET ?
- `
- rows, err := r.db.Query(r.db.Rebind(query), orgID, limit, offset)
+ ` + pageClause
+ rows, err := r.db.Query(r.db.Rebind(query), append([]any{orgID}, pageArgs...)...)
if err != nil {
return nil, err
}
diff --git a/platform-api/src/internal/repository/subscription_plan_repository.go b/platform-api/src/internal/repository/subscription_plan_repository.go
index e92ecbdcc..9f462500e 100644
--- a/platform-api/src/internal/repository/subscription_plan_repository.go
+++ b/platform-api/src/internal/repository/subscription_plan_repository.go
@@ -143,15 +143,15 @@ func (r *SubscriptionPlanRepo) GetByIDs(planIDs []string, orgUUID string) (map[s
// ListByOrganization returns subscription plans for an organization with pagination
func (r *SubscriptionPlanRepo) ListByOrganization(orgUUID string, limit, offset int) ([]*model.SubscriptionPlan, error) {
+ pageClause, pageArgs := r.db.PaginationClause(limit, offset)
query := `
SELECT uuid, plan_name, billing_plan, stop_on_quota_reach, throttle_limit_count,
throttle_limit_unit, expiry_time, organization_uuid, status, created_at, updated_at
FROM subscription_plans
WHERE organization_uuid = ?
ORDER BY created_at DESC
- LIMIT ? OFFSET ?
- `
- rows, err := r.db.Query(r.db.Rebind(query), orgUUID, limit, offset)
+ ` + pageClause
+ rows, err := r.db.Query(r.db.Rebind(query), append([]any{orgUUID}, pageArgs...)...)
if err != nil {
return nil, fmt.Errorf("failed to list subscription plans: %w", err)
}
@@ -219,8 +219,8 @@ func (r *SubscriptionPlanRepo) ExistsByNameAndOrg(planName, orgUUID string) (boo
query := `
SELECT 1 FROM subscription_plans
WHERE plan_name = ? AND organization_uuid = ?
- LIMIT 1
- `
+ ORDER BY (SELECT NULL)
+ ` + r.db.FetchFirstClause(1)
var exists int
err := r.db.QueryRow(r.db.Rebind(query), planName, orgUUID).Scan(&exists)
if err == sql.ErrNoRows {
diff --git a/platform-api/src/internal/repository/subscription_repository.go b/platform-api/src/internal/repository/subscription_repository.go
index 0ed5dc9a8..d511484a4 100644
--- a/platform-api/src/internal/repository/subscription_repository.go
+++ b/platform-api/src/internal/repository/subscription_repository.go
@@ -202,8 +202,9 @@ func (r *SubscriptionRepo) ListByFilters(orgUUID string, apiUUID *string, subscr
query += ` AND status = ?`
args = append(args, *status)
}
- query += ` ORDER BY created_at DESC LIMIT ? OFFSET ?`
- args = append(args, limit, offset)
+ pageClause, pageArgs := r.db.PaginationClause(limit, offset)
+ query += ` ORDER BY created_at DESC ` + pageClause
+ args = append(args, pageArgs...)
rows, err := r.db.Query(r.db.Rebind(query), args...)
if err != nil {
@@ -314,8 +315,8 @@ func (r *SubscriptionRepo) ExistsByAPIAndSubscriber(apiUUID, subscriberID, orgUU
SELECT 1 FROM subscriptions
WHERE api_uuid = ? AND organization_uuid = ?
AND subscriber_id = ?
- LIMIT 1
- `
+ ORDER BY (SELECT NULL)
+ ` + r.db.FetchFirstClause(1)
var exists int
err := r.db.QueryRow(r.db.Rebind(query), apiUUID, orgUUID, subscriberID).Scan(&exists)
if err == sql.ErrNoRows {
diff --git a/platform-api/src/internal/repository/webbroker_api.go b/platform-api/src/internal/repository/webbroker_api.go
index 732c9db82..05ecb6aea 100644
--- a/platform-api/src/internal/repository/webbroker_api.go
+++ b/platform-api/src/internal/repository/webbroker_api.go
@@ -132,6 +132,7 @@ func (r *WebBrokerAPIRepo) GetByUUID(uuid, orgUUID string) (*model.WebBrokerAPI,
func (r *WebBrokerAPIRepo) List(orgUUID, projectUUID string, limit, offset int) ([]*model.WebBrokerAPI, error) {
var query string
var args []interface{}
+ pageClause, pageArgs := r.db.PaginationClause(limit, offset)
if projectUUID != "" {
query = `
@@ -142,8 +143,8 @@ func (r *WebBrokerAPIRepo) List(orgUUID, projectUUID string, limit, offset int)
JOIN webbroker_apis p ON a.uuid = p.uuid
WHERE a.organization_uuid = ? AND a.kind = ? AND p.project_uuid = ?
ORDER BY a.created_at DESC
- LIMIT ? OFFSET ?`
- args = []interface{}{orgUUID, constants.WebBrokerApi, projectUUID, limit, offset}
+ ` + pageClause
+ args = append([]interface{}{orgUUID, constants.WebBrokerApi, projectUUID}, pageArgs...)
} else {
query = `
SELECT
@@ -153,8 +154,8 @@ func (r *WebBrokerAPIRepo) List(orgUUID, projectUUID string, limit, offset int)
JOIN webbroker_apis p ON a.uuid = p.uuid
WHERE a.organization_uuid = ? AND a.kind = ?
ORDER BY a.created_at DESC
- LIMIT ? OFFSET ?`
- args = []interface{}{orgUUID, constants.WebBrokerApi, limit, offset}
+ ` + pageClause
+ args = append([]interface{}{orgUUID, constants.WebBrokerApi}, pageArgs...)
}
rows, err := r.db.Query(r.db.Rebind(query), args...)
diff --git a/platform-api/src/internal/repository/websub_api.go b/platform-api/src/internal/repository/websub_api.go
index 8858a2fcc..682363844 100644
--- a/platform-api/src/internal/repository/websub_api.go
+++ b/platform-api/src/internal/repository/websub_api.go
@@ -132,6 +132,7 @@ func (r *WebSubAPIRepo) GetByUUID(uuid, orgUUID string) (*model.WebSubAPI, error
func (r *WebSubAPIRepo) List(orgUUID, projectUUID string, limit, offset int) ([]*model.WebSubAPI, error) {
var query string
var args []interface{}
+ pageClause, pageArgs := r.db.PaginationClause(limit, offset)
if projectUUID != "" {
query = `
@@ -142,8 +143,8 @@ func (r *WebSubAPIRepo) List(orgUUID, projectUUID string, limit, offset int) ([]
JOIN websub_apis p ON a.uuid = p.uuid
WHERE a.organization_uuid = ? AND a.kind = ? AND p.project_uuid = ?
ORDER BY a.created_at DESC
- LIMIT ? OFFSET ?`
- args = []interface{}{orgUUID, constants.WebSubApi, projectUUID, limit, offset}
+ ` + pageClause
+ args = append([]interface{}{orgUUID, constants.WebSubApi, projectUUID}, pageArgs...)
} else {
query = `
SELECT
@@ -153,8 +154,8 @@ func (r *WebSubAPIRepo) List(orgUUID, projectUUID string, limit, offset int) ([]
JOIN websub_apis p ON a.uuid = p.uuid
WHERE a.organization_uuid = ? AND a.kind = ?
ORDER BY a.created_at DESC
- LIMIT ? OFFSET ?`
- args = []interface{}{orgUUID, constants.WebSubApi, limit, offset}
+ ` + pageClause
+ args = append([]interface{}{orgUUID, constants.WebSubApi}, pageArgs...)
}
rows, err := r.db.Query(r.db.Rebind(query), args...)
diff --git a/tests/integration-e2e/README.md b/tests/integration-e2e/README.md
new file mode 100644
index 000000000..8772ecf04
--- /dev/null
+++ b/tests/integration-e2e/README.md
@@ -0,0 +1,107 @@
+# Combined platform-api + gateway end-to-end integration tests
+
+This stack runs the **real platform-api (control plane)** and the **real gateway
+(gateway-controller + gateway-runtime data plane)** against the **same database
+engine**, so a single scenario exercises both products integrated end to end:
+an API created in platform-api is deployed to a gateway and served by the data
+plane.
+
+It complements the per-component cross-database suites:
+- `platform-api/it` — platform-api store on SQLite / PostgreSQL / SQL Server.
+- `gateway/it` — gateway store on SQLite / PostgreSQL / SQL Server.
+
+## Topology
+
+```
+ REST (9243) control-plane WS (9243)
+client ───────────────► platform-api ◄────────────────── gateway-controller ──xDS──► gateway-runtime ──► sample-backend
+ │ │ (8080 ingress)
+ platform_api DB gateway_test DB
+ └──────────── shared engine (postgres/sqlserver) ─────────────┘
+```
+
+platform-api and gateway-controller keep **separate databases** (their schemas
+share table names like `artifacts`/`gateways`/`subscriptions`). The PostgreSQL
+variant uses one server with two databases (`init-db.sql`).
+
+## Integration contract (verified from source)
+
+- gateway-controller dials platform-api at `…/api/internal/v1/ws/gateways/connect`
+ with an `api-key` header (the gateway **registration token**); platform-api
+ validates it via `GatewayService.VerifyToken` and replies `connection.ack`.
+- platform-api pushes `subscription.created` / deployment events down that socket;
+ the gateway-controller pulls subscription/plan data from
+ `…/api/internal/v1/subscription-plans` and posts its manifest to
+ `…/api/internal/v1/gateways/{id}/manifest`.
+
+## Cucumber suite (godog)
+
+The suite is written in Gherkin and run by [godog](https://github.com/cucumber/godog),
+consistent with `gateway/it`:
+
+| File | Purpose |
+|------|---------|
+| `features/api-deployment.feature` | The scenarios (Gherkin). |
+| `suite_test.go` | godog runner + `BeforeSuite`/`AfterSuite` (compose orchestration, bootstrap) + platform-api REST helpers. |
+| `steps_test.go` | step definitions + ingress polling. |
+| `docker-compose*.yaml` | the stack per database engine. |
+
+### How the harness works
+
+`BeforeSuite` brings the whole stack up once and solves the registration-token
+chicken-and-egg: it starts the control plane, authenticates (`admin`/`admin`),
+creates a project and one (or two) gateway(s) with their registration tokens,
+then starts the gateway controllers with those tokens. Each scenario then creates
+its own API and deploys it to a pre-registered gateway, so scenarios are
+independent. The `I deploy the API to the gateway` step bounces that gateway's
+controller, because the controller runs its full deployment sync only once on
+connect (`c.syncOnce` in `pkg/controlplane/client.go`); the restart re-runs that
+sync so the new deployment is picked up.
+
+## Running
+
+Build the component images once (tagged `it-e2e`), then run the suite:
+
+```bash
+cd platform-api && docker build -t platform-api:it-e2e --build-context common=../common .
+cd gateway && make build VERSION=it-e2e # gateway-controller / gateway-runtime :it-e2e
+
+cd tests/integration-e2e
+go test -run TestFeatures -v ./... # PostgreSQL (default)
+E2E_DB=sqlite go test -run TestFeatures -v ./... # SQLite
+MSSQL_IMAGE=mcr.microsoft.com/azure-sql-edge:latest E2E_DB=sqlserver \
+ go test -run TestFeatures -v ./... # SQL Server (azure-sql-edge on Apple Silicon)
+```
+
+Or via make (from `platform-api/`): `make e2e`, `make e2e-all-dbs`.
+
+- `E2E_DB` = `postgres` (default) | `sqlite` | `sqlserver`.
+- `E2E_KEEP=1` leaves the stack up after the run for inspection.
+- `E2E_TAGS=@smoke` runs a tag subset. The `@multigateway` scenario runs only on
+ the postgres stack (the only one wired with a second gateway) and is otherwise
+ skipped automatically.
+- `PA_HOST_PORT` / `GW_HTTP_PORT` / `GW2_HTTP_PORT` override the published host
+ ports to avoid clashing with other local stacks (defaults 9243 / 18080 / 18081).
+
+### Scenarios
+
+1. **An API deployed to a gateway is served by the data plane** — deploy, then a
+ request to the ingress returns 200 via Envoy; a path outside the API context
+ returns 404.
+2. **Undeploy / redeploy** — undeploying stops the data plane serving the API
+ (404); redeploying restores it (200).
+3. **Multi-gateway** (`@multigateway`, postgres) — the same API deployed to two
+ gateways is served by both (fan-out), and undeploying from one leaves the
+ other serving (per-gateway isolation).
+
+## Status — passing on all three databases
+
+The full live-traffic scenario passes on **SQLite, PostgreSQL and SQL Server**
+(verified locally; SQL Server via `azure-sql-edge` on Apple Silicon).
+
+Bugs this harness surfaced and that are fixed alongside it:
+- platform-api image build (`go.sum` missing `go-mssqldb` → `go mod tidy`).
+- platform-api SQL Server `LIMIT` and cascade-path/self-ref schema issues.
+- gateway eventhub `INSERT … ON CONFLICT` (invalid on SQL Server) in the
+ deployment event-publish path → made dialect-aware
+ (`common/eventhub/sqlbackend.go`).
diff --git a/tests/integration-e2e/docker-compose.sqlite.yaml b/tests/integration-e2e/docker-compose.sqlite.yaml
new file mode 100644
index 000000000..5a18e47f2
--- /dev/null
+++ b/tests/integration-e2e/docker-compose.sqlite.yaml
@@ -0,0 +1,76 @@
+# SQLite variant of the combined e2e stack. platform-api and gateway-controller
+# each use their own SQLite file (no shared DB server). Same scenario as the
+# PostgreSQL stack; see the godog suite and README.md.
+services:
+ platform-api:
+ image: ${PLATFORM_API_IMAGE:-platform-api:it-e2e}
+ command: ["./platform-api", "-config", "/etc/platform-api/config.toml"]
+ environment:
+ - DATABASE_DRIVER=sqlite3
+ - DATABASE_PATH=/app/data/platform.db
+ - DB_SCHEMA_PATH=./schema.sql
+ - DATABASE_EXECUTE_SCHEMA_DDL=true
+ - DATABASE_SUBSCRIPTION_TOKEN_ENCRYPTION_KEY=0123456789abcdef0123456789abcdef
+ - AUTH_FILE_BASED_ENABLED=true
+ - AUTH_FILE_BASED_ORGANIZATION_ID=99089a17-72e0-4dd8-a2f4-c8dfbb085295
+ - AUTH_FILE_BASED_ORGANIZATION_HANDLE=ap-org
+ - AUTH_FILE_BASED_ORGANIZATION_NAME=AP Organization
+ - AUTH_FILE_BASED_ORGANIZATION_REGION=us
+ - AUTH_JWT_SECRET_KEY=e2e-integration-secret-key-0123456789
+ volumes:
+ - ./platform-api-config.toml:/etc/platform-api/config.toml:ro
+ ports:
+ - "${PA_HOST_PORT:-9243}:9243"
+ networks: [e2e]
+
+ gateway-controller:
+ image: ${GATEWAY_CONTROLLER_IMAGE:-ghcr.io/wso2/api-platform/gateway-controller:it-e2e}
+ command: ["-config", "/etc/gateway-controller/config.toml"]
+ environment:
+ - APIP_GW_CONTROLLER_STORAGE_TYPE=sqlite
+ - APIP_GW_CONTROLLER_STORAGE_SQLITE_PATH=./data/gateway.db
+ - APIP_GW_CONTROLLER_LOGGING_LEVEL=debug
+ - APIP_GW_CONTROLLER_CONTROLPLANE_HOST=platform-api:9243
+ - APIP_GW_CONTROLLER_CONTROLPLANE_TOKEN=${GATEWAY_REGISTRATION_TOKEN:-}
+ - APIP_GW_CONTROLLER_CONTROLPLANE_INSECURE_SKIP_VERIFY=true
+ ports:
+ - "9090:9090"
+ - "9092:9092"
+ - "18000:18000"
+ volumes:
+ - ../../gateway/it/test-config.toml:/etc/gateway-controller/config.toml:ro
+ - ../../gateway/it/it-aesgcm-keys/default-aesgcm256-v1.bin:/app/data/aesgcm-keys/default-aesgcm256-v1.bin:ro
+ - ../../gateway/gateway-controller/certificates:/app/certificates
+ - ../../gateway/gateway-controller/listener-certs:/app/listener-certs:ro
+ depends_on:
+ platform-api:
+ condition: service_started
+ networks: [e2e]
+
+ gateway-runtime:
+ image: ${GATEWAY_RUNTIME_IMAGE:-ghcr.io/wso2/api-platform/gateway-runtime:it-e2e}
+ command: ["--pol.config", "/etc/policy-engine/config.toml"]
+ environment:
+ - GATEWAY_CONTROLLER_HOST=gateway-controller
+ - LOG_LEVEL=info
+ ports:
+ - "${GW_HTTP_PORT:-18080}:8080"
+ - "${GW_HTTPS_PORT:-18443}:8443"
+ - "9002:9002"
+ volumes:
+ - ../../gateway/it/test-config.toml:/etc/policy-engine/config.toml:ro
+ depends_on:
+ gateway-controller:
+ condition: service_started
+ networks: [e2e]
+
+ sample-backend:
+ image: ghcr.io/wso2/api-platform/sample-service:latest
+ command: ["-addr", ":9080", "-pretty"]
+ ports:
+ - "9080:9080"
+ networks: [e2e]
+
+networks:
+ e2e:
+ driver: bridge
diff --git a/tests/integration-e2e/docker-compose.sqlserver.yaml b/tests/integration-e2e/docker-compose.sqlserver.yaml
new file mode 100644
index 000000000..4ae3c46d8
--- /dev/null
+++ b/tests/integration-e2e/docker-compose.sqlserver.yaml
@@ -0,0 +1,120 @@
+# SQL Server variant of the combined e2e stack. One SQL Server instance holds
+# two databases (platform_api, gateway_test). Same scenario as the other
+# backends; see the godog suite and README.md.
+#
+# CI (amd64) uses the default mcr.microsoft.com/mssql/server image. On Apple
+# Silicon that image fails under emulation, so override with Azure SQL Edge:
+# MSSQL_IMAGE=mcr.microsoft.com/azure-sql-edge:latest E2E_DB=sqlserver go test -run TestFeatures ./...
+services:
+ sqlserver:
+ image: ${MSSQL_IMAGE:-mcr.microsoft.com/mssql/server:2022-latest}
+ environment:
+ - ACCEPT_EULA=Y
+ - MSSQL_SA_PASSWORD=${MSSQL_PASSWORD:-Strong!Passw0rd}
+ - MSSQL_PID=Developer
+ ports:
+ - "14333:1433"
+ networks: [e2e]
+
+ # Creates both databases via sqlcmd (the server images do not auto-create them,
+ # and platform-api / gateway-controller only create their schema, not the DB).
+ mssql-init:
+ image: mcr.microsoft.com/mssql-tools:latest
+ depends_on:
+ sqlserver:
+ condition: service_started
+ entrypoint: ["/bin/bash", "-c"]
+ command:
+ - >
+ for i in $$(seq 1 90); do
+ /opt/mssql-tools/bin/sqlcmd -S sqlserver -U sa -P "$${MSSQL_SA_PASSWORD}" -Q "SELECT 1" && break;
+ sleep 2;
+ done;
+ /opt/mssql-tools/bin/sqlcmd -S sqlserver -U sa -P "$${MSSQL_SA_PASSWORD}" -Q
+ "IF DB_ID('platform_api') IS NULL CREATE DATABASE [platform_api]; IF DB_ID('gateway_test') IS NULL CREATE DATABASE [gateway_test];"
+ environment:
+ - MSSQL_SA_PASSWORD=${MSSQL_PASSWORD:-Strong!Passw0rd}
+ networks: [e2e]
+
+ platform-api:
+ image: ${PLATFORM_API_IMAGE:-platform-api:it-e2e}
+ command: ["./platform-api", "-config", "/etc/platform-api/config.toml"]
+ environment:
+ - DATABASE_DRIVER=sqlserver
+ - DATABASE_HOST=sqlserver
+ - DATABASE_PORT=1433
+ - DATABASE_NAME=platform_api
+ - DATABASE_USER=sa
+ - DATABASE_PASSWORD=${MSSQL_PASSWORD:-Strong!Passw0rd}
+ - DATABASE_SSL_MODE=disable
+ - DB_SCHEMA_PATH=./schema.sqlserver.sql
+ - DATABASE_EXECUTE_SCHEMA_DDL=true
+ - DATABASE_SUBSCRIPTION_TOKEN_ENCRYPTION_KEY=0123456789abcdef0123456789abcdef
+ - AUTH_FILE_BASED_ENABLED=true
+ - AUTH_FILE_BASED_ORGANIZATION_ID=99089a17-72e0-4dd8-a2f4-c8dfbb085295
+ - AUTH_FILE_BASED_ORGANIZATION_HANDLE=ap-org
+ - AUTH_FILE_BASED_ORGANIZATION_NAME=AP Organization
+ - AUTH_FILE_BASED_ORGANIZATION_REGION=us
+ - AUTH_JWT_SECRET_KEY=e2e-integration-secret-key-0123456789
+ volumes:
+ - ./platform-api-config.toml:/etc/platform-api/config.toml:ro
+ ports:
+ - "${PA_HOST_PORT:-9243}:9243"
+ depends_on:
+ mssql-init:
+ condition: service_completed_successfully
+ networks: [e2e]
+
+ gateway-controller:
+ image: ${GATEWAY_CONTROLLER_IMAGE:-ghcr.io/wso2/api-platform/gateway-controller:it-e2e}
+ command: ["-config", "/etc/gateway-controller/config.toml"]
+ environment:
+ - APIP_GW_CONTROLLER_STORAGE_TYPE=sqlserver
+ - APIP_GW_CONTROLLER_STORAGE_DATABASE_DSN=sqlserver://sa:${MSSQL_PASSWORD:-Strong!Passw0rd}@sqlserver:1433?database=gateway_test&encrypt=disable&TrustServerCertificate=true&app+name=gateway-controller
+ - APIP_GW_CONTROLLER_LOGGING_LEVEL=debug
+ - APIP_GW_CONTROLLER_CONTROLPLANE_HOST=platform-api:9243
+ - APIP_GW_CONTROLLER_CONTROLPLANE_TOKEN=${GATEWAY_REGISTRATION_TOKEN:-}
+ - APIP_GW_CONTROLLER_CONTROLPLANE_INSECURE_SKIP_VERIFY=true
+ ports:
+ - "9090:9090"
+ - "9092:9092"
+ - "18000:18000"
+ volumes:
+ - ../../gateway/it/test-config.toml:/etc/gateway-controller/config.toml:ro
+ - ../../gateway/it/it-aesgcm-keys/default-aesgcm256-v1.bin:/app/data/aesgcm-keys/default-aesgcm256-v1.bin:ro
+ - ../../gateway/gateway-controller/certificates:/app/certificates
+ - ../../gateway/gateway-controller/listener-certs:/app/listener-certs:ro
+ depends_on:
+ mssql-init:
+ condition: service_completed_successfully
+ platform-api:
+ condition: service_started
+ networks: [e2e]
+
+ gateway-runtime:
+ image: ${GATEWAY_RUNTIME_IMAGE:-ghcr.io/wso2/api-platform/gateway-runtime:it-e2e}
+ command: ["--pol.config", "/etc/policy-engine/config.toml"]
+ environment:
+ - GATEWAY_CONTROLLER_HOST=gateway-controller
+ - LOG_LEVEL=info
+ ports:
+ - "${GW_HTTP_PORT:-18080}:8080"
+ - "${GW_HTTPS_PORT:-18443}:8443"
+ - "9002:9002"
+ volumes:
+ - ../../gateway/it/test-config.toml:/etc/policy-engine/config.toml:ro
+ depends_on:
+ gateway-controller:
+ condition: service_started
+ networks: [e2e]
+
+ sample-backend:
+ image: ghcr.io/wso2/api-platform/sample-service:latest
+ command: ["-addr", ":9080", "-pretty"]
+ ports:
+ - "9080:9080"
+ networks: [e2e]
+
+networks:
+ e2e:
+ driver: bridge
diff --git a/tests/integration-e2e/docker-compose.yaml b/tests/integration-e2e/docker-compose.yaml
new file mode 100644
index 000000000..633f12a4f
--- /dev/null
+++ b/tests/integration-e2e/docker-compose.yaml
@@ -0,0 +1,175 @@
+# Combined platform-api + gateway end-to-end stack for cross-database
+# integration testing. The real platform-api (control plane) and the real
+# gateway (gateway-controller + gateway-runtime data plane) run against the same
+# database engine, so a single scenario exercises both products together.
+#
+# This is the PostgreSQL variant (one server, two logical databases). SQLite and
+# SQL Server variants follow the same shape; see README.md.
+#
+# Images (override via env to use locally built tags):
+# PLATFORM_API_IMAGE (build: cd platform-api && make build, or the it-e2e tag)
+# GATEWAY_CONTROLLER_IMAGE GATEWAY_RUNTIME_IMAGE
+#
+# The gateway-controller's control-plane token is injected at run time after the
+# scenario creates a gateway + registration token in platform-api (see README,
+# "Bootstrap"). Until then the gateway-controller stays in its reconnect loop.
+services:
+ postgres:
+ image: postgres:16-alpine
+ environment:
+ - POSTGRES_USER=apip
+ - POSTGRES_PASSWORD=apip
+ - POSTGRES_DB=postgres
+ volumes:
+ - ./init-db.sql:/docker-entrypoint-initdb.d/init-db.sql:ro
+ ports:
+ - "55433:5432"
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U apip -d gateway_test"]
+ interval: 2s
+ timeout: 3s
+ retries: 40
+ networks: [e2e]
+
+ platform-api:
+ image: ${PLATFORM_API_IMAGE:-platform-api:it-e2e}
+ command: ["./platform-api", "-config", "/etc/platform-api/config.toml"]
+ environment:
+ - DATABASE_DRIVER=postgres
+ - DATABASE_HOST=postgres
+ - DATABASE_PORT=5432
+ - DATABASE_NAME=platform_api
+ - DATABASE_USER=apip
+ - DATABASE_PASSWORD=apip
+ - DATABASE_SSL_MODE=disable
+ - DB_SCHEMA_PATH=./schema.postgres.sql
+ - DATABASE_EXECUTE_SCHEMA_DDL=true
+ - DATABASE_SUBSCRIPTION_TOKEN_ENCRYPTION_KEY=0123456789abcdef0123456789abcdef
+ # Force file-based auth + a stable org so the scenario can log in and the
+ # org is seeded (env overrides the mounted config file).
+ - AUTH_FILE_BASED_ENABLED=true
+ - AUTH_FILE_BASED_ORGANIZATION_ID=99089a17-72e0-4dd8-a2f4-c8dfbb085295
+ - AUTH_FILE_BASED_ORGANIZATION_HANDLE=ap-org
+ - AUTH_FILE_BASED_ORGANIZATION_NAME=AP Organization
+ - AUTH_FILE_BASED_ORGANIZATION_REGION=us
+ - AUTH_JWT_SECRET_KEY=e2e-integration-secret-key-0123456789
+ volumes:
+ - ./platform-api-config.toml:/etc/platform-api/config.toml:ro
+ ports:
+ - "${PA_HOST_PORT:-9243}:9243"
+ depends_on:
+ postgres:
+ condition: service_healthy
+ networks: [e2e]
+
+ gateway-controller:
+ image: ${GATEWAY_CONTROLLER_IMAGE:-ghcr.io/wso2/api-platform/gateway-controller:it-e2e}
+ command: ["-config", "/etc/gateway-controller/config.toml"]
+ environment:
+ - APIP_GW_CONTROLLER_STORAGE_TYPE=postgres
+ - APIP_GW_CONTROLLER_STORAGE_POSTGRES_HOST=postgres
+ - APIP_GW_CONTROLLER_STORAGE_POSTGRES_PORT=5432
+ - APIP_GW_CONTROLLER_STORAGE_POSTGRES_DATABASE=gateway_test
+ - APIP_GW_CONTROLLER_STORAGE_POSTGRES_USER=apip
+ - APIP_GW_CONTROLLER_STORAGE_POSTGRES_PASSWORD=apip
+ - APIP_GW_CONTROLLER_STORAGE_POSTGRES_SSLMODE=disable
+ - APIP_GW_CONTROLLER_LOGGING_LEVEL=debug
+ # Control plane = the real platform-api. The token is created by the
+ # scenario and injected via GATEWAY_REGISTRATION_TOKEN before this starts.
+ - APIP_GW_CONTROLLER_CONTROLPLANE_HOST=platform-api:9243
+ - APIP_GW_CONTROLLER_CONTROLPLANE_TOKEN=${GATEWAY_REGISTRATION_TOKEN:-}
+ - APIP_GW_CONTROLLER_CONTROLPLANE_INSECURE_SKIP_VERIFY=true
+ ports:
+ - "9090:9090" # REST API
+ - "9092:9092" # Admin API
+ - "18000:18000" # xDS gRPC
+ volumes:
+ - ../../gateway/it/test-config.toml:/etc/gateway-controller/config.toml:ro
+ - ../../gateway/it/it-aesgcm-keys/default-aesgcm256-v1.bin:/app/data/aesgcm-keys/default-aesgcm256-v1.bin:ro
+ - ../../gateway/gateway-controller/certificates:/app/certificates
+ - ../../gateway/gateway-controller/listener-certs:/app/listener-certs:ro
+ depends_on:
+ postgres:
+ condition: service_healthy
+ platform-api:
+ condition: service_started
+ networks: [e2e]
+
+ gateway-runtime:
+ image: ${GATEWAY_RUNTIME_IMAGE:-ghcr.io/wso2/api-platform/gateway-runtime:it-e2e}
+ command: ["--pol.config", "/etc/policy-engine/config.toml"]
+ environment:
+ - GATEWAY_CONTROLLER_HOST=gateway-controller
+ - LOG_LEVEL=info
+ ports:
+ - "${GW_HTTP_PORT:-18080}:8080" # HTTP ingress (data plane)
+ - "${GW_HTTPS_PORT:-18443}:8443" # HTTPS ingress
+ - "9002:9002" # Policy engine admin
+ volumes:
+ - ../../gateway/it/test-config.toml:/etc/policy-engine/config.toml:ro
+ depends_on:
+ gateway-controller:
+ condition: service_started
+ networks: [e2e]
+
+ sample-backend:
+ image: ghcr.io/wso2/api-platform/sample-service:latest
+ command: ["-addr", ":9080", "-pretty"]
+ ports:
+ - "9080:9080"
+ networks: [e2e]
+
+ # Second gateway (own controller + runtime + DB) for the multi-gateway
+ # scenario: the same API is deployed to both gateways, and undeploying from one
+ # must not affect the other. Started on demand by the godog suite.
+ gateway-controller-2:
+ image: ${GATEWAY_CONTROLLER_IMAGE:-ghcr.io/wso2/api-platform/gateway-controller:it-e2e}
+ command: ["-config", "/etc/gateway-controller/config.toml"]
+ environment:
+ - APIP_GW_CONTROLLER_STORAGE_TYPE=postgres
+ - APIP_GW_CONTROLLER_STORAGE_POSTGRES_HOST=postgres
+ - APIP_GW_CONTROLLER_STORAGE_POSTGRES_PORT=5432
+ - APIP_GW_CONTROLLER_STORAGE_POSTGRES_DATABASE=gateway_test2
+ - APIP_GW_CONTROLLER_STORAGE_POSTGRES_USER=apip
+ - APIP_GW_CONTROLLER_STORAGE_POSTGRES_PASSWORD=apip
+ - APIP_GW_CONTROLLER_STORAGE_POSTGRES_SSLMODE=disable
+ - APIP_GW_CONTROLLER_LOGGING_LEVEL=debug
+ - APIP_GW_CONTROLLER_CONTROLPLANE_HOST=platform-api:9243
+ - APIP_GW_CONTROLLER_CONTROLPLANE_TOKEN=${GATEWAY_REGISTRATION_TOKEN_2:-}
+ - APIP_GW_CONTROLLER_CONTROLPLANE_INSECURE_SKIP_VERIFY=true
+ ports:
+ - "9190:9090"
+ - "9192:9092"
+ - "18100:18000"
+ volumes:
+ - ../../gateway/it/test-config.toml:/etc/gateway-controller/config.toml:ro
+ - ../../gateway/it/it-aesgcm-keys/default-aesgcm256-v1.bin:/app/data/aesgcm-keys/default-aesgcm256-v1.bin:ro
+ - ../../gateway/gateway-controller/certificates:/app/certificates
+ - ../../gateway/gateway-controller/listener-certs:/app/listener-certs:ro
+ depends_on:
+ postgres:
+ condition: service_healthy
+ platform-api:
+ condition: service_started
+ networks: [e2e]
+
+ gateway-runtime-2:
+ image: ${GATEWAY_RUNTIME_IMAGE:-ghcr.io/wso2/api-platform/gateway-runtime:it-e2e}
+ command: ["--pol.config", "/etc/policy-engine/config.toml"]
+ environment:
+ - GATEWAY_CONTROLLER_HOST=gateway-controller-2
+ - LOG_LEVEL=info
+ ports:
+ - "${GW2_HTTP_PORT:-18081}:8080"
+ - "${GW2_HTTPS_PORT:-18444}:8443"
+ - "9102:9002"
+ volumes:
+ - ../../gateway/it/test-config.toml:/etc/policy-engine/config.toml:ro
+ depends_on:
+ gateway-controller-2:
+ condition: service_started
+ networks: [e2e]
+
+networks:
+ e2e:
+ driver: bridge
diff --git a/tests/integration-e2e/features/api-deployment.feature b/tests/integration-e2e/features/api-deployment.feature
new file mode 100644
index 000000000..57a07765d
--- /dev/null
+++ b/tests/integration-e2e/features/api-deployment.feature
@@ -0,0 +1,33 @@
+Feature: Deploying APIs from platform-api to the gateway
+ As an API platform operator
+ I want an API created in platform-api to be served by the gateway data plane
+ So that the control plane and data plane work together on every supported database.
+
+ Background:
+ Given the platform-api control plane and gateway data plane are running
+ And I am authenticated to platform-api
+
+ @smoke
+ Scenario: An API deployed to a gateway is served by the data plane
+ Given a REST API routed to the sample backend
+ When I deploy the API to the gateway
+ Then the gateway serves the API
+ And a request to a path outside the API context returns 404
+
+ Scenario: Undeploying stops the gateway serving the API, and redeploying restores it
+ Given a REST API routed to the sample backend
+ And the API is deployed to the gateway and served
+ When I undeploy the API from the gateway
+ Then the gateway stops serving the API
+ When I deploy the API to the gateway
+ Then the gateway serves the API
+
+ @multigateway
+ Scenario: An API deployed to two gateways is served by both, and undeploy is isolated
+ Given a REST API routed to the sample backend
+ And the API is deployed to the gateway and served
+ When I deploy the API to the second gateway
+ Then the second gateway serves the API
+ When I undeploy the API from the second gateway
+ Then the second gateway stops serving the API
+ And the gateway still serves the API
diff --git a/tests/integration-e2e/go.mod b/tests/integration-e2e/go.mod
new file mode 100644
index 000000000..1ea7b8684
--- /dev/null
+++ b/tests/integration-e2e/go.mod
@@ -0,0 +1,15 @@
+module github.com/wso2/api-platform/tests/integration-e2e
+
+go 1.26.2
+
+require github.com/cucumber/godog v0.15.0
+
+require (
+ github.com/cucumber/gherkin/go/v26 v26.2.0 // indirect
+ github.com/cucumber/messages/go/v21 v21.0.1 // indirect
+ github.com/gofrs/uuid v4.3.1+incompatible // indirect
+ github.com/hashicorp/go-immutable-radix v1.3.1 // indirect
+ github.com/hashicorp/go-memdb v1.3.4 // indirect
+ github.com/hashicorp/golang-lru v0.5.4 // indirect
+ github.com/spf13/pflag v1.0.5 // indirect
+)
diff --git a/tests/integration-e2e/go.sum b/tests/integration-e2e/go.sum
new file mode 100644
index 000000000..9514a4664
--- /dev/null
+++ b/tests/integration-e2e/go.sum
@@ -0,0 +1,48 @@
+github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
+github.com/cucumber/gherkin/go/v26 v26.2.0 h1:EgIjePLWiPeslwIWmNQ3XHcypPsWAHoMCz/YEBKP4GI=
+github.com/cucumber/gherkin/go/v26 v26.2.0/go.mod h1:t2GAPnB8maCT4lkHL99BDCVNzCh1d7dBhCLt150Nr/0=
+github.com/cucumber/godog v0.15.0 h1:51AL8lBXF3f0cyA5CV4TnJFCTHpgiy+1x1Hb3TtZUmo=
+github.com/cucumber/godog v0.15.0/go.mod h1:FX3rzIDybWABU4kuIXLZ/qtqEe1Ac5RdXmqvACJOces=
+github.com/cucumber/messages/go/v21 v21.0.1 h1:wzA0LxwjlWQYZd32VTlAVDTkW6inOFmSM+RuOwHZiMI=
+github.com/cucumber/messages/go/v21 v21.0.1/go.mod h1:zheH/2HS9JLVFukdrsPWoPdmUtmYQAQPLk7w5vWsk5s=
+github.com/cucumber/messages/go/v22 v22.0.0/go.mod h1:aZipXTKc0JnjCsXrJnuZpWhtay93k7Rn3Dee7iyPJjs=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/gofrs/uuid v4.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
+github.com/gofrs/uuid v4.3.1+incompatible h1:0/KbAdpx3UXAx1kEOWHJeOkpbgRFGHVgv+CFIY7dBJI=
+github.com/gofrs/uuid v4.3.1+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
+github.com/hashicorp/go-immutable-radix v1.3.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
+github.com/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc=
+github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
+github.com/hashicorp/go-memdb v1.3.4 h1:XSL3NR682X/cVk2IeV0d70N4DZ9ljI885xAEU8IoK3c=
+github.com/hashicorp/go-memdb v1.3.4/go.mod h1:uBTr1oQbtuMgd1SSGoR8YV27eT3sBHbYiNm53bMpgSg=
+github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
+github.com/hashicorp/go-uuid v1.0.2 h1:cfejS+Tpcp13yd5nYHWDI6qVCny6wyX2Mt5SGur2IGE=
+github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
+github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
+github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc=
+github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
+github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
+github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
+github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
+github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
+github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
+github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
+github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
+github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
+github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/tests/integration-e2e/init-db.sql b/tests/integration-e2e/init-db.sql
new file mode 100644
index 000000000..a60acf82f
--- /dev/null
+++ b/tests/integration-e2e/init-db.sql
@@ -0,0 +1,7 @@
+-- platform-api and gateway-controller keep separate schemas with overlapping
+-- table names (artifacts, gateways, subscriptions, ...), so they must live in
+-- different databases on the shared server.
+CREATE DATABASE platform_api;
+CREATE DATABASE gateway_test;
+-- Second gateway-controller store for the multi-gateway scenario.
+CREATE DATABASE gateway_test2;
diff --git a/tests/integration-e2e/platform-api-config.toml b/tests/integration-e2e/platform-api-config.toml
new file mode 100644
index 000000000..71fe719b3
--- /dev/null
+++ b/tests/integration-e2e/platform-api-config.toml
@@ -0,0 +1,22 @@
+# platform-api configuration for the combined e2e stack.
+# Database settings come from environment variables (see docker-compose.yaml);
+# this file supplies file-based auth so the scenario can log in (admin/admin).
+
+[auth.jwt]
+enabled = true
+issuer = "platform-api"
+secret_key = "e2e-integration-secret-key-0123456789"
+
+[auth.file_based]
+enabled = true
+
+[auth.file_based.organization]
+id = "99089a17-72e0-4dd8-a2f4-c8dfbb085295"
+name = "AP Organization"
+handle = "ap-org"
+
+# Default login: admin / admin (bcrypt hash of "admin", reused from the shipped sample config).
+[[auth.file_based.users]]
+username = "admin"
+password_hash = "$2y$10$U2yKMwGamGwDoMu0hRPT7u8nCuP8z/qxHFOKV6dhIxkJN9NJ0eVQ."
+scopes = "ap:organization:manage ap:gateway:manage ap:gateway_custom_policy:manage ap:rest_api:manage ap:llm_provider:manage ap:llm_proxy:manage ap:mcp_proxy:manage ap:webbroker_api:manage ap:websub_api:manage ap:application:manage ap:subscription:manage ap:subscription_plan:manage ap:project:manage ap:llm_template:manage ap:devportal:manage ap:git:read ap:api_key:read"
diff --git a/tests/integration-e2e/steps_test.go b/tests/integration-e2e/steps_test.go
new file mode 100644
index 000000000..e9992a7fa
--- /dev/null
+++ b/tests/integration-e2e/steps_test.go
@@ -0,0 +1,230 @@
+/*
+ * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com).
+ *
+ * WSO2 LLC. licenses this file to you under the Apache License,
+ * Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package e2e
+
+import (
+ "crypto/rand"
+ "encoding/hex"
+ "fmt"
+ "net/http"
+ "time"
+
+ "github.com/cucumber/godog"
+)
+
+// world holds the per-scenario state: the API created for the scenario and the
+// deployment ids returned when it is deployed to each gateway.
+type world struct {
+ apiID string
+ apiContext string // e.g. /e2e-ab12cd34
+ depGw1 string
+ depGw2 string
+}
+
+// initializeScenario is invoked by godog for each scenario; it binds a fresh
+// world so scenarios do not share API/deployment state.
+func initializeScenario(sc *godog.ScenarioContext) {
+ w := &world{}
+
+ sc.Step(`^the platform-api control plane and gateway data plane are running$`, w.stackRunning)
+ sc.Step(`^I am authenticated to platform-api$`, w.authenticated)
+ sc.Step(`^a REST API routed to the sample backend$`, w.aRestAPI)
+
+ sc.Step(`^I deploy the API to the gateway$`, w.deployToGateway)
+ sc.Step(`^the API is deployed to the gateway and served$`, w.deployedAndServed)
+ sc.Step(`^I undeploy the API from the gateway$`, w.undeployFromGateway)
+ sc.Step(`^the gateway serves the API$`, w.gatewayServes)
+ sc.Step(`^the gateway stops serving the API$`, w.gatewayStopsServing)
+ sc.Step(`^the gateway still serves the API$`, w.gatewayStillServes)
+ sc.Step(`^a request to a path outside the API context returns 404$`, w.unmappedPathReturns404)
+
+ sc.Step(`^I deploy the API to the second gateway$`, w.deployToSecondGateway)
+ sc.Step(`^the second gateway serves the API$`, w.secondGatewayServes)
+ sc.Step(`^I undeploy the API from the second gateway$`, w.undeployFromSecondGateway)
+ sc.Step(`^the second gateway stops serving the API$`, w.secondGatewayStopsServing)
+}
+
+// --- Background steps ------------------------------------------------------
+
+func (w *world) stackRunning() error {
+ if suite.gw1ID == "" {
+ return fmt.Errorf("gateway was not registered during suite setup")
+ }
+ return nil
+}
+
+func (w *world) authenticated() error {
+ if suite.token == "" {
+ return fmt.Errorf("not authenticated to platform-api")
+ }
+ return nil
+}
+
+// --- Given -----------------------------------------------------------------
+
+func (w *world) aRestAPI() error {
+ // Name/displayName must be URL-friendly (no slash); the context is the path.
+ suffix := randHex()
+ w.apiContext = "/e2e-" + suffix
+ st, body, err := apiCall(http.MethodPost, "/api/v1/rest-apis", suite.token, map[string]any{
+ "name": "e2e-api-" + suffix,
+ "context": w.apiContext,
+ "version": "v1",
+ "projectId": suite.projectID,
+ "upstream": map[string]any{"main": map[string]any{"url": "http://sample-backend:9080"}},
+ })
+ if err != nil {
+ return err
+ }
+ w.apiID = jsonField(body, "id", "handle", "uuid")
+ if st >= 300 || w.apiID == "" {
+ return fmt.Errorf("create API failed (%d): %s", st, body)
+ }
+ return nil
+}
+
+func (w *world) deployedAndServed() error {
+ if err := w.deployToGateway(); err != nil {
+ return err
+ }
+ return w.gatewayServes()
+}
+
+// --- deploy / undeploy -----------------------------------------------------
+
+// deploy attaches the gateway to the API, creates a deployment and bounces the
+// gateway's controller so it picks the deployment up.
+//
+// The controller runs a full deployment sync only once, on first connect
+// (c.syncOnce in pkg/controlplane/client.go); deployments created while it is
+// already connected are not full-synced. Restarting the controller process
+// re-runs that one-time sync, which is the data-plane equivalent of "the
+// controller noticed the new deployment". Returns the deployment id.
+func deploy(apiID, gatewayID, controllerService string) (string, error) {
+ if st, body, err := apiCall(http.MethodPost, "/api/v1/rest-apis/"+apiID+"/gateways", suite.token,
+ []map[string]string{{"gatewayId": gatewayID}}); err != nil {
+ return "", err
+ } else if st >= 300 {
+ return "", fmt.Errorf("attach gateway failed (%d): %s", st, body)
+ }
+ st, body, err := apiCall(http.MethodPost, "/api/v1/rest-apis/"+apiID+"/deployments", suite.token,
+ map[string]any{"base": "current", "gatewayId": gatewayID, "name": "dep-" + randHex()})
+ if err != nil {
+ return "", err
+ }
+ id := jsonField(body, "deploymentId")
+ if st >= 300 || id == "" {
+ return "", fmt.Errorf("deploy failed (%d): %s", st, body)
+ }
+ if err := compose(nil, "restart", controllerService); err != nil {
+ return "", fmt.Errorf("restart %s: %w", controllerService, err)
+ }
+ return id, nil
+}
+
+func undeploy(apiID, deploymentID, gatewayID string) error {
+ st, body, err := apiCall(http.MethodPost,
+ "/api/v1/rest-apis/"+apiID+"/deployments/"+deploymentID+"/undeploy?gatewayId="+gatewayID, suite.token, nil)
+ if err != nil {
+ return err
+ }
+ if st >= 300 {
+ return fmt.Errorf("undeploy failed (%d): %s", st, body)
+ }
+ return nil
+}
+
+func (w *world) deployToGateway() error {
+ id, err := deploy(w.apiID, suite.gw1ID, "gateway-controller")
+ if err != nil {
+ return err
+ }
+ w.depGw1 = id
+ return nil
+}
+
+func (w *world) deployToSecondGateway() error {
+ id, err := deploy(w.apiID, suite.gw2ID, "gateway-controller-2")
+ if err != nil {
+ return err
+ }
+ w.depGw2 = id
+ return nil
+}
+
+func (w *world) undeployFromGateway() error { return undeploy(w.apiID, w.depGw1, suite.gw1ID) }
+func (w *world) undeployFromSecondGateway() error { return undeploy(w.apiID, w.depGw2, suite.gw2ID) }
+
+// --- Then (data-plane assertions) ------------------------------------------
+
+func (w *world) gatewayServes() error { return waitIngress(ingressGw1, w.apiContext, 200) }
+func (w *world) gatewayStopsServing() error { return waitIngress(ingressGw1, w.apiContext, 404) }
+func (w *world) secondGatewayServes() error { return waitIngress(ingressGw2, w.apiContext, 200) }
+func (w *world) secondGatewayStopsServing() error {
+ return waitIngress(ingressGw2, w.apiContext, 404)
+}
+
+func (w *world) gatewayStillServes() error {
+ if code := ingressStatus(ingressGw1, w.apiContext); code != 200 {
+ return fmt.Errorf("gateway 1 should still serve the API, got %d", code)
+ }
+ return nil
+}
+
+func (w *world) unmappedPathReturns404() error {
+ if code := ingressStatus(ingressGw1, "/no-such-"+randHex()); code != 404 {
+ return fmt.Errorf("unmapped path should return 404, got %d", code)
+ }
+ return nil
+}
+
+// --- ingress helpers -------------------------------------------------------
+
+func ingressStatus(base, context string) int {
+ req, err := http.NewRequest(http.MethodGet, base+context+"/", nil)
+ if err != nil {
+ return -1
+ }
+ req.Host = ingressHost // gateway routes by vhost
+ resp, err := httpClient.Do(req)
+ if err != nil {
+ return 0
+ }
+ resp.Body.Close()
+ return resp.StatusCode
+}
+
+// waitIngress polls the gateway ingress until it returns want (or times out).
+func waitIngress(base, context string, want int) error {
+ deadline := time.Now().Add(pollTimeout)
+ var last int
+ for time.Now().Before(deadline) {
+ if last = ingressStatus(base, context); last == want {
+ return nil
+ }
+ time.Sleep(2 * time.Second)
+ }
+ return fmt.Errorf("ingress %s%s: wanted %d, last observed %d", base, context, want, last)
+}
+
+func randHex() string {
+ b := make([]byte, 4)
+ _, _ = rand.Read(b)
+ return hex.EncodeToString(b)
+}
diff --git a/tests/integration-e2e/suite_test.go b/tests/integration-e2e/suite_test.go
new file mode 100644
index 000000000..1d38a2b94
--- /dev/null
+++ b/tests/integration-e2e/suite_test.go
@@ -0,0 +1,305 @@
+/*
+ * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com).
+ *
+ * WSO2 LLC. licenses this file to you under the Apache License,
+ * Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+// Package e2e is a godog (Cucumber) suite that drives the real platform-api
+// control plane and the real gateway data plane end to end, on a database
+// engine selected by E2E_DB (postgres | sqlite | sqlserver).
+package e2e
+
+import (
+ "bytes"
+ "crypto/tls"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "os/exec"
+ "testing"
+ "time"
+
+ "github.com/cucumber/godog"
+)
+
+const (
+ composeProject = "apip-e2e-bdd"
+ ingressHost = "localhost"
+ pollTimeout = 90 * time.Second
+)
+
+// Host-side endpoints. Ports are overridable so the suite can run alongside
+// other local stacks; container-internal wiring (controller -> platform-api:9243)
+// is unaffected. Defaults match the compose files and CI.
+var (
+ platformAPI = "https://localhost:" + envOr("PA_HOST_PORT", "9243")
+ ingressGw1 = "http://localhost:" + envOr("GW_HTTP_PORT", "18080")
+ ingressGw2 = "http://localhost:" + envOr("GW2_HTTP_PORT", "18081")
+)
+
+// suite holds state established once for the whole run (BeforeSuite): the chosen
+// database, the running stack, the admin token and the pre-registered gateways.
+var suite struct {
+ db string // postgres | sqlite | sqlserver
+ composeFile string
+ multi bool // second gateway available (postgres stack only)
+ token string
+ projectID string
+ gw1ID string
+ gw2ID string
+}
+
+var httpClient = &http.Client{
+ Timeout: 25 * time.Second,
+ Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}},
+}
+
+// TestFeatures is the go test entry point that runs the godog suite.
+func TestFeatures(t *testing.T) {
+ tags := os.Getenv("E2E_TAGS")
+ if tags == "" && os.Getenv("E2E_DB") != "" && os.Getenv("E2E_DB") != "postgres" {
+ tags = "~@multigateway" // second gateway is only wired on the postgres stack
+ }
+ status := godog.TestSuite{
+ Name: "platform-api-gateway-e2e",
+ TestSuiteInitializer: initializeSuite,
+ ScenarioInitializer: initializeScenario,
+ Options: &godog.Options{
+ Format: "pretty",
+ Paths: []string{"features"},
+ Tags: tags,
+ Strict: true,
+ TestingT: t,
+ },
+ }.Run()
+ if status != 0 {
+ t.Fatalf("godog suite failed with status %d", status)
+ }
+}
+
+// initializeSuite brings the whole stack up before any scenario and tears it
+// down afterwards. The registration-token bootstrap (create gateway -> mint
+// token -> start its controller) is done here so scenarios can simply deploy.
+func initializeSuite(ctx *godog.TestSuiteContext) {
+ ctx.BeforeSuite(func() {
+ if err := bringUpStack(); err != nil {
+ tearDownStack()
+ panic(fmt.Sprintf("e2e setup failed: %v", err))
+ }
+ })
+ ctx.AfterSuite(func() {
+ if os.Getenv("E2E_KEEP") == "" {
+ tearDownStack()
+ }
+ })
+}
+
+func bringUpStack() error {
+ suite.db = envOr("E2E_DB", "postgres")
+ switch suite.db {
+ case "postgres":
+ suite.composeFile, suite.multi = "docker-compose.yaml", true
+ case "sqlite":
+ suite.composeFile = "docker-compose.sqlite.yaml"
+ case "sqlserver":
+ suite.composeFile = "docker-compose.sqlserver.yaml"
+ default:
+ return fmt.Errorf("unsupported E2E_DB %q", suite.db)
+ }
+ fmt.Printf("E2E database backend: %s (%s)\n", suite.db, suite.composeFile)
+
+ // Phase 1: control plane + backend.
+ phase1 := []string{"platform-api", "sample-backend"}
+ if suite.db != "sqlite" {
+ phase1 = append([]string{dbService()}, phase1...)
+ }
+ if err := compose(nil, append([]string{"up", "-d"}, phase1...)...); err != nil {
+ return fmt.Errorf("start control plane: %w", err)
+ }
+ if err := waitHealthy(); err != nil {
+ return err
+ }
+
+ // Bootstrap: authenticate, project, gateways + registration tokens.
+ var err error
+ if suite.token, err = login(); err != nil {
+ return err
+ }
+ if suite.projectID, err = createProject(); err != nil {
+ return err
+ }
+ gw1Token, gw2Token := "", ""
+ if suite.gw1ID, gw1Token, err = createGatewayAndToken("e2e-gw"); err != nil {
+ return err
+ }
+ env := map[string]string{"GATEWAY_REGISTRATION_TOKEN": gw1Token}
+ dataPlane := []string{"gateway-controller", "gateway-runtime"}
+ if suite.multi {
+ if suite.gw2ID, gw2Token, err = createGatewayAndToken("e2e-gw2"); err != nil {
+ return err
+ }
+ env["GATEWAY_REGISTRATION_TOKEN_2"] = gw2Token
+ dataPlane = append(dataPlane, "gateway-controller-2", "gateway-runtime-2")
+ }
+
+ // Phase 2: data plane with the minted tokens.
+ if err := compose(env, append([]string{"up", "-d"}, dataPlane...)...); err != nil {
+ return fmt.Errorf("start data plane: %w", err)
+ }
+ return nil
+}
+
+func tearDownStack() {
+ _ = compose(map[string]string{"GATEWAY_REGISTRATION_TOKEN": "x"}, "down", "-v", "--remove-orphans")
+}
+
+func dbService() string {
+ if suite.db == "sqlserver" {
+ return "sqlserver"
+ }
+ return "postgres"
+}
+
+// compose runs `docker compose -p -f ` with optional
+// extra environment (for the registration tokens).
+func compose(extraEnv map[string]string, args ...string) error {
+ full := append([]string{"compose", "-p", composeProject, "-f", suite.composeFile}, args...)
+ cmd := exec.Command("docker", full...)
+ cmd.Env = os.Environ()
+ for k, v := range extraEnv {
+ cmd.Env = append(cmd.Env, k+"="+v)
+ }
+ if out, err := cmd.CombinedOutput(); err != nil {
+ return fmt.Errorf("docker %v: %w\n%s", args, err, out)
+ }
+ return nil
+}
+
+func waitHealthy() error {
+ deadline := time.Now().Add(pollTimeout * 2)
+ for time.Now().Before(deadline) {
+ resp, err := httpClient.Get(platformAPI + "/health")
+ if err == nil {
+ body, _ := io.ReadAll(resp.Body)
+ resp.Body.Close()
+ if resp.StatusCode == 200 && bytes.Contains(body, []byte("ok")) {
+ return nil
+ }
+ }
+ time.Sleep(2 * time.Second)
+ }
+ return fmt.Errorf("platform-api did not become healthy")
+}
+
+// --- platform-api REST helpers --------------------------------------------
+
+func apiCall(method, path, token string, body any) (int, []byte, error) {
+ var rdr io.Reader
+ if body != nil {
+ b, _ := json.Marshal(body)
+ rdr = bytes.NewReader(b)
+ }
+ req, err := http.NewRequest(method, platformAPI+path, rdr)
+ if err != nil {
+ return 0, nil, err
+ }
+ req.Header.Set("Content-Type", "application/json")
+ if token != "" {
+ req.Header.Set("Authorization", "Bearer "+token)
+ }
+ resp, err := httpClient.Do(req)
+ if err != nil {
+ return 0, nil, err
+ }
+ defer resp.Body.Close()
+ out, _ := io.ReadAll(resp.Body)
+ return resp.StatusCode, out, nil
+}
+
+func login() (string, error) {
+ st, body, err := apiCall(http.MethodPost, "/api/portal/v1/auth/login", "",
+ map[string]string{"username": "admin", "password": "admin"})
+ if err != nil {
+ return "", err
+ }
+ var r struct {
+ Token string `json:"token"`
+ }
+ _ = json.Unmarshal(body, &r)
+ if st != 200 || r.Token == "" {
+ return "", fmt.Errorf("login failed (%d): %s", st, body)
+ }
+ return r.Token, nil
+}
+
+func createProject() (string, error) {
+ st, body, err := apiCall(http.MethodPost, "/api/v1/projects", suite.token,
+ map[string]string{"name": "e2e-proj", "description": "e2e"})
+ if err != nil {
+ return "", err
+ }
+ id := jsonField(body, "id", "uuid")
+ if st >= 300 || id == "" {
+ return "", fmt.Errorf("create project failed (%d): %s", st, body)
+ }
+ return id, nil
+}
+
+func createGatewayAndToken(name string) (gatewayID, token string, err error) {
+ st, body, err := apiCall(http.MethodPost, "/api/v1/gateways", suite.token, map[string]any{
+ "name": name, "displayName": name, "vhost": ingressHost, "functionalityType": "regular",
+ })
+ if err != nil {
+ return "", "", err
+ }
+ gatewayID = jsonField(body, "id", "uuid")
+ if st >= 300 || gatewayID == "" {
+ return "", "", fmt.Errorf("create gateway failed (%d): %s", st, body)
+ }
+ st, body, err = apiCall(http.MethodPost, "/api/v1/gateways/"+gatewayID+"/tokens", suite.token, map[string]any{})
+ if err != nil {
+ return "", "", err
+ }
+ token = jsonField(body, "token")
+ if st >= 300 || token == "" {
+ return "", "", fmt.Errorf("rotate token failed (%d): %s", st, body)
+ }
+ return gatewayID, token, nil
+}
+
+// --- small helpers ---------------------------------------------------------
+
+func envOr(k, def string) string {
+ if v := os.Getenv(k); v != "" {
+ return v
+ }
+ return def
+}
+
+// jsonField returns the first non-empty string value among the given keys.
+func jsonField(body []byte, keys ...string) string {
+ var m map[string]any
+ if json.Unmarshal(body, &m) != nil {
+ return ""
+ }
+ for _, k := range keys {
+ if s, ok := m[k].(string); ok && s != "" {
+ return s
+ }
+ }
+ return ""
+}
diff --git a/tests/mock-servers/mock-platform-api/go.mod b/tests/mock-servers/mock-platform-api/go.mod
index 25d69b755..97a886edb 100644
--- a/tests/mock-servers/mock-platform-api/go.mod
+++ b/tests/mock-servers/mock-platform-api/go.mod
@@ -7,14 +7,19 @@ require (
github.com/gorilla/websocket v1.5.3
github.com/jackc/pgx/v5 v5.9.2
github.com/mattn/go-sqlite3 v1.14.34
+ github.com/microsoft/go-mssqldb v1.10.0
)
require (
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
+ github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
+ github.com/golang-sql/sqlexp v0.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
+ github.com/shopspring/decimal v1.4.0 // indirect
+ golang.org/x/crypto v0.50.0 // indirect
golang.org/x/sync v0.20.0 // indirect
- golang.org/x/text v0.35.0 // indirect
+ golang.org/x/text v0.36.0 // indirect
)
diff --git a/tests/mock-servers/mock-platform-api/go.sum b/tests/mock-servers/mock-platform-api/go.sum
index 2b0b85f1e..90a1d4af2 100644
--- a/tests/mock-servers/mock-platform-api/go.sum
+++ b/tests/mock-servers/mock-platform-api/go.sum
@@ -1,6 +1,24 @@
+github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1 h1:jHb/wfvRikGdxMXYV3QG/SzUOPYN9KEUUuC0Yd0/vC0=
+github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1/go.mod h1:pzBXCYn05zvYIrwLgtK8Ap8QcjRg+0i76tMQdWN6wOk=
+github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4=
+github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0=
+github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 h1:fhqpLE3UEXi9lPaBRpQ6XuRW0nU7hgg4zlmZZa+a9q4=
+github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0/go.mod h1:7dCRMLwisfRH3dBupKeNCioWYUZ4SS09Z14H+7i8ZoY=
+github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.4.0 h1:E4MgwLBGeVB5f2MdcIVD3ELVAWpr+WD6MUe1i+tM/PA=
+github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.4.0/go.mod h1:Y2b/1clN4zsAoUd/pgNAQHjLDnTis/6ROkUfyob6psM=
+github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 h1:nCYfgcSyHZXJI8J0IWE5MsCGlb2xp9fJiXyxWgmOFg4=
+github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0/go.mod h1:ucUjca2JtSZboY8IoUqyQyuuXvwbMBVwFOm0vdQPNhA=
+github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs=
+github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
+github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
+github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
+github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
+github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
+github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
@@ -13,20 +31,34 @@ github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
+github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
+github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
+github.com/microsoft/go-mssqldb v1.10.0 h1:pHEt+Qz6YFPWqREq10mqSE524QQo+/QremwTCQht7TY=
+github.com/microsoft/go-mssqldb v1.10.0/go.mod h1:mnG7lGa9iYJbzJqGCXyuQCegStKMr3kogDLD6+bmggg=
+github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
+github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
+github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
+golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
+golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
+golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
-golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
-golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
+golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
+golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
+golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
diff --git a/tests/mock-servers/mock-platform-api/main.go b/tests/mock-servers/mock-platform-api/main.go
index bfac26aab..8a7fa463a 100644
--- a/tests/mock-servers/mock-platform-api/main.go
+++ b/tests/mock-servers/mock-platform-api/main.go
@@ -34,6 +34,7 @@ import (
"github.com/gorilla/websocket"
_ "github.com/jackc/pgx/v5/stdlib"
_ "github.com/mattn/go-sqlite3"
+ _ "github.com/microsoft/go-mssqldb"
)
const (
@@ -66,6 +67,16 @@ func main() {
getEnv("DB_SSLMODE", "disable"),
)
log.Printf("Mock platform-api using Postgres at %s", getEnv("DB_HOST", "localhost"))
+ } else if dbType == "sqlserver" {
+ dbDSN = fmt.Sprintf("sqlserver://%s:%s@%s:%s?database=%s&encrypt=%s&TrustServerCertificate=true",
+ getEnv("DB_USER", "sa"),
+ getEnv("DB_PASSWORD", "gateway"),
+ getEnv("DB_HOST", "localhost"),
+ getEnv("DB_PORT", "1433"),
+ getEnv("DB_NAME", "gateway_test"),
+ getEnv("DB_ENCRYPT", "disable"),
+ )
+ log.Printf("Mock platform-api using SQL Server at %s", getEnv("DB_HOST", "localhost"))
} else {
dbPath = os.Getenv("GATEWAY_DB_PATH")
if dbPath == "" {
@@ -240,9 +251,12 @@ func getEnv(key, def string) string {
func getDeploymentUUID(handle string) (string, error) {
var db *sql.DB
var err error
- if dbType == "postgres" {
+ switch dbType {
+ case "postgres":
db, err = sql.Open("pgx", dbDSN)
- } else {
+ case "sqlserver":
+ db, err = sql.Open("sqlserver", dbDSN)
+ default:
db, err = sql.Open("sqlite3", dbPath)
}
if err != nil {
@@ -251,12 +265,18 @@ func getDeploymentUUID(handle string) (string, error) {
defer db.Close()
var uuid string
- if dbType == "postgres" {
+ switch dbType {
+ case "postgres":
err = db.QueryRow(
"SELECT uuid FROM artifacts WHERE handle = $1 AND kind = 'RestApi' LIMIT 1",
handle,
).Scan(&uuid)
- } else {
+ case "sqlserver":
+ err = db.QueryRow(
+ "SELECT TOP 1 uuid FROM artifacts WHERE handle = @p1 AND kind = 'RestApi'",
+ handle,
+ ).Scan(&uuid)
+ default:
err = db.QueryRow(
"SELECT uuid FROM artifacts WHERE handle = ? AND kind = 'RestApi' LIMIT 1",
handle,