From fac419a07009b033540a146c352381cf2ee67012 Mon Sep 17 00:00:00 2001 From: zeevdr Date: Fri, 5 Jun 2026 07:17:36 +0300 Subject: [PATCH] feat(demos): add multi-tenant with shared schema demo Adds a self-contained multi-tenant/ demo showing how multiple tenants share one schema definition while maintaining completely independent config values. The demo uses a SaaS e-commerce scenario (US store vs. EU store) and covers schema seeding, per-tenant provisioning, isolation verification, schema validation enforcement, and idempotent re-seeding. Partially closes opendecree/decree#30. Co-Authored-By: Claude --- multi-tenant/README.md | 238 ++++++++++++++++++++++++++ multi-tenant/docker-compose.yml | 122 +++++++++++++ multi-tenant/run.sh | 90 ++++++++++ multi-tenant/schema.seed.yaml | 27 +++ multi-tenant/seed/init.sql | 125 ++++++++++++++ multi-tenant/tenant1.config.seed.yaml | 18 ++ multi-tenant/tenant2.config.seed.yaml | 18 ++ multi-tenant/test.sh | 46 +++++ 8 files changed, 684 insertions(+) create mode 100644 multi-tenant/README.md create mode 100644 multi-tenant/docker-compose.yml create mode 100755 multi-tenant/run.sh create mode 100644 multi-tenant/schema.seed.yaml create mode 100644 multi-tenant/seed/init.sql create mode 100644 multi-tenant/tenant1.config.seed.yaml create mode 100644 multi-tenant/tenant2.config.seed.yaml create mode 100755 multi-tenant/test.sh diff --git a/multi-tenant/README.md b/multi-tenant/README.md new file mode 100644 index 0000000..71d03cb --- /dev/null +++ b/multi-tenant/README.md @@ -0,0 +1,238 @@ +# Multi-Tenant with Shared Schema + +[![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/opendecree/demos?devcontainer_path=.devcontainer%2Fmulti-tenant%2Fdevcontainer.json) + +> Multiple customers share one schema definition but manage their config values completely independently. + +## What you'll learn + +This tutorial shows how OpenDecree handles the core SaaS multi-tenancy pattern: + +- The **schema** is the contract — it belongs to the engineering team and ships with the application. +- Each **tenant** (customer) provisions their own config values against that schema independently. +- Changing one tenant's config has zero effect on any other tenant. +- All tenants are validated against the same schema — constraints are enforced for everyone. + +This demo uses a SaaS e-commerce platform as the example: two stores (one US, one EU) that run on the same platform with different currencies, tax rates, and tier limits. + +## Prerequisites + +- Docker and Docker Compose +- `curl` (for the CLI steps) + +## Quick start + +```bash +./run.sh +``` + +Then open: +- **[http://localhost:3000](http://localhost:3000)** — Admin panel (switch between tenant1 and tenant2) +- **[http://localhost:8080](http://localhost:8080)** — Decree REST API + +## Step-by-step walkthrough + +### Step 1 — App deploys the schema + +The schema is a deployment artifact. The engineering team publishes it once per release — independently of any tenant or config values. + +```bash +docker compose run --rm seed-schema +``` + +`schema.seed.yaml` defines five fields across three groups: + +| Field | Type | Constraint | +|-------|------|------------| +| `checkout.currency` | string | enum: USD, EUR, GBP | +| `checkout.tax_rate` | number | 0 – 0.5 | +| `notifications.email_enabled` | bool | — | +| `notifications.webhook_url` | url | — | +| `pricing.free_tier_limit` | integer | min: 0 | + +No tenant data, no config values — just the field definitions and their constraints. Every tenant that onboards later will be validated against this contract. + +**Why it matters:** When you ship a new feature that needs a new config field, you publish a new schema version. All tenants pick it up on their own schedule. Their config files don't change. + +### Step 2 — Tenant 1 (US store) provisions its config + +Tenant 1 is a US-based store. Their deployment pipeline seeds their config once and keeps it in version control. + +```bash +docker compose run --rm seed-tenant1 +``` + +`tenant1.config.seed.yaml` references the schema by name and sets values appropriate for a US store: + +```yaml +checkout.currency: USD +checkout.tax_rate: 0.08 # 8% sales tax +notifications.email_enabled: true +notifications.webhook_url: https://hooks.tenant1.example.com/orders +pricing.free_tier_limit: 100 +``` + +The file does not redeclare fields or constraints — those live in the schema. Tenant 1 only sets values. + +**Why it matters:** Tenant 1's config is decoupled from the engineering team's release cycle. They can update their values independently, at their own pace, without touching the schema. + +### Step 3 — Tenant 2 (EU store) onboards + +Tenant 2 is a European store. They use the same platform (same schema), but different values. + +```bash +docker compose run --rm seed-tenant2 +``` + +`tenant2.config.seed.yaml` references the same `saas-ecommerce` schema but sets EU-appropriate values: + +```yaml +checkout.currency: EUR +checkout.tax_rate: 0.20 # 20% VAT +notifications.email_enabled: true +notifications.webhook_url: https://hooks.tenant2.example.com/orders +pricing.free_tier_limit: 50 +``` + +**Why it matters:** Adding the hundredth tenant is identical to adding the second. The schema is a shared contract; config is per-tenant state. No schema duplication, no per-tenant copies of field definitions. + +### Step 4 — Verify isolation + +Read both tenants and confirm their values are independent: + +```bash +# Tenant 1 — US store +curl http://localhost:8080/v1/config/tenant1/snapshot + +# Tenant 2 — EU store +curl http://localhost:8080/v1/config/tenant2/snapshot +``` + +Now change tenant 1's tax rate: + +```bash +curl -X PUT http://localhost:8080/v1/config/tenant1/values/checkout.tax_rate \ + -H "Content-Type: application/json" \ + -H "X-Decree-Subject: demo-user" \ + -d '{"value": "0.10"}' +``` + +Read both again — tenant 2 is untouched: + +```bash +# tenant1 → 0.10 (updated) +curl http://localhost:8080/v1/config/tenant1/values/checkout.tax_rate + +# tenant2 → 0.20 (unchanged) +curl http://localhost:8080/v1/config/tenant2/values/checkout.tax_rate +``` + +**Why it matters:** Config changes are scoped to a single tenant. There is no shared mutable state between tenants — even though they share a schema. + +### Step 5 (optional) — Schema validation in action + +Try setting an invalid value — the server rejects it: + +```bash +# Attempt to set currency to an unsupported value (not in enum) +curl -X PUT http://localhost:8080/v1/config/tenant1/values/checkout.currency \ + -H "Content-Type: application/json" \ + -H "X-Decree-Subject: demo-user" \ + -d '{"value": "JPY"}' +# → 400 Bad Request: constraint violation +``` + +Both tenants are protected by the same schema constraints — neither can set a value outside the defined enum or range. + +### Step 6 (optional) — Evolve the schema + +Add a new field to `schema.seed.yaml`: + +```yaml +checkout.express_enabled: + type: bool + description: Enable express checkout for this store +``` + +Re-seed the schema — the new field appears for both tenants immediately: + +```bash +docker compose run --rm seed-schema +``` + +Neither tenant's existing config values are affected. The new field is available to both, ready to be configured. + +## What's happening + +```mermaid +flowchart LR + subgraph "Deploy time" + SchemaFile["schema.seed.yaml\n(shared contract)"] + T1File["tenant1.config.seed.yaml\n(US store)"] + T2File["tenant2.config.seed.yaml\n(EU store)"] + end + + subgraph "Runtime" + Server["Decree Server"] + Admin["Admin Panel\n:3000"] + end + + SchemaFile -->|"Step 1: decree seed"| Server + T1File -->|"Step 2: decree seed"| Server + T2File -->|"Step 3: decree seed"| Server + Admin -->|"Step 4: runtime override"| Server +``` + +The schema is seeded once. Each tenant seeds their config independently, referencing the schema by name. The Decree server validates every write against the schema constraints — for every tenant. + +## Clean up + +```bash +docker compose down -v +``` + +## Appendix: Environment and Architecture + +
+Click to expand + +### Services + +| Service | Image | Ports | Purpose | +|---------|-------|-------|---------| +| postgres | `postgres:17` | (internal) | Schema, config, and audit storage | +| redis | `redis:7` | (internal) | Cache invalidation + real-time pub/sub | +| decree-server | `ghcr.io/opendecree/decree:0.11.0-alpha.1` | 8080 | Core config management | +| seed-schema | `ghcr.io/opendecree/decree-cli:0.11.0-alpha.1` | — | Step 1: imports saas-ecommerce schema | +| seed-tenant1 | `ghcr.io/opendecree/decree-cli:0.11.0-alpha.1` | — | Step 2: provisions tenant1 config | +| seed-tenant2 | `ghcr.io/opendecree/decree-cli:0.11.0-alpha.1` | — | Step 3: provisions tenant2 config | +| admin | `ghcr.io/opendecree/decree-ui:0.1.0-alpha.1` | 3000 | Admin GUI (multi-tenant mode) | + +### Seed files + +| File | Type | Purpose | +|------|------|---------| +| `schema.seed.yaml` | schema | Defines all fields and constraints — shared by all tenants | +| `tenant1.config.seed.yaml` | config | US store values | +| `tenant2.config.seed.yaml` | config | EU store values | + +### Key design points + +- The schema has no `tenant` section — it is owned by the application, not by any customer. +- Each config file has a `tenant` section but no `schema` section — it references, never redefines. +- `schema_version` is omitted in config files — binds to the latest published version automatically. +- Seeds are idempotent — re-running them does not create duplicate versions or overwrite runtime overrides. + +### Volumes + +| Volume | Purpose | +|--------|---------| +| `pgdata` | Persistent Postgres data (survives `docker compose stop`) | + +
+ +## Next steps + +- [Quickstart](../quickstart/) — see config changes flow to a live service in real time +- [REST Walkthrough](../rest-walkthrough/) — drive the same API with nothing but curl +- [OpenDecree docs](https://github.com/opendecree/decree) — full API, CLI, and SDK reference diff --git a/multi-tenant/docker-compose.yml b/multi-tenant/docker-compose.yml new file mode 100644 index 0000000..0b2093c --- /dev/null +++ b/multi-tenant/docker-compose.yml @@ -0,0 +1,122 @@ +# Multi-Tenant Demo — Docker Compose +# +# Architecture: +# postgres + redis → decree-server → seed-schema +# → seed-tenant1 +# → seed-tenant2 +# → admin (UI) +# +# Ports: +# 3000 — Admin panel (decree-ui) for viewing/editing config +# 8080 — Decree REST API (exposed for CLI walkthrough) +# +# Data flow: +# 1. schema.seed.yaml defines the schema — deployed once, shared by all tenants +# 2. tenant1.config.seed.yaml and tenant2.config.seed.yaml define per-tenant config +# 3. Admin panel lets you view and edit any tenant's config via the UI + +services: + # --- Infrastructure --- + # PostgreSQL stores schemas, config values, and audit history. + # The migration SQL runs automatically on first start via initdb. + postgres: + image: postgres:17 + environment: + POSTGRES_DB: centralconfig + POSTGRES_USER: centralconfig + POSTGRES_PASSWORD: localdev + volumes: + - pgdata:/var/lib/postgresql/data + - ./seed/init.sql:/docker-entrypoint-initdb.d/001_schema.sql:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U centralconfig"] + interval: 2s + timeout: 5s + retries: 10 + + # Redis handles cache invalidation and real-time change notifications (pub/sub). + redis: + image: redis:7 + command: redis-server --maxmemory 128mb --maxmemory-policy allkeys-lru + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 2s + timeout: 5s + retries: 10 + + # --- Decree Server --- + # The core config management service. Runs database migrations automatically. + # Exposes gRPC (:9090) for SDKs and CLI, and REST (:8080) for the admin UI / curl. + decree-server: + image: ghcr.io/opendecree/decree:0.11.0-alpha.1 + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + environment: + GRPC_PORT: "9090" + HTTP_PORT: "8080" + DB_WRITE_URL: "postgres://centralconfig:localdev@postgres:5432/centralconfig?sslmode=disable" + DB_READ_URL: "postgres://centralconfig:localdev@postgres:5432/centralconfig?sslmode=disable" + REDIS_URL: "redis://redis:6379" + ENABLE_SERVICES: "schema,config,audit" + ports: + - "8080:8080" # Exposed so the run.sh CLI walkthrough can reach it from the host + + # --- Step 1: App deploys the schema --- + # Schema-only seed. Runs once on startup; idempotent on re-run. + seed-schema: + image: ghcr.io/opendecree/decree-cli:0.11.0-alpha.1 + depends_on: + decree-server: + condition: service_started + volumes: + - ./schema.seed.yaml:/schema.seed.yaml:ro + command: ["seed", "/schema.seed.yaml", + "--server", "decree-server:9090", "--subject", "demo-setup", + "--auto-publish", "--wait", "--wait-timeout", "60s"] + + # --- Step 2: Tenant 1 (US store) provisions its config --- + seed-tenant1: + image: ghcr.io/opendecree/decree-cli:0.11.0-alpha.1 + depends_on: + seed-schema: + condition: service_completed_successfully + volumes: + - ./tenant1.config.seed.yaml:/tenant1.config.seed.yaml:ro + command: ["seed", "/tenant1.config.seed.yaml", + "--server", "decree-server:9090", "--subject", "demo-setup", + "--wait", "--wait-timeout", "60s"] + + # --- Step 3: Tenant 2 (EU store) onboards --- + # Same schema, independent config. + seed-tenant2: + image: ghcr.io/opendecree/decree-cli:0.11.0-alpha.1 + depends_on: + seed-schema: + condition: service_completed_successfully + volumes: + - ./tenant2.config.seed.yaml:/tenant2.config.seed.yaml:ro + command: ["seed", "/tenant2.config.seed.yaml", + "--server", "decree-server:9090", "--subject", "demo-setup", + "--wait", "--wait-timeout", "60s"] + + # --- Admin GUI --- + # The decree-ui admin panel. Runs in multi-tenant mode so you can switch + # between tenant1 and tenant2 and see their independent config values. + admin: + image: ghcr.io/opendecree/decree-ui:0.1.0-alpha.1 + depends_on: + decree-server: + condition: service_started + environment: + API_URL: http://decree-server:8080 + LAYOUT_MODE: multi-tenant # Shows schema/tenant navigation + DEFAULT_ROLE: superadmin # Allows viewing all tenants + DEFAULT_SUBJECT: admin + ports: + - "3000:80" + +volumes: + pgdata: diff --git a/multi-tenant/run.sh b/multi-tenant/run.sh new file mode 100755 index 0000000..babc288 --- /dev/null +++ b/multi-tenant/run.sh @@ -0,0 +1,90 @@ +#!/bin/bash +# Multi-tenant demo walkthrough. +# Shows how two tenants share one schema with independent config values. +# Safe to re-run — every step is idempotent. +set -euo pipefail + +DECREE_CLI="docker compose run --rm --no-TTY" +COMPOSE="docker compose" +SERVER="localhost:8080" + +echo "=== Starting infrastructure ===" +$COMPOSE up -d postgres redis decree-server + +echo "" +echo "=== Step 1: App deploys the schema ===" +echo " The 'saas-ecommerce' schema is a deployment artifact." +echo " It defines the config contract for all tenants — deployed once." +$COMPOSE run --rm seed-schema + +echo "" +echo "=== Step 2: Tenant 1 (US store) provisions its config ===" +echo " tenant1 references the schema by name and sets its own values:" +echo " checkout.currency = USD" +echo " checkout.tax_rate = 0.08" +echo " pricing.free_tier_limit = 100" +$COMPOSE run --rm seed-tenant1 + +echo "" +echo "=== Step 3: Tenant 2 (EU store) onboards ===" +echo " tenant2 shares the same schema but has completely independent values:" +echo " checkout.currency = EUR" +echo " checkout.tax_rate = 0.20" +echo " pricing.free_tier_limit = 50" +$COMPOSE run --rm seed-tenant2 + +echo "" +echo "=== Starting admin panel ===" +$COMPOSE up -d admin + +echo "" +echo "=== Waiting for decree REST API ===" +for i in $(seq 1 30); do + curl -sf "http://${SERVER}/v1/server/info" >/dev/null 2>&1 && break + sleep 2 +done + +echo "" +echo "=== Reading tenant1 config ===" +echo " (US store — USD, 8% tax, 100 free-tier orders)" +curl -s "http://${SERVER}/v1/config/tenant1/snapshot" | \ + python3 -m json.tool 2>/dev/null || \ + curl -s "http://${SERVER}/v1/config/tenant1/snapshot" + +echo "" +echo "=== Reading tenant2 config ===" +echo " (EU store — EUR, 20% tax, 50 free-tier orders)" +curl -s "http://${SERVER}/v1/config/tenant2/snapshot" | \ + python3 -m json.tool 2>/dev/null || \ + curl -s "http://${SERVER}/v1/config/tenant2/snapshot" + +echo "" +echo "=== Demonstrating isolation: changing tenant1 does not affect tenant2 ===" +echo " Setting tenant1 checkout.tax_rate to 0.10 (a runtime override)..." +curl -s -X PUT "http://${SERVER}/v1/config/tenant1/values/checkout.tax_rate" \ + -H "Content-Type: application/json" \ + -H "X-Decree-Subject: demo-user" \ + -d '{"value": "0.10"}' | python3 -m json.tool 2>/dev/null || true + +echo "" +echo " tenant1 checkout.tax_rate after update:" +curl -s "http://${SERVER}/v1/config/tenant1/values/checkout.tax_rate" | \ + python3 -m json.tool 2>/dev/null || \ + curl -s "http://${SERVER}/v1/config/tenant1/values/checkout.tax_rate" + +echo "" +echo " tenant2 checkout.tax_rate (unchanged — still 0.20):" +curl -s "http://${SERVER}/v1/config/tenant2/values/checkout.tax_rate" | \ + python3 -m json.tool 2>/dev/null || \ + curl -s "http://${SERVER}/v1/config/tenant2/values/checkout.tax_rate" + +echo "" +echo "=== Done ===" +echo " Admin panel (all tenants): http://localhost:3000" +echo " REST API: http://localhost:8080" +echo "" +echo " Try it:" +echo " curl http://localhost:8080/v1/config/tenant1/snapshot" +echo " curl http://localhost:8080/v1/config/tenant2/snapshot" +echo "" +echo " Run 'docker compose down -v' to tear down and remove all data." diff --git a/multi-tenant/schema.seed.yaml b/multi-tenant/schema.seed.yaml new file mode 100644 index 0000000..0cc000b --- /dev/null +++ b/multi-tenant/schema.seed.yaml @@ -0,0 +1,27 @@ +spec_version: v1 +schema: + name: saas-ecommerce + description: E-commerce SaaS platform configuration schema + fields: + checkout.currency: + type: string + description: Store currency code + constraints: + enum: [USD, EUR, GBP] + checkout.tax_rate: + type: number + description: Tax rate as a decimal (0–0.5) + constraints: + minimum: 0 + maximum: 0.5 + notifications.email_enabled: + type: bool + description: Whether to send email notifications to customers + notifications.webhook_url: + type: url + description: Webhook endpoint for order event notifications + pricing.free_tier_limit: + type: integer + description: Maximum number of free-tier orders per month + constraints: + minimum: 0 diff --git a/multi-tenant/seed/init.sql b/multi-tenant/seed/init.sql new file mode 100644 index 0000000..afb44c0 --- /dev/null +++ b/multi-tenant/seed/init.sql @@ -0,0 +1,125 @@ + +CREATE EXTENSION IF NOT EXISTS "pgcrypto"; + +-- Field type enum +CREATE TYPE field_type AS ENUM ( + 'integer', + 'number', + 'string', + 'bool', + 'time', + 'duration', + 'url', + 'json' +); + +-- Schema definitions +CREATE TABLE schemas ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL UNIQUE, + description TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE schema_versions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + schema_id UUID NOT NULL REFERENCES schemas(id) ON DELETE CASCADE, + version INT NOT NULL, + parent_version INT, + description TEXT, + checksum TEXT NOT NULL, + published BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE(schema_id, version) +); + +CREATE TABLE schema_fields ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + schema_version_id UUID NOT NULL REFERENCES schema_versions(id) ON DELETE CASCADE, + path TEXT NOT NULL, + field_type field_type NOT NULL, + constraints JSONB, + nullable BOOLEAN NOT NULL DEFAULT false, + deprecated BOOLEAN NOT NULL DEFAULT false, + redirect_to TEXT, + default_value TEXT, + description TEXT, + title TEXT, + example TEXT, + examples JSONB, + external_docs JSONB, + tags TEXT[], + format TEXT, + read_only BOOLEAN NOT NULL DEFAULT false, + write_once BOOLEAN NOT NULL DEFAULT false, + sensitive BOOLEAN NOT NULL DEFAULT false, + UNIQUE(schema_version_id, path) +); + +-- Tenants +CREATE TABLE tenants ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL UNIQUE, + schema_id UUID NOT NULL REFERENCES schemas(id), + schema_version INT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE tenant_field_locks ( + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + field_path TEXT NOT NULL, + locked_values JSONB, + PRIMARY KEY (tenant_id, field_path) +); + +-- Config versions +CREATE TABLE config_versions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + version INT NOT NULL, + description TEXT, + created_by TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE(tenant_id, version) +); + +-- Config values (delta storage — only changed fields per version) +CREATE TABLE config_values ( + config_version_id UUID NOT NULL REFERENCES config_versions(id) ON DELETE CASCADE, + field_path TEXT NOT NULL, + value TEXT, + checksum TEXT, + description TEXT, + PRIMARY KEY (config_version_id, field_path) +); + +-- Audit: write events +CREATE TABLE audit_write_log ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + actor TEXT NOT NULL, + action TEXT NOT NULL, + field_path TEXT, + old_value TEXT, + new_value TEXT, + config_version INT, + metadata JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_audit_write_log_tenant ON audit_write_log(tenant_id, created_at); +CREATE INDEX idx_audit_write_log_actor ON audit_write_log(actor, created_at); + +-- Audit: read usage aggregation +CREATE TABLE usage_stats ( + tenant_id UUID NOT NULL, + field_path TEXT NOT NULL, + period_start TIMESTAMPTZ NOT NULL, + read_count BIGINT NOT NULL DEFAULT 0, + last_read_by TEXT, + last_read_at TIMESTAMPTZ, + PRIMARY KEY (tenant_id, field_path, period_start) +); + diff --git a/multi-tenant/tenant1.config.seed.yaml b/multi-tenant/tenant1.config.seed.yaml new file mode 100644 index 0000000..09db764 --- /dev/null +++ b/multi-tenant/tenant1.config.seed.yaml @@ -0,0 +1,18 @@ +spec_version: v1 +tenant: + name: tenant1 + schema: saas-ecommerce + # schema_version omitted — binds to whatever version is currently published +config: + description: Config for Tenant 1 (US store) + values: + checkout.currency: + value: "USD" + checkout.tax_rate: + value: 0.08 + notifications.email_enabled: + value: true + notifications.webhook_url: + value: "https://hooks.tenant1.example.com/orders" + pricing.free_tier_limit: + value: 100 diff --git a/multi-tenant/tenant2.config.seed.yaml b/multi-tenant/tenant2.config.seed.yaml new file mode 100644 index 0000000..9ac2f00 --- /dev/null +++ b/multi-tenant/tenant2.config.seed.yaml @@ -0,0 +1,18 @@ +spec_version: v1 +tenant: + name: tenant2 + schema: saas-ecommerce + # schema_version omitted — binds to whatever version is currently published +config: + description: Config for Tenant 2 (EU store) + values: + checkout.currency: + value: "EUR" + checkout.tax_rate: + value: 0.20 + notifications.email_enabled: + value: true + notifications.webhook_url: + value: "https://hooks.tenant2.example.com/orders" + pricing.free_tier_limit: + value: 50 diff --git a/multi-tenant/test.sh b/multi-tenant/test.sh new file mode 100755 index 0000000..2454fef --- /dev/null +++ b/multi-tenant/test.sh @@ -0,0 +1,46 @@ +#!/bin/bash +# Validate the multi-tenant demo: start, verify both tenants, check isolation, tear down. +set -euo pipefail + +COMPOSE="docker compose" +SERVER="localhost:8080" + +echo "=== Starting demo ===" +./run.sh + +echo "" +echo "=== Verifying tenant1 config ===" +T1=$(curl -sf "http://${SERVER}/v1/config/tenant1/snapshot") +echo "tenant1 snapshot: $T1" +echo "$T1" | grep -q "checkout.currency" || { echo "FAIL: tenant1 missing checkout.currency"; exit 1; } +echo "$T1" | grep -q "USD" || { echo "FAIL: tenant1 currency should be USD"; exit 1; } + +echo "" +echo "=== Verifying tenant2 config ===" +T2=$(curl -sf "http://${SERVER}/v1/config/tenant2/snapshot") +echo "tenant2 snapshot: $T2" +echo "$T2" | grep -q "checkout.currency" || { echo "FAIL: tenant2 missing checkout.currency"; exit 1; } +echo "$T2" | grep -q "EUR" || { echo "FAIL: tenant2 currency should be EUR"; exit 1; } + +echo "" +echo "=== Verifying tenant isolation (different tax rates) ===" +T1_TAX=$(curl -sf "http://${SERVER}/v1/config/tenant1/values/checkout.tax_rate") +T2_TAX=$(curl -sf "http://${SERVER}/v1/config/tenant2/values/checkout.tax_rate") +echo "tenant1 tax_rate: $T1_TAX" +echo "tenant2 tax_rate: $T2_TAX" +# tenant1 was updated to 0.10 by run.sh; tenant2 must still be 0.20 +echo "$T1_TAX" | grep -q "0.10" || { echo "FAIL: tenant1 tax_rate should be 0.10 after override"; exit 1; } +echo "$T2_TAX" | grep -q "0.20" || { echo "FAIL: tenant2 tax_rate should be 0.20 (unchanged)"; exit 1; } + +echo "" +echo "=== Verifying idempotency ===" +$COMPOSE run --rm seed-schema +$COMPOSE run --rm seed-tenant1 +$COMPOSE run --rm seed-tenant2 +echo "Re-seed completed without error." + +echo "" +echo "=== All checks passed ===" + +echo "=== Tearing down ===" +$COMPOSE down -v