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,