Merge pull request #15 from rede5/codex/refactor-and-implement-baas-control-plane-service
Refactor baas-control-plane into modular control plane with provider abstractions
This commit is contained in:
commit
a6d1b24c01
50 changed files with 1303 additions and 1895 deletions
|
|
@ -1,18 +1,5 @@
|
|||
NODE_ENV=development
|
||||
BACKEND_PORT=4000
|
||||
ADMIN_API_TOKEN=change-me
|
||||
DATA_DIR=./data
|
||||
|
||||
# Appwrite admin credentials (to create projects)
|
||||
APPWRITE_ADMIN_ENDPOINT=https://cloud.appwrite.io/v1
|
||||
APPWRITE_ADMIN_PROJECT_ID=console
|
||||
APPWRITE_ADMIN_API_KEY=
|
||||
|
||||
# Defaults for new tenant projects
|
||||
DEFAULT_APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1
|
||||
APPWRITE_DEFAULT_RUNTIME=deno-1.35
|
||||
|
||||
# Optional setup script target
|
||||
APPWRITE_SETUP_ENDPOINT=
|
||||
APPWRITE_SETUP_PROJECT_ID=
|
||||
APPWRITE_SETUP_API_KEY=
|
||||
PORT=4000
|
||||
APPWRITE_ENDPOINT=https://cloud.appwrite.io
|
||||
APPWRITE_API_KEY=replace-with-appwrite-key
|
||||
SUPABASE_ENDPOINT=https://api.supabase.com
|
||||
SUPABASE_SERVICE_KEY=replace-with-supabase-key
|
||||
|
|
|
|||
56
baas-control-plane/README.md
Normal file
56
baas-control-plane/README.md
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
# baas-control-plane
|
||||
|
||||
Control plane multi-tenant para orquestrar provedores BaaS (Appwrite, Supabase) com foco em provisioning, schema, secrets, métricas e auditoria.
|
||||
|
||||
## Visão geral
|
||||
- Backend Node.js + TypeScript com Fastify
|
||||
- Multi-tenant com isolamento lógico por tenant
|
||||
- Providers plugáveis sem lógica de negócio
|
||||
- Serviços centrais para provisioning, schema, secrets, finops e audit
|
||||
|
||||
## Arquitetura
|
||||
```
|
||||
/src
|
||||
/core
|
||||
/providers
|
||||
/modules
|
||||
/lib
|
||||
main.ts
|
||||
```
|
||||
|
||||
Detalhes adicionais em [docs/architecture.md](docs/architecture.md).
|
||||
|
||||
## Fluxo multi-tenant
|
||||
1. Criar tenant (`POST /tenants`)
|
||||
2. Criar projeto para o tenant (`POST /tenants/:id/projects`)
|
||||
3. Provisionar projeto no provider (`POST /projects/:id/provision`)
|
||||
4. Sincronizar schema (`POST /projects/:id/schema/sync`)
|
||||
5. Coletar métricas (`GET /projects/:id/metrics`)
|
||||
|
||||
## Como adicionar um novo provider
|
||||
1. Criar pasta em `src/providers/<provider>`
|
||||
2. Implementar `client`, `provisioning`, `schema`, `metrics`
|
||||
3. Registrar no `provider.factory.ts`
|
||||
4. Adicionar variáveis em `.env.example` e no `SecretsService`
|
||||
|
||||
## Como subir localmente
|
||||
```bash
|
||||
cp .env.example .env
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Docker
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
## API mínima
|
||||
- `POST /tenants`
|
||||
- `GET /tenants`
|
||||
- `POST /tenants/:id/projects`
|
||||
- `GET /tenants/:id/projects`
|
||||
- `POST /projects/:id/provision`
|
||||
- `POST /projects/:id/schema/sync`
|
||||
- `GET /projects/:id/metrics`
|
||||
- `GET /health`
|
||||
17
baas-control-plane/docs/architecture.md
Normal file
17
baas-control-plane/docs/architecture.md
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
# Arquitetura
|
||||
|
||||
O `baas-control-plane` implementa um control plane modular para gerenciar múltiplos provedores BaaS de forma multi-tenant. Ele centraliza provisioning, schema, secrets, métricas e auditoria sem executar workloads de clientes.
|
||||
|
||||
## Camadas
|
||||
- **core**: tipos e interface dos providers.
|
||||
- **providers**: implementações técnicas de Appwrite e Supabase.
|
||||
- **modules**: serviços de negócio (tenants, projects, provisioning, schema, secrets, finops, audit).
|
||||
- **lib**: utilitários de ambiente, logger e HTTP.
|
||||
|
||||
## Fluxo básico
|
||||
1. Tenant é criado e armazenado.
|
||||
2. Projeto é criado e vinculado a um provider.
|
||||
3. Provisioning aciona o provider e salva o `externalId`.
|
||||
4. Schema é versionado e aplicado via provider.
|
||||
5. FinOps coleta métricas normalizadas.
|
||||
6. Auditoria registra eventos relevantes.
|
||||
21
baas-control-plane/docs/providers.md
Normal file
21
baas-control-plane/docs/providers.md
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
# Providers
|
||||
|
||||
Os providers implementam apenas comandos técnicos e não contêm regras de negócio.
|
||||
|
||||
## Interface obrigatória
|
||||
- `createProject`
|
||||
- `deleteProject`
|
||||
- `applySchema`
|
||||
- `collectMetrics`
|
||||
- `rotateSecrets`
|
||||
- `healthCheck`
|
||||
|
||||
## Implementações iniciais
|
||||
- Appwrite: `src/providers/appwrite`
|
||||
- Supabase: `src/providers/supabase`
|
||||
|
||||
## Extensão
|
||||
1. Crie `src/providers/<provider>`
|
||||
2. Implemente `ProviderInterface`
|
||||
3. Registre no `provider.factory.ts`
|
||||
4. Configure secrets no `SecretsService`
|
||||
11
baas-control-plane/docs/security.md
Normal file
11
baas-control-plane/docs/security.md
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
# Segurança
|
||||
|
||||
## Princípios
|
||||
- Providers não acessam `.env` diretamente.
|
||||
- Secrets são entregues via `SecretsService`.
|
||||
- Preparado para integração com Vault/Infisical.
|
||||
|
||||
## Boas práticas
|
||||
- Não faça hardcode de credenciais.
|
||||
- Rotacione secrets via `rotateSecrets`.
|
||||
- Audite eventos críticos (tenant, projeto, schema, secrets).
|
||||
1239
baas-control-plane/package-lock.json
generated
1239
baas-control-plane/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,24 +1,20 @@
|
|||
{
|
||||
"name": "core-backend",
|
||||
"name": "baas-control-plane",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/main.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/main.js",
|
||||
"setup:appwrite": "tsx src/scripts/setup-appwrite.ts"
|
||||
"start": "node dist/main.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"cors": "^2.8.5",
|
||||
"@fastify/cors": "^9.0.1",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.19.2",
|
||||
"node-appwrite": "^14.1.0",
|
||||
"fastify": "^4.27.0",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^20.12.12",
|
||||
"tsx": "^4.15.7",
|
||||
"typescript": "^5.4.5"
|
||||
|
|
|
|||
|
|
@ -1,11 +0,0 @@
|
|||
{
|
||||
"provider": "appwrite-cloud",
|
||||
"description": "Appwrite Cloud configuration for DevOps orchestration platform",
|
||||
"endpoint": "${APPWRITE_ADMIN_ENDPOINT}",
|
||||
"projectId": "${APPWRITE_ADMIN_PROJECT_ID}",
|
||||
"apiKey": "${APPWRITE_ADMIN_API_KEY}",
|
||||
"functions": {
|
||||
"defaultRuntime": "deno-1.35",
|
||||
"source": "../../appwrite-functions"
|
||||
}
|
||||
}
|
||||
19
baas-control-plane/src/core/provider.factory.ts
Normal file
19
baas-control-plane/src/core/provider.factory.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { ProviderInterface } from './provider.interface.js';
|
||||
import { ProviderType } from './types.js';
|
||||
import { AppwriteProvider } from '../providers/appwrite/appwrite.provisioning.js';
|
||||
import { SupabaseProvider } from '../providers/supabase/supabase.provisioning.js';
|
||||
|
||||
const providerRegistry: Record<ProviderType, () => ProviderInterface> = {
|
||||
appwrite: () => new AppwriteProvider(),
|
||||
supabase: () => new SupabaseProvider(),
|
||||
};
|
||||
|
||||
export const providerFactory = {
|
||||
create(type: ProviderType): ProviderInterface {
|
||||
const providerBuilder = providerRegistry[type];
|
||||
if (!providerBuilder) {
|
||||
throw new Error(`Provider ${type} is not registered`);
|
||||
}
|
||||
return providerBuilder();
|
||||
},
|
||||
};
|
||||
10
baas-control-plane/src/core/provider.interface.ts
Normal file
10
baas-control-plane/src/core/provider.interface.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { ProviderMetrics, ProviderProject, ProviderSecrets, SchemaDefinition } from './types.js';
|
||||
|
||||
export interface ProviderInterface {
|
||||
createProject(name: string, secrets: ProviderSecrets): Promise<ProviderProject>;
|
||||
deleteProject(externalId: string, secrets: ProviderSecrets): Promise<void>;
|
||||
applySchema(externalId: string, schema: SchemaDefinition, secrets: ProviderSecrets): Promise<void>;
|
||||
collectMetrics(externalId: string, secrets: ProviderSecrets): Promise<ProviderMetrics>;
|
||||
rotateSecrets(externalId: string, secrets: ProviderSecrets): Promise<ProviderSecrets>;
|
||||
healthCheck(secrets: ProviderSecrets): Promise<boolean>;
|
||||
}
|
||||
59
baas-control-plane/src/core/types.ts
Normal file
59
baas-control-plane/src/core/types.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
export type ProviderType = 'appwrite' | 'supabase';
|
||||
|
||||
export type TenantStatus = 'active' | 'suspended';
|
||||
|
||||
export type ProjectStatus = 'draft' | 'provisioning' | 'provisioned' | 'failed';
|
||||
|
||||
export interface Tenant {
|
||||
id: string;
|
||||
name: string;
|
||||
plan: string;
|
||||
status: TenantStatus;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
name: string;
|
||||
provider: ProviderType;
|
||||
status: ProjectStatus;
|
||||
externalId?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface SchemaDefinition {
|
||||
version: string;
|
||||
payload: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ProviderProject {
|
||||
externalId: string;
|
||||
dashboardUrl?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ProviderMetrics {
|
||||
users: number;
|
||||
storageMb: number;
|
||||
requests: number;
|
||||
functions: number;
|
||||
capturedAt: string;
|
||||
}
|
||||
|
||||
export interface ProviderSecrets {
|
||||
endpoint: string;
|
||||
apiKey: string;
|
||||
projectRef?: string;
|
||||
}
|
||||
|
||||
export interface AuditEvent {
|
||||
id: string;
|
||||
tenantId?: string;
|
||||
projectId?: string;
|
||||
action: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
}
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
# Backend multi-tenant Appwrite
|
||||
|
||||
Este backend é a camada administrativa para gerenciar múltiplos projetos Appwrite.
|
||||
|
||||
## O que ele faz
|
||||
|
||||
- CRUD de tenants e projetos Appwrite (multi-tenant).
|
||||
- Setup e sincronização automática de schema (database, collections, buckets e functions).
|
||||
- Base para FinOps (métricas de uso) e automações DevOps.
|
||||
|
||||
## Rodando localmente
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
npm install
|
||||
cp .env.example .env
|
||||
npm run dev
|
||||
```
|
||||
|
||||
A API inicia em `http://localhost:4000`.
|
||||
|
||||
## Endpoints mínimos
|
||||
|
||||
- `POST /tenants`
|
||||
- `GET /tenants`
|
||||
- `POST /tenants/:id/appwrite-project`
|
||||
- `GET /tenants/:id/appwrite-projects`
|
||||
- `POST /appwrite/setup`
|
||||
- `POST /appwrite/sync-schema`
|
||||
- `GET /health`
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
# Segurança e Permissões do Appwrite
|
||||
|
||||
As coleções do projeto seguem uma política de Row-Level Security (RLS) para proteger os dados dos usuários e os registros de auditoria.
|
||||
|
||||
## Princípios gerais
|
||||
- Cada documento pertence a um usuário autenticado, identificado pelo `userId` armazenado no documento.
|
||||
- Operações de leitura e gravação são restritas ao proprietário do documento (role `member`).
|
||||
- Logs de auditoria globais só podem ser consultados por usuários com a role `admin`.
|
||||
|
||||
## Regras por coleção
|
||||
|
||||
### `cloud_accounts`
|
||||
- **Leitura**: apenas o usuário que criou o documento (role `member`).
|
||||
- **Criação**: apenas usuários autenticados.
|
||||
- **Atualização/Exclusão**: somente o proprietário do documento.
|
||||
|
||||
### `projects`
|
||||
- **Leitura**: restrita ao proprietário do documento.
|
||||
- **Criação**: usuários autenticados podem criar seus próprios registros.
|
||||
- **Atualização/Exclusão**: apenas o proprietário do documento.
|
||||
|
||||
### `audit_logs`
|
||||
- **Leitura**: exclusiva para usuários com role `admin` (para auditoria global).
|
||||
- **Criação**: serviços e funções podem registrar ações em nome dos usuários; cada entrada mantém o `userId` responsável.
|
||||
- **Atualização/Exclusão**: não permitidas para usuários finais; apenas processos administrativos podem gerenciar retenção.
|
||||
|
||||
## Considerações adicionais
|
||||
- Tokens e chaves sensíveis (GitHub, Cloudflare) devem ser armazenados em `cloud_accounts` e nunca enviados ao frontend.
|
||||
- Funções Cloud (como as de proxy) devem validar o `userId` associado ao documento antes de usar qualquer chave.
|
||||
- Revogue ou rotacione chaves comprometidas removendo ou atualizando o documento correspondente na coleção.
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
# Guia rápido - Setup Appwrite (Multi-tenant)
|
||||
|
||||
O script `setup-appwrite.ts` aplica o schema base (database, collections, buckets e functions)
|
||||
para um projeto Appwrite específico.
|
||||
|
||||
## Variáveis necessárias
|
||||
|
||||
Edite `backend/.env` e configure:
|
||||
|
||||
```env
|
||||
APPWRITE_SETUP_ENDPOINT=https://cloud.appwrite.io/v1
|
||||
APPWRITE_SETUP_PROJECT_ID=<project_id>
|
||||
APPWRITE_SETUP_API_KEY=<api_key>
|
||||
```
|
||||
|
||||
## Executar
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
npm run setup:appwrite
|
||||
```
|
||||
|
||||
O script é idempotente (pode rodar várias vezes sem duplicar recursos).
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
# Appwrite Database Schema (Base Multi-tenant)
|
||||
|
||||
Schema base aplicado pelo backend para novos projetos Appwrite.
|
||||
|
||||
## Database
|
||||
|
||||
- **ID**: `core-platform`
|
||||
- **Nome**: Core Platform
|
||||
|
||||
## Collections
|
||||
|
||||
### tenants
|
||||
- `name` (string)
|
||||
- `slug` (string)
|
||||
- `status` (enum: active | inactive)
|
||||
- `createdAt` (datetime)
|
||||
|
||||
### projects
|
||||
- `tenantId` (string)
|
||||
- `name` (string)
|
||||
- `endpoint` (string)
|
||||
- `projectId` (string)
|
||||
- `createdAt` (datetime)
|
||||
|
||||
### finops_usage
|
||||
- `tenantId` (string)
|
||||
- `users` (integer)
|
||||
- `documents` (integer)
|
||||
- `storageBytes` (integer)
|
||||
- `functionRuns` (integer)
|
||||
- `capturedAt` (datetime)
|
||||
|
||||
### audit_logs
|
||||
- `event` (string)
|
||||
- `actor` (string)
|
||||
- `timestamp` (datetime)
|
||||
|
||||
## Buckets
|
||||
|
||||
- `tenant-assets` (storage base por tenant)
|
||||
|
||||
## Functions
|
||||
|
||||
- `hello-world`
|
||||
- `sync-github`
|
||||
- `check-cloudflare-status`
|
||||
|
|
@ -1,184 +0,0 @@
|
|||
import {
|
||||
Client,
|
||||
Databases,
|
||||
Functions,
|
||||
Storage,
|
||||
ID,
|
||||
Runtime,
|
||||
} from 'node-appwrite';
|
||||
import { appwriteSchema } from '../modules/projects/appwriteSchema.js';
|
||||
import { logger } from './logger.js';
|
||||
|
||||
export type AppwriteConnection = {
|
||||
endpoint: string;
|
||||
projectId: string;
|
||||
apiKey: string;
|
||||
};
|
||||
|
||||
export type ApplySchemaResult = {
|
||||
databaseId: string;
|
||||
collections: string[];
|
||||
buckets: string[];
|
||||
functions: string[];
|
||||
};
|
||||
|
||||
export const createAppwriteClient = ({ endpoint, projectId, apiKey }: AppwriteConnection) =>
|
||||
new Client().setEndpoint(endpoint).setProject(projectId).setKey(apiKey);
|
||||
|
||||
const isConflict = (error: unknown) =>
|
||||
typeof error === 'object' && error !== null && 'code' in error && (error as { code?: number }).code === 409;
|
||||
|
||||
export const applySchema = async (
|
||||
connection: AppwriteConnection,
|
||||
options?: { logPrefix?: string }
|
||||
): Promise<ApplySchemaResult> => {
|
||||
const client = createAppwriteClient(connection);
|
||||
const databases = new Databases(client);
|
||||
const storage = new Storage(client);
|
||||
const functions = new Functions(client);
|
||||
const logPrefix = options?.logPrefix ?? 'appwrite';
|
||||
|
||||
logger.info(`[${logPrefix}] Applying Appwrite schema`, {
|
||||
endpoint: connection.endpoint,
|
||||
projectId: connection.projectId,
|
||||
});
|
||||
|
||||
const { database } = appwriteSchema;
|
||||
|
||||
try {
|
||||
await databases.create(database.id, database.name, database.enabled);
|
||||
logger.info(`[${logPrefix}] Database created`, { databaseId: database.id });
|
||||
} catch (error) {
|
||||
if (!isConflict(error)) {
|
||||
throw error;
|
||||
}
|
||||
logger.info(`[${logPrefix}] Database already exists`, { databaseId: database.id });
|
||||
}
|
||||
|
||||
for (const collection of appwriteSchema.collections) {
|
||||
try {
|
||||
await databases.createCollection(database.id, collection.id, collection.name, collection.permissions);
|
||||
logger.info(`[${logPrefix}] Collection created`, { collectionId: collection.id });
|
||||
} catch (error) {
|
||||
if (!isConflict(error)) {
|
||||
throw error;
|
||||
}
|
||||
logger.info(`[${logPrefix}] Collection already exists`, { collectionId: collection.id });
|
||||
}
|
||||
|
||||
for (const attribute of collection.attributes) {
|
||||
try {
|
||||
switch (attribute.type) {
|
||||
case 'string':
|
||||
await databases.createStringAttribute(
|
||||
database.id,
|
||||
collection.id,
|
||||
attribute.key,
|
||||
attribute.size,
|
||||
attribute.required,
|
||||
attribute.array ?? false
|
||||
);
|
||||
break;
|
||||
case 'enum':
|
||||
await databases.createEnumAttribute(
|
||||
database.id,
|
||||
collection.id,
|
||||
attribute.key,
|
||||
attribute.elements,
|
||||
attribute.required
|
||||
);
|
||||
break;
|
||||
case 'integer':
|
||||
await databases.createIntegerAttribute(
|
||||
database.id,
|
||||
collection.id,
|
||||
attribute.key,
|
||||
attribute.required
|
||||
);
|
||||
break;
|
||||
case 'datetime':
|
||||
await databases.createDatetimeAttribute(
|
||||
database.id,
|
||||
collection.id,
|
||||
attribute.key,
|
||||
attribute.required
|
||||
);
|
||||
break;
|
||||
case 'boolean':
|
||||
await databases.createBooleanAttribute(
|
||||
database.id,
|
||||
collection.id,
|
||||
attribute.key,
|
||||
attribute.required
|
||||
);
|
||||
break;
|
||||
case 'url':
|
||||
await databases.createUrlAttribute(
|
||||
database.id,
|
||||
collection.id,
|
||||
attribute.key,
|
||||
attribute.required
|
||||
);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
logger.info(`[${logPrefix}] Attribute ensured`, {
|
||||
collectionId: collection.id,
|
||||
attribute: attribute.key,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isConflict(error)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const bucket of appwriteSchema.buckets) {
|
||||
try {
|
||||
await storage.createBucket(bucket.id, bucket.name, bucket.permissions, bucket.fileSecurity ?? false);
|
||||
logger.info(`[${logPrefix}] Bucket created`, { bucketId: bucket.id });
|
||||
} catch (error) {
|
||||
if (!isConflict(error)) {
|
||||
throw error;
|
||||
}
|
||||
logger.info(`[${logPrefix}] Bucket already exists`, { bucketId: bucket.id });
|
||||
}
|
||||
}
|
||||
|
||||
for (const func of appwriteSchema.functions) {
|
||||
try {
|
||||
await functions.get(func.id);
|
||||
logger.info(`[${logPrefix}] Function already exists`, { functionId: func.id });
|
||||
} catch (error) {
|
||||
const code = typeof error === 'object' && error !== null && 'code' in error ? (error as { code?: number }).code : null;
|
||||
if (code !== 404 && code !== 409) {
|
||||
throw error;
|
||||
}
|
||||
await functions.create(func.id, func.name, func.runtime as Runtime, func.execute);
|
||||
logger.info(`[${logPrefix}] Function created`, { functionId: func.id });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
databaseId: database.id,
|
||||
collections: appwriteSchema.collections.map((collection) => collection.id),
|
||||
buckets: appwriteSchema.buckets.map((bucket) => bucket.id),
|
||||
functions: appwriteSchema.functions.map((func) => func.id),
|
||||
};
|
||||
};
|
||||
|
||||
export const createProject = async (
|
||||
connection: AppwriteConnection,
|
||||
projectName: string
|
||||
): Promise<{ projectId: string }> => {
|
||||
const client = createAppwriteClient(connection);
|
||||
const { Projects } = await import('node-appwrite');
|
||||
const projects = new Projects(client);
|
||||
const projectId = ID.unique();
|
||||
|
||||
await projects.create(projectId, projectName);
|
||||
|
||||
return { projectId };
|
||||
};
|
||||
|
|
@ -1,30 +1,22 @@
|
|||
import dotenv from 'dotenv';
|
||||
import path from 'path';
|
||||
import { z } from 'zod';
|
||||
|
||||
const envFile = process.env.ENV_FILE || path.resolve(process.cwd(), '.env');
|
||||
dotenv.config();
|
||||
|
||||
dotenv.config({ path: envFile });
|
||||
const envSchema = z.object({
|
||||
PORT: z.string().default('4000'),
|
||||
APPWRITE_ENDPOINT: z.string().default('https://cloud.appwrite.io'),
|
||||
APPWRITE_API_KEY: z.string().default('appwrite-api-key'),
|
||||
SUPABASE_ENDPOINT: z.string().default('https://api.supabase.com'),
|
||||
SUPABASE_SERVICE_KEY: z.string().default('supabase-service-key'),
|
||||
});
|
||||
|
||||
const numberFromEnv = (value: string | undefined, fallback: number) => {
|
||||
if (!value) {
|
||||
return fallback;
|
||||
}
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isNaN(parsed) ? fallback : parsed;
|
||||
};
|
||||
const parsed = envSchema.parse(process.env);
|
||||
|
||||
export const env = {
|
||||
nodeEnv: process.env.NODE_ENV ?? 'development',
|
||||
port: numberFromEnv(process.env.BACKEND_PORT, 4000),
|
||||
adminToken: process.env.ADMIN_API_TOKEN ?? '',
|
||||
dataDir: process.env.DATA_DIR ?? 'data',
|
||||
appwriteAdmin: {
|
||||
endpoint: process.env.APPWRITE_ADMIN_ENDPOINT ?? '',
|
||||
projectId: process.env.APPWRITE_ADMIN_PROJECT_ID ?? '',
|
||||
apiKey: process.env.APPWRITE_ADMIN_API_KEY ?? '',
|
||||
},
|
||||
defaults: {
|
||||
appwriteEndpoint: process.env.DEFAULT_APPWRITE_ENDPOINT ?? process.env.APPWRITE_DEFAULT_ENDPOINT ?? '',
|
||||
functionRuntime: process.env.APPWRITE_DEFAULT_RUNTIME ?? 'deno-1.35',
|
||||
},
|
||||
port: Number(parsed.PORT),
|
||||
appwriteEndpoint: parsed.APPWRITE_ENDPOINT,
|
||||
appwriteApiKey: parsed.APPWRITE_API_KEY,
|
||||
supabaseEndpoint: parsed.SUPABASE_ENDPOINT,
|
||||
supabaseServiceKey: parsed.SUPABASE_SERVICE_KEY,
|
||||
};
|
||||
|
|
|
|||
24
baas-control-plane/src/lib/http.ts
Normal file
24
baas-control-plane/src/lib/http.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
export const http = {
|
||||
async get<T>(url: string, options?: RequestInit): Promise<T> {
|
||||
const response = await fetch(url, { ...options, method: 'GET' });
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP GET failed with status ${response.status}`);
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
},
|
||||
async post<T>(url: string, body?: unknown, options?: RequestInit): Promise<T> {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(options?.headers ?? {}),
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP POST failed with status ${response.status}`);
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
},
|
||||
};
|
||||
|
|
@ -1,15 +1,34 @@
|
|||
export type LogMeta = Record<string, unknown> | undefined;
|
||||
type LogPayload = Record<string, unknown>;
|
||||
|
||||
const formatMeta = (meta?: LogMeta) => (meta ? ` ${JSON.stringify(meta)}` : '');
|
||||
const log = (level: 'info' | 'error' | 'warn', message: string, payload?: LogPayload) => {
|
||||
const entry = {
|
||||
level,
|
||||
message,
|
||||
timestamp: new Date().toISOString(),
|
||||
...payload,
|
||||
};
|
||||
|
||||
if (level === 'error') {
|
||||
console.error(entry);
|
||||
return;
|
||||
}
|
||||
|
||||
if (level === 'warn') {
|
||||
console.warn(entry);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(entry);
|
||||
};
|
||||
|
||||
export const logger = {
|
||||
info(message: string, meta?: LogMeta) {
|
||||
console.log(`[INFO] ${message}${formatMeta(meta)}`);
|
||||
info(message: string, payload?: LogPayload) {
|
||||
log('info', message, payload);
|
||||
},
|
||||
warn(message: string, meta?: LogMeta) {
|
||||
console.warn(`[WARN] ${message}${formatMeta(meta)}`);
|
||||
warn(message: string, payload?: LogPayload) {
|
||||
log('warn', message, payload);
|
||||
},
|
||||
error(message: string, meta?: LogMeta) {
|
||||
console.error(`[ERROR] ${message}${formatMeta(meta)}`);
|
||||
error(message: string, payload?: LogPayload) {
|
||||
log('error', message, payload);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
29
baas-control-plane/src/lib/storage.ts
Normal file
29
baas-control-plane/src/lib/storage.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const dataDir = path.resolve('data');
|
||||
|
||||
const ensureDir = async () => {
|
||||
await fs.mkdir(dataDir, { recursive: true });
|
||||
};
|
||||
|
||||
const filePath = (file: string) => path.join(dataDir, file);
|
||||
|
||||
export const storage = {
|
||||
async readCollection<T>(file: string): Promise<T[]> {
|
||||
await ensureDir();
|
||||
try {
|
||||
const content = await fs.readFile(filePath(file), 'utf-8');
|
||||
return JSON.parse(content) as T[];
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
async writeCollection<T>(file: string, data: T[]): Promise<void> {
|
||||
await ensureDir();
|
||||
await fs.writeFile(filePath(file), JSON.stringify(data, null, 2));
|
||||
},
|
||||
};
|
||||
|
|
@ -1,34 +1,57 @@
|
|||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import Fastify from 'fastify';
|
||||
import cors from '@fastify/cors';
|
||||
import { env } from './lib/env.js';
|
||||
import { logger } from './lib/logger.js';
|
||||
import { requireAdminToken } from './modules/auth/auth.middleware.js';
|
||||
import { tenantsRouter } from './modules/tenants/tenants.routes.js';
|
||||
import { appwriteRouter } from './modules/projects/appwrite.routes.js';
|
||||
import { TenantsService } from './modules/tenants/tenants.service.js';
|
||||
import { ProjectsService } from './modules/projects/projects.service.js';
|
||||
import { ProvisioningService } from './modules/provisioning/provisioning.service.js';
|
||||
import { SchemaService } from './modules/schema/schema.service.js';
|
||||
import { SchemaVersioning } from './modules/schema/schema.versioning.js';
|
||||
import { SecretsService } from './modules/secrets/secrets.service.js';
|
||||
import { AuditService } from './modules/audit/audit.service.js';
|
||||
import { FinopsCollector } from './modules/finops/finops.collector.js';
|
||||
import { registerTenantsController } from './modules/tenants/tenants.controller.js';
|
||||
import { registerProjectsController } from './modules/projects/projects.controller.js';
|
||||
import { providerFactory } from './core/provider.factory.js';
|
||||
|
||||
const app = express();
|
||||
const app = Fastify({ logger: false });
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
await app.register(cors, { origin: true });
|
||||
|
||||
app.get('/health', (_request, response) => {
|
||||
response.json({ status: 'ok' });
|
||||
const tenantsService = new TenantsService();
|
||||
const projectsService = new ProjectsService();
|
||||
const secretsService = new SecretsService();
|
||||
const auditService = new AuditService();
|
||||
const provisioningService = new ProvisioningService(projectsService, secretsService);
|
||||
const schemaService = new SchemaService(projectsService, secretsService, new SchemaVersioning());
|
||||
const finopsCollector = new FinopsCollector(projectsService, secretsService);
|
||||
|
||||
app.get('/health', async () => {
|
||||
const appwrite = providerFactory.create('appwrite');
|
||||
const supabase = providerFactory.create('supabase');
|
||||
|
||||
const [appwriteHealthy, supabaseHealthy] = await Promise.all([
|
||||
appwrite.healthCheck(await secretsService.getProviderSecrets('appwrite', 'system')),
|
||||
supabase.healthCheck(await secretsService.getProviderSecrets('supabase', 'system')),
|
||||
]);
|
||||
|
||||
return {
|
||||
status: 'ok',
|
||||
providers: {
|
||||
appwrite: appwriteHealthy,
|
||||
supabase: supabaseHealthy,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
app.use(requireAdminToken);
|
||||
registerTenantsController(app, tenantsService, auditService);
|
||||
registerProjectsController(app, projectsService, provisioningService, schemaService, auditService, finopsCollector);
|
||||
|
||||
app.use('/tenants', tenantsRouter);
|
||||
app.use('/appwrite', appwriteRouter);
|
||||
|
||||
app.use((error: Error, _request: express.Request, response: express.Response, _next: express.NextFunction) => {
|
||||
app.setErrorHandler((error, _request, reply) => {
|
||||
logger.error('Request failed', { message: error.message });
|
||||
|
||||
const status = error.message.includes('not found') ? 404 : 400;
|
||||
response.status(status).json({
|
||||
error: error.message,
|
||||
});
|
||||
reply.status(400).send({ error: error.message });
|
||||
});
|
||||
|
||||
app.listen(env.port, () => {
|
||||
logger.info(`Backend listening on http://localhost:${env.port}`);
|
||||
app.listen({ port: env.port, host: '0.0.0.0' }).then(() => {
|
||||
logger.info(`baas-control-plane listening on http://localhost:${env.port}`);
|
||||
});
|
||||
|
|
|
|||
18
baas-control-plane/src/modules/audit/audit.service.ts
Normal file
18
baas-control-plane/src/modules/audit/audit.service.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { storage } from '../../lib/storage.js';
|
||||
import { AuditEvent } from '../../core/types.js';
|
||||
|
||||
const AUDIT_FILE = 'audit-events.json';
|
||||
|
||||
export class AuditService {
|
||||
async record(event: Omit<AuditEvent, 'id' | 'createdAt'>): Promise<AuditEvent> {
|
||||
const events = await storage.readCollection<AuditEvent>(AUDIT_FILE);
|
||||
const entry: AuditEvent = {
|
||||
id: crypto.randomUUID(),
|
||||
createdAt: new Date().toISOString(),
|
||||
...event,
|
||||
};
|
||||
events.push(entry);
|
||||
await storage.writeCollection(AUDIT_FILE, events);
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
import type { NextFunction, Request, Response } from 'express';
|
||||
import { env } from '../../lib/env.js';
|
||||
|
||||
export const requireAdminToken = (request: Request, response: Response, next: NextFunction) => {
|
||||
if (!env.adminToken) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const header = request.headers.authorization;
|
||||
const fallbackToken = request.headers['x-admin-token'];
|
||||
const tokenValue = Array.isArray(fallbackToken) ? fallbackToken[0] : fallbackToken;
|
||||
const token = header?.startsWith('Bearer ') ? header.slice(7) : tokenValue;
|
||||
|
||||
if (token !== env.adminToken) {
|
||||
return response.status(401).json({
|
||||
error: 'Unauthorized',
|
||||
message: 'Invalid admin token',
|
||||
});
|
||||
}
|
||||
|
||||
return next();
|
||||
};
|
||||
22
baas-control-plane/src/modules/finops/finops.collector.ts
Normal file
22
baas-control-plane/src/modules/finops/finops.collector.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { providerFactory } from '../../core/provider.factory.js';
|
||||
import { ProjectsService } from '../projects/projects.service.js';
|
||||
import { SecretsService } from '../secrets/secrets.service.js';
|
||||
import { ProviderMetrics } from '../../core/types.js';
|
||||
|
||||
export class FinopsCollector {
|
||||
constructor(
|
||||
private readonly projectsService: ProjectsService,
|
||||
private readonly secretsService: SecretsService,
|
||||
) {}
|
||||
|
||||
async collectForProject(projectId: string): Promise<ProviderMetrics> {
|
||||
const project = await this.projectsService.getProject(projectId);
|
||||
if (!project || !project.externalId) {
|
||||
throw new Error('Project not provisioned');
|
||||
}
|
||||
|
||||
const provider = providerFactory.create(project.provider);
|
||||
const secrets = await this.secretsService.getProviderSecrets(project.provider, project.tenantId);
|
||||
return provider.collectMetrics(project.externalId, secrets);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
export type FinopsSnapshot = {
|
||||
users: number;
|
||||
documents: number;
|
||||
storageBytes: number;
|
||||
functionRuns: number;
|
||||
capturedAt: string;
|
||||
};
|
||||
|
||||
export type FinopsSummary = FinopsSnapshot & {
|
||||
lastSyncedAt?: string;
|
||||
};
|
||||
|
||||
export const createEmptyFinops = (): FinopsSummary => ({
|
||||
users: 0,
|
||||
documents: 0,
|
||||
storageBytes: 0,
|
||||
functionRuns: 0,
|
||||
capturedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
export const recordSnapshot = (summary: FinopsSummary, snapshot: FinopsSnapshot): FinopsSummary => ({
|
||||
...snapshot,
|
||||
lastSyncedAt: new Date().toISOString(),
|
||||
});
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
import { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { syncSchemaForProject } from './appwrite.service.js';
|
||||
|
||||
const schemaRequest = z.object({
|
||||
tenantId: z.string().min(1),
|
||||
projectRef: z.string().optional(),
|
||||
});
|
||||
|
||||
export const appwriteRouter = Router();
|
||||
|
||||
appwriteRouter.post('/setup', async (request, response, next) => {
|
||||
try {
|
||||
const payload = schemaRequest.parse(request.body);
|
||||
const result = await syncSchemaForProject(payload.tenantId, payload.projectRef);
|
||||
response.status(200).json({
|
||||
message: 'Appwrite setup applied',
|
||||
result,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
appwriteRouter.post('/sync-schema', async (request, response, next) => {
|
||||
try {
|
||||
const payload = schemaRequest.parse(request.body);
|
||||
const result = await syncSchemaForProject(payload.tenantId, payload.projectRef);
|
||||
response.status(200).json({
|
||||
message: 'Schema synchronized',
|
||||
result,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
import { applySchema } from '../../lib/appwrite.js';
|
||||
import { loadTenants, saveTenants } from '../tenants/tenants.store.js';
|
||||
import type { AppwriteProject } from '../tenants/tenants.types.js';
|
||||
import { logger } from '../../lib/logger.js';
|
||||
|
||||
export const resolveProject = async (tenantId: string, projectRef?: string): Promise<AppwriteProject> => {
|
||||
const tenants = await loadTenants();
|
||||
const tenant = tenants.find((item) => item.id === tenantId);
|
||||
|
||||
if (!tenant) {
|
||||
throw new Error('Tenant not found');
|
||||
}
|
||||
|
||||
if (!projectRef) {
|
||||
if (tenant.appwriteProjects.length === 1) {
|
||||
return tenant.appwriteProjects[0];
|
||||
}
|
||||
throw new Error('Multiple projects found. Provide projectRef.');
|
||||
}
|
||||
|
||||
const project = tenant.appwriteProjects.find(
|
||||
(item) => item.id === projectRef || item.projectId === projectRef
|
||||
);
|
||||
|
||||
if (!project) {
|
||||
throw new Error('Project not found');
|
||||
}
|
||||
|
||||
return project;
|
||||
};
|
||||
|
||||
export const syncSchemaForProject = async (tenantId: string, projectRef?: string) => {
|
||||
const project = await resolveProject(tenantId, projectRef);
|
||||
|
||||
const result = await applySchema({
|
||||
endpoint: project.endpoint,
|
||||
projectId: project.projectId,
|
||||
apiKey: project.apiKey,
|
||||
});
|
||||
|
||||
project.updatedAt = new Date().toISOString();
|
||||
|
||||
const tenants = await loadTenants();
|
||||
const tenant = tenants.find((item) => item.id === tenantId);
|
||||
if (tenant) {
|
||||
tenant.updatedAt = project.updatedAt;
|
||||
await saveTenants(tenants);
|
||||
}
|
||||
|
||||
logger.info('Schema synced for project', {
|
||||
tenantId,
|
||||
projectId: project.projectId,
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
|
@ -1,179 +0,0 @@
|
|||
import { Permission, Role, Runtime } from 'node-appwrite';
|
||||
import { env } from '../../lib/env.js';
|
||||
|
||||
export type AttributeDefinition =
|
||||
| {
|
||||
type: 'string';
|
||||
key: string;
|
||||
size: number;
|
||||
required: boolean;
|
||||
array?: boolean;
|
||||
}
|
||||
| {
|
||||
type: 'enum';
|
||||
key: string;
|
||||
elements: string[];
|
||||
required: boolean;
|
||||
}
|
||||
| {
|
||||
type: 'integer';
|
||||
key: string;
|
||||
required: boolean;
|
||||
}
|
||||
| {
|
||||
type: 'datetime';
|
||||
key: string;
|
||||
required: boolean;
|
||||
}
|
||||
| {
|
||||
type: 'boolean';
|
||||
key: string;
|
||||
required: boolean;
|
||||
}
|
||||
| {
|
||||
type: 'url';
|
||||
key: string;
|
||||
required: boolean;
|
||||
};
|
||||
|
||||
export type CollectionSchema = {
|
||||
id: string;
|
||||
name: string;
|
||||
permissions: string[];
|
||||
attributes: AttributeDefinition[];
|
||||
};
|
||||
|
||||
export type BucketSchema = {
|
||||
id: string;
|
||||
name: string;
|
||||
permissions: string[];
|
||||
fileSecurity?: boolean;
|
||||
};
|
||||
|
||||
export type FunctionSchema = {
|
||||
id: string;
|
||||
name: string;
|
||||
runtime: Runtime;
|
||||
execute: string[];
|
||||
};
|
||||
|
||||
export type AppwriteSchema = {
|
||||
database: {
|
||||
id: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
collections: CollectionSchema[];
|
||||
buckets: BucketSchema[];
|
||||
functions: FunctionSchema[];
|
||||
};
|
||||
|
||||
const runtime = (Object.values(Runtime) as string[]).includes(env.defaults.functionRuntime)
|
||||
? (env.defaults.functionRuntime as Runtime)
|
||||
: Runtime.Deno135;
|
||||
|
||||
export const appwriteSchema: AppwriteSchema = {
|
||||
database: {
|
||||
id: 'core-platform',
|
||||
name: 'Core Platform',
|
||||
enabled: true,
|
||||
},
|
||||
collections: [
|
||||
{
|
||||
id: 'tenants',
|
||||
name: 'Tenants',
|
||||
permissions: [
|
||||
Permission.read(Role.any()),
|
||||
Permission.create(Role.team('admins')),
|
||||
Permission.update(Role.team('admins')),
|
||||
Permission.delete(Role.team('admins')),
|
||||
],
|
||||
attributes: [
|
||||
{ type: 'string', key: 'name', size: 255, required: true },
|
||||
{ type: 'string', key: 'slug', size: 120, required: true },
|
||||
{ type: 'enum', key: 'status', elements: ['active', 'inactive'], required: true },
|
||||
{ type: 'datetime', key: 'createdAt', required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'projects',
|
||||
name: 'Projects',
|
||||
permissions: [
|
||||
Permission.read(Role.team('admins')),
|
||||
Permission.create(Role.team('admins')),
|
||||
Permission.update(Role.team('admins')),
|
||||
Permission.delete(Role.team('admins')),
|
||||
],
|
||||
attributes: [
|
||||
{ type: 'string', key: 'tenantId', size: 80, required: true },
|
||||
{ type: 'string', key: 'name', size: 255, required: true },
|
||||
{ type: 'string', key: 'endpoint', size: 255, required: true },
|
||||
{ type: 'string', key: 'projectId', size: 255, required: true },
|
||||
{ type: 'datetime', key: 'createdAt', required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'finops_usage',
|
||||
name: 'FinOps Usage',
|
||||
permissions: [
|
||||
Permission.read(Role.team('admins')),
|
||||
Permission.create(Role.team('admins')),
|
||||
Permission.update(Role.team('admins')),
|
||||
],
|
||||
attributes: [
|
||||
{ type: 'string', key: 'tenantId', size: 80, required: true },
|
||||
{ type: 'integer', key: 'users', required: true },
|
||||
{ type: 'integer', key: 'documents', required: true },
|
||||
{ type: 'integer', key: 'storageBytes', required: true },
|
||||
{ type: 'integer', key: 'functionRuns', required: true },
|
||||
{ type: 'datetime', key: 'capturedAt', required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'audit_logs',
|
||||
name: 'Audit Logs',
|
||||
permissions: [
|
||||
Permission.read(Role.team('admins')),
|
||||
Permission.create(Role.team('admins')),
|
||||
],
|
||||
attributes: [
|
||||
{ type: 'string', key: 'event', size: 500, required: true },
|
||||
{ type: 'string', key: 'actor', size: 255, required: true },
|
||||
{ type: 'datetime', key: 'timestamp', required: true },
|
||||
],
|
||||
},
|
||||
],
|
||||
buckets: [
|
||||
{
|
||||
id: 'tenant-assets',
|
||||
name: 'Tenant Assets',
|
||||
permissions: [
|
||||
Permission.read(Role.team('admins')),
|
||||
Permission.create(Role.team('admins')),
|
||||
Permission.update(Role.team('admins')),
|
||||
Permission.delete(Role.team('admins')),
|
||||
],
|
||||
fileSecurity: true,
|
||||
},
|
||||
],
|
||||
functions: [
|
||||
{
|
||||
id: 'hello-world',
|
||||
name: 'Hello World',
|
||||
runtime,
|
||||
execute: [Role.any()],
|
||||
},
|
||||
{
|
||||
id: 'sync-github',
|
||||
name: 'Sync GitHub',
|
||||
runtime,
|
||||
execute: [Role.team('admins')],
|
||||
},
|
||||
{
|
||||
id: 'check-cloudflare-status',
|
||||
name: 'Check Cloudflare Status',
|
||||
runtime,
|
||||
execute: [Role.team('admins')],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
import { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { ProjectsService } from './projects.service.js';
|
||||
import { ProvisioningService } from '../provisioning/provisioning.service.js';
|
||||
import { SchemaService } from '../schema/schema.service.js';
|
||||
import { AuditService } from '../audit/audit.service.js';
|
||||
import { FinopsCollector } from '../finops/finops.collector.js';
|
||||
|
||||
const projectSchema = z.object({
|
||||
name: z.string().min(2),
|
||||
provider: z.enum(['appwrite', 'supabase']),
|
||||
});
|
||||
|
||||
const schemaSyncPayload = z.object({
|
||||
version: z.string().min(1),
|
||||
payload: z.record(z.unknown()),
|
||||
});
|
||||
|
||||
export const registerProjectsController = (
|
||||
app: FastifyInstance,
|
||||
projectsService: ProjectsService,
|
||||
provisioningService: ProvisioningService,
|
||||
schemaService: SchemaService,
|
||||
auditService: AuditService,
|
||||
finopsCollector: FinopsCollector,
|
||||
) => {
|
||||
app.get('/tenants/:tenantId/projects', async (request) => {
|
||||
const { tenantId } = request.params as { tenantId: string };
|
||||
return projectsService.listProjectsForTenant(tenantId);
|
||||
});
|
||||
|
||||
app.post('/tenants/:tenantId/projects', async (request, reply) => {
|
||||
const { tenantId } = request.params as { tenantId: string };
|
||||
const payload = projectSchema.parse(request.body);
|
||||
const project = await projectsService.createProject(tenantId, payload);
|
||||
await auditService.record({
|
||||
tenantId,
|
||||
projectId: project.id,
|
||||
action: 'project.created',
|
||||
metadata: { provider: project.provider },
|
||||
});
|
||||
reply.code(201);
|
||||
return project;
|
||||
});
|
||||
|
||||
app.post('/projects/:projectId/provision', async (request) => {
|
||||
const { projectId } = request.params as { projectId: string };
|
||||
const result = await provisioningService.provisionProject(projectId);
|
||||
await auditService.record({
|
||||
projectId,
|
||||
tenantId: result.project.tenantId,
|
||||
action: 'project.provisioned',
|
||||
metadata: { provider: result.project.provider, externalId: result.project.externalId },
|
||||
});
|
||||
return result;
|
||||
});
|
||||
|
||||
app.post('/projects/:projectId/schema/sync', async (request) => {
|
||||
const { projectId } = request.params as { projectId: string };
|
||||
const payload = schemaSyncPayload.parse(request.body);
|
||||
const result = await schemaService.syncSchema(projectId, payload);
|
||||
await auditService.record({
|
||||
projectId,
|
||||
tenantId: result.project.tenantId,
|
||||
action: 'schema.applied',
|
||||
metadata: { version: payload.version },
|
||||
});
|
||||
return result;
|
||||
});
|
||||
|
||||
app.get('/projects/:projectId/metrics', async (request) => {
|
||||
const { projectId } = request.params as { projectId: string };
|
||||
return finopsCollector.collectForProject(projectId);
|
||||
});
|
||||
};
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
import { Project } from '../../core/types.js';
|
||||
|
||||
export type ProjectEntity = Project;
|
||||
49
baas-control-plane/src/modules/projects/projects.service.ts
Normal file
49
baas-control-plane/src/modules/projects/projects.service.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { storage } from '../../lib/storage.js';
|
||||
import { Project, ProviderType } from '../../core/types.js';
|
||||
|
||||
const PROJECTS_FILE = 'projects.json';
|
||||
|
||||
export class ProjectsService {
|
||||
async listProjectsForTenant(tenantId: string): Promise<Project[]> {
|
||||
const projects = await storage.readCollection<Project>(PROJECTS_FILE);
|
||||
return projects.filter((project) => project.tenantId === tenantId);
|
||||
}
|
||||
|
||||
async getProject(projectId: string): Promise<Project | undefined> {
|
||||
const projects = await storage.readCollection<Project>(PROJECTS_FILE);
|
||||
return projects.find((project) => project.id === projectId);
|
||||
}
|
||||
|
||||
async createProject(tenantId: string, input: { name: string; provider: ProviderType }): Promise<Project> {
|
||||
const projects = await storage.readCollection<Project>(PROJECTS_FILE);
|
||||
const now = new Date().toISOString();
|
||||
const project: Project = {
|
||||
id: crypto.randomUUID(),
|
||||
tenantId,
|
||||
name: input.name,
|
||||
provider: input.provider,
|
||||
status: 'draft',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
projects.push(project);
|
||||
await storage.writeCollection(PROJECTS_FILE, projects);
|
||||
return project;
|
||||
}
|
||||
|
||||
async updateProject(projectId: string, changes: Partial<Project>): Promise<Project> {
|
||||
const projects = await storage.readCollection<Project>(PROJECTS_FILE);
|
||||
const index = projects.findIndex((project) => project.id === projectId);
|
||||
if (index === -1) {
|
||||
throw new Error('Project not found');
|
||||
}
|
||||
const updated = {
|
||||
...projects[index],
|
||||
...changes,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
projects[index] = updated;
|
||||
await storage.writeCollection(PROJECTS_FILE, projects);
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import { ProjectsService } from '../projects/projects.service.js';
|
||||
import { providerFactory } from '../../core/provider.factory.js';
|
||||
import { SecretsService } from '../secrets/secrets.service.js';
|
||||
import { Project } from '../../core/types.js';
|
||||
|
||||
export class ProvisioningService {
|
||||
constructor(
|
||||
private readonly projectsService: ProjectsService,
|
||||
private readonly secretsService: SecretsService,
|
||||
) {}
|
||||
|
||||
async provisionProject(projectId: string): Promise<{ project: Project }> {
|
||||
const project = await this.projectsService.getProject(projectId);
|
||||
if (!project) {
|
||||
throw new Error('Project not found');
|
||||
}
|
||||
|
||||
const provider = providerFactory.create(project.provider);
|
||||
const secrets = await this.secretsService.getProviderSecrets(project.provider, project.tenantId);
|
||||
|
||||
const created = await provider.createProject(project.name, secrets);
|
||||
const updated = await this.projectsService.updateProject(projectId, {
|
||||
status: 'provisioned',
|
||||
externalId: created.externalId,
|
||||
});
|
||||
|
||||
return { project: updated };
|
||||
}
|
||||
}
|
||||
27
baas-control-plane/src/modules/schema/schema.service.ts
Normal file
27
baas-control-plane/src/modules/schema/schema.service.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { SchemaDefinition } from '../../core/types.js';
|
||||
import { ProjectsService } from '../projects/projects.service.js';
|
||||
import { providerFactory } from '../../core/provider.factory.js';
|
||||
import { SecretsService } from '../secrets/secrets.service.js';
|
||||
import { SchemaVersioning } from './schema.versioning.js';
|
||||
|
||||
export class SchemaService {
|
||||
constructor(
|
||||
private readonly projectsService: ProjectsService,
|
||||
private readonly secretsService: SecretsService,
|
||||
private readonly versioning: SchemaVersioning,
|
||||
) {}
|
||||
|
||||
async syncSchema(projectId: string, schema: SchemaDefinition): Promise<{ project: { id: string; tenantId: string } }> {
|
||||
const project = await this.projectsService.getProject(projectId);
|
||||
if (!project || !project.externalId) {
|
||||
throw new Error('Project not provisioned');
|
||||
}
|
||||
|
||||
const provider = providerFactory.create(project.provider);
|
||||
const secrets = await this.secretsService.getProviderSecrets(project.provider, project.tenantId);
|
||||
await provider.applySchema(project.externalId, schema, secrets);
|
||||
await this.versioning.addVersion(projectId, schema);
|
||||
|
||||
return { project: { id: project.id, tenantId: project.tenantId } };
|
||||
}
|
||||
}
|
||||
28
baas-control-plane/src/modules/schema/schema.versioning.ts
Normal file
28
baas-control-plane/src/modules/schema/schema.versioning.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { storage } from '../../lib/storage.js';
|
||||
import { SchemaDefinition } from '../../core/types.js';
|
||||
|
||||
const SCHEMA_FILE = 'schema-versions.json';
|
||||
|
||||
interface SchemaVersionRecord {
|
||||
projectId: string;
|
||||
versions: SchemaDefinition[];
|
||||
}
|
||||
|
||||
export class SchemaVersioning {
|
||||
async listVersions(projectId: string): Promise<SchemaDefinition[]> {
|
||||
const records = await storage.readCollection<SchemaVersionRecord>(SCHEMA_FILE);
|
||||
const record = records.find((item) => item.projectId === projectId);
|
||||
return record?.versions ?? [];
|
||||
}
|
||||
|
||||
async addVersion(projectId: string, schema: SchemaDefinition): Promise<void> {
|
||||
const records = await storage.readCollection<SchemaVersionRecord>(SCHEMA_FILE);
|
||||
const existing = records.find((item) => item.projectId === projectId);
|
||||
if (existing) {
|
||||
existing.versions.push(schema);
|
||||
} else {
|
||||
records.push({ projectId, versions: [schema] });
|
||||
}
|
||||
await storage.writeCollection(SCHEMA_FILE, records);
|
||||
}
|
||||
}
|
||||
52
baas-control-plane/src/modules/secrets/secrets.service.ts
Normal file
52
baas-control-plane/src/modules/secrets/secrets.service.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { env } from '../../lib/env.js';
|
||||
import { ProviderSecrets, ProviderType } from '../../core/types.js';
|
||||
import { storage } from '../../lib/storage.js';
|
||||
|
||||
const SECRETS_FILE = 'provider-secrets.json';
|
||||
|
||||
interface SecretsRecord {
|
||||
tenantId: string;
|
||||
provider: ProviderType;
|
||||
secrets: ProviderSecrets;
|
||||
}
|
||||
|
||||
export class SecretsService {
|
||||
async getProviderSecrets(provider: ProviderType, tenantId: string): Promise<ProviderSecrets> {
|
||||
const records = await storage.readCollection<SecretsRecord>(SECRETS_FILE);
|
||||
const record = records.find((item) => item.tenantId === tenantId && item.provider === provider);
|
||||
|
||||
if (record) {
|
||||
return record.secrets;
|
||||
}
|
||||
|
||||
const defaults: Record<ProviderType, ProviderSecrets> = {
|
||||
appwrite: {
|
||||
endpoint: env.appwriteEndpoint,
|
||||
apiKey: env.appwriteApiKey,
|
||||
},
|
||||
supabase: {
|
||||
endpoint: env.supabaseEndpoint,
|
||||
apiKey: env.supabaseServiceKey,
|
||||
},
|
||||
};
|
||||
|
||||
return defaults[provider];
|
||||
}
|
||||
|
||||
async rotateProviderSecrets(
|
||||
provider: ProviderType,
|
||||
tenantId: string,
|
||||
secrets: ProviderSecrets,
|
||||
): Promise<void> {
|
||||
const records = await storage.readCollection<SecretsRecord>(SECRETS_FILE);
|
||||
const existing = records.find((item) => item.tenantId === tenantId && item.provider === provider);
|
||||
|
||||
if (existing) {
|
||||
existing.secrets = secrets;
|
||||
} else {
|
||||
records.push({ tenantId, provider, secrets });
|
||||
}
|
||||
|
||||
await storage.writeCollection(SECRETS_FILE, records);
|
||||
}
|
||||
}
|
||||
30
baas-control-plane/src/modules/tenants/tenants.controller.ts
Normal file
30
baas-control-plane/src/modules/tenants/tenants.controller.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { TenantsService } from './tenants.service.js';
|
||||
import { AuditService } from '../audit/audit.service.js';
|
||||
|
||||
const tenantSchema = z.object({
|
||||
name: z.string().min(2),
|
||||
plan: z.string().optional(),
|
||||
status: z.enum(['active', 'suspended']).optional(),
|
||||
});
|
||||
|
||||
export const registerTenantsController = (
|
||||
app: FastifyInstance,
|
||||
tenantsService: TenantsService,
|
||||
auditService: AuditService,
|
||||
) => {
|
||||
app.get('/tenants', async () => tenantsService.listTenants());
|
||||
|
||||
app.post('/tenants', async (request, reply) => {
|
||||
const payload = tenantSchema.parse(request.body);
|
||||
const tenant = await tenantsService.createTenant(payload);
|
||||
await auditService.record({
|
||||
tenantId: tenant.id,
|
||||
action: 'tenant.created',
|
||||
metadata: { name: tenant.name, plan: tenant.plan },
|
||||
});
|
||||
reply.code(201);
|
||||
return tenant;
|
||||
});
|
||||
};
|
||||
3
baas-control-plane/src/modules/tenants/tenants.entity.ts
Normal file
3
baas-control-plane/src/modules/tenants/tenants.entity.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { Tenant } from '../../core/types.js';
|
||||
|
||||
export type TenantEntity = Tenant;
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
import { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
addAppwriteProject,
|
||||
createTenant,
|
||||
listTenantProjects,
|
||||
listTenants,
|
||||
redactProject,
|
||||
removeTenantProject,
|
||||
updateTenantProject,
|
||||
} from './tenants.service.js';
|
||||
|
||||
const tenantSchema = z.object({
|
||||
name: z.string().min(2),
|
||||
slug: z.string().optional(),
|
||||
});
|
||||
|
||||
const projectSchema = z.object({
|
||||
name: z.string().min(2),
|
||||
endpoint: z.string().url(),
|
||||
projectId: z.string().optional(),
|
||||
apiKey: z.string().min(10),
|
||||
createProject: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const projectUpdateSchema = projectSchema.partial();
|
||||
|
||||
export const tenantsRouter = Router();
|
||||
|
||||
tenantsRouter.post('/', async (request, response, next) => {
|
||||
try {
|
||||
const payload = tenantSchema.parse(request.body);
|
||||
const tenant = await createTenant(payload);
|
||||
response.status(201).json(tenant);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
tenantsRouter.get('/', async (_request, response, next) => {
|
||||
try {
|
||||
const tenants = await listTenants();
|
||||
response.json(tenants);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
tenantsRouter.post('/:id/appwrite-project', async (request, response, next) => {
|
||||
try {
|
||||
const payload = projectSchema.parse(request.body);
|
||||
const project = await addAppwriteProject(request.params.id, payload);
|
||||
response.status(201).json(redactProject(project));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
tenantsRouter.get('/:id/appwrite-projects', async (request, response, next) => {
|
||||
try {
|
||||
const projects = await listTenantProjects(request.params.id);
|
||||
response.json(projects);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
tenantsRouter.patch('/:id/appwrite-projects/:projectRef', async (request, response, next) => {
|
||||
try {
|
||||
const payload = projectUpdateSchema.parse(request.body);
|
||||
const project = await updateTenantProject(request.params.id, request.params.projectRef, payload);
|
||||
response.json(redactProject(project));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
tenantsRouter.delete('/:id/appwrite-projects/:projectRef', async (request, response, next) => {
|
||||
try {
|
||||
await removeTenantProject(request.params.id, request.params.projectRef);
|
||||
response.status(204).send();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
|
@ -1,165 +1,33 @@
|
|||
import crypto from 'crypto';
|
||||
import { createEmptyFinops } from '../finops/finops.service.js';
|
||||
import { loadTenants, saveTenants } from './tenants.store.js';
|
||||
import type { AppwriteProject, AppwriteProjectInput, Tenant, TenantInput } from './tenants.types.js';
|
||||
import { env } from '../../lib/env.js';
|
||||
import { createProject } from '../../lib/appwrite.js';
|
||||
import { storage } from '../../lib/storage.js';
|
||||
import { logger } from '../../lib/logger.js';
|
||||
import { Tenant, TenantStatus } from '../../core/types.js';
|
||||
|
||||
const slugify = (name: string) =>
|
||||
name
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[^\w\s-]/g, '')
|
||||
.trim()
|
||||
.replace(/[\s_-]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
const TENANTS_FILE = 'tenants.json';
|
||||
|
||||
export const redactProject = (project: AppwriteProject) => ({
|
||||
...project,
|
||||
apiKey: project.apiKey ? `${project.apiKey.slice(0, 4)}****${project.apiKey.slice(-4)}` : '',
|
||||
});
|
||||
|
||||
export const listTenants = async () => {
|
||||
const tenants = await loadTenants();
|
||||
return tenants.map((tenant) => ({
|
||||
...tenant,
|
||||
appwriteProjects: tenant.appwriteProjects.map(redactProject),
|
||||
}));
|
||||
};
|
||||
|
||||
export const createTenant = async (input: TenantInput): Promise<Tenant> => {
|
||||
const tenants = await loadTenants();
|
||||
const slug = input.slug ? slugify(input.slug) : slugify(input.name);
|
||||
|
||||
if (tenants.some((tenant) => tenant.slug === slug)) {
|
||||
throw new Error('Tenant slug already exists');
|
||||
export class TenantsService {
|
||||
async listTenants(): Promise<Tenant[]> {
|
||||
return storage.readCollection<Tenant>(TENANTS_FILE);
|
||||
}
|
||||
|
||||
async getTenant(id: string): Promise<Tenant | undefined> {
|
||||
const tenants = await storage.readCollection<Tenant>(TENANTS_FILE);
|
||||
return tenants.find((tenant) => tenant.id === id);
|
||||
}
|
||||
|
||||
async createTenant(input: { name: string; plan?: string; status?: TenantStatus }): Promise<Tenant> {
|
||||
const tenants = await storage.readCollection<Tenant>(TENANTS_FILE);
|
||||
const now = new Date().toISOString();
|
||||
const tenant: Tenant = {
|
||||
id: crypto.randomUUID(),
|
||||
name: input.name,
|
||||
slug,
|
||||
status: 'active',
|
||||
plan: input.plan ?? 'standard',
|
||||
status: input.status ?? 'active',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
appwriteProjects: [],
|
||||
finops: createEmptyFinops(),
|
||||
};
|
||||
|
||||
tenants.push(tenant);
|
||||
await saveTenants(tenants);
|
||||
|
||||
await storage.writeCollection(TENANTS_FILE, tenants);
|
||||
logger.info('Tenant created', { tenantId: tenant.id });
|
||||
return tenant;
|
||||
};
|
||||
|
||||
export const addAppwriteProject = async (tenantId: string, input: AppwriteProjectInput): Promise<AppwriteProject> => {
|
||||
const tenants = await loadTenants();
|
||||
const tenant = tenants.find((item) => item.id === tenantId);
|
||||
|
||||
if (!tenant) {
|
||||
throw new Error('Tenant not found');
|
||||
}
|
||||
|
||||
let projectId = input.projectId;
|
||||
|
||||
if (input.createProject) {
|
||||
if (!env.appwriteAdmin.endpoint || !env.appwriteAdmin.apiKey || !env.appwriteAdmin.projectId) {
|
||||
throw new Error('Missing Appwrite admin credentials to create projects');
|
||||
}
|
||||
|
||||
const created = await createProject(
|
||||
{
|
||||
endpoint: env.appwriteAdmin.endpoint,
|
||||
projectId: env.appwriteAdmin.projectId,
|
||||
apiKey: env.appwriteAdmin.apiKey,
|
||||
},
|
||||
input.name
|
||||
);
|
||||
projectId = created.projectId;
|
||||
}
|
||||
|
||||
if (!projectId) {
|
||||
throw new Error('projectId is required when createProject is false');
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const project: AppwriteProject = {
|
||||
id: crypto.randomUUID(),
|
||||
name: input.name,
|
||||
endpoint: input.endpoint,
|
||||
projectId,
|
||||
apiKey: input.apiKey,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
tenant.appwriteProjects.push(project);
|
||||
tenant.updatedAt = now;
|
||||
|
||||
await saveTenants(tenants);
|
||||
|
||||
return project;
|
||||
};
|
||||
|
||||
export const listTenantProjects = async (tenantId: string) => {
|
||||
const tenants = await loadTenants();
|
||||
const tenant = tenants.find((item) => item.id === tenantId);
|
||||
|
||||
if (!tenant) {
|
||||
throw new Error('Tenant not found');
|
||||
}
|
||||
|
||||
return tenant.appwriteProjects.map(redactProject);
|
||||
};
|
||||
|
||||
export const updateTenantProject = async (
|
||||
tenantId: string,
|
||||
projectRef: string,
|
||||
updates: Partial<AppwriteProjectInput>
|
||||
): Promise<AppwriteProject> => {
|
||||
const tenants = await loadTenants();
|
||||
const tenant = tenants.find((item) => item.id === tenantId);
|
||||
|
||||
if (!tenant) {
|
||||
throw new Error('Tenant not found');
|
||||
}
|
||||
|
||||
const project = tenant.appwriteProjects.find((item) => item.id === projectRef || item.projectId === projectRef);
|
||||
|
||||
if (!project) {
|
||||
throw new Error('Project not found');
|
||||
}
|
||||
|
||||
project.name = updates.name ?? project.name;
|
||||
project.endpoint = updates.endpoint ?? project.endpoint;
|
||||
project.projectId = updates.projectId ?? project.projectId;
|
||||
project.apiKey = updates.apiKey ?? project.apiKey;
|
||||
project.updatedAt = new Date().toISOString();
|
||||
|
||||
tenant.updatedAt = project.updatedAt;
|
||||
await saveTenants(tenants);
|
||||
|
||||
return project;
|
||||
};
|
||||
|
||||
export const removeTenantProject = async (tenantId: string, projectRef: string): Promise<void> => {
|
||||
const tenants = await loadTenants();
|
||||
const tenant = tenants.find((item) => item.id === tenantId);
|
||||
|
||||
if (!tenant) {
|
||||
throw new Error('Tenant not found');
|
||||
}
|
||||
|
||||
const nextProjects = tenant.appwriteProjects.filter(
|
||||
(item) => item.id !== projectRef && item.projectId !== projectRef
|
||||
);
|
||||
|
||||
if (nextProjects.length === tenant.appwriteProjects.length) {
|
||||
throw new Error('Project not found');
|
||||
}
|
||||
|
||||
tenant.appwriteProjects = nextProjects;
|
||||
tenant.updatedAt = new Date().toISOString();
|
||||
await saveTenants(tenants);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,27 +0,0 @@
|
|||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import { env } from '../../lib/env.js';
|
||||
import type { Tenant } from './tenants.types.js';
|
||||
|
||||
const dataFile = path.join(env.dataDir, 'tenants.json');
|
||||
|
||||
const ensureDataDir = async () => {
|
||||
await fs.mkdir(env.dataDir, { recursive: true });
|
||||
};
|
||||
|
||||
export const loadTenants = async (): Promise<Tenant[]> => {
|
||||
try {
|
||||
const content = await fs.readFile(dataFile, 'utf-8');
|
||||
return JSON.parse(content) as Tenant[];
|
||||
} catch (error) {
|
||||
if (typeof error === 'object' && error !== null && 'code' in error && (error as { code?: string }).code === 'ENOENT') {
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const saveTenants = async (tenants: Tenant[]): Promise<void> => {
|
||||
await ensureDataDir();
|
||||
await fs.writeFile(dataFile, JSON.stringify(tenants, null, 2));
|
||||
};
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
import type { FinopsSummary } from '../finops/finops.service.js';
|
||||
|
||||
export type AppwriteProject = {
|
||||
id: string;
|
||||
name: string;
|
||||
endpoint: string;
|
||||
projectId: string;
|
||||
apiKey: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type Tenant = {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
status: 'active' | 'inactive';
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
appwriteProjects: AppwriteProject[];
|
||||
finops: FinopsSummary;
|
||||
};
|
||||
|
||||
export type TenantInput = {
|
||||
name: string;
|
||||
slug?: string;
|
||||
};
|
||||
|
||||
export type AppwriteProjectInput = {
|
||||
name: string;
|
||||
endpoint: string;
|
||||
projectId?: string;
|
||||
apiKey: string;
|
||||
createProject?: boolean;
|
||||
};
|
||||
48
baas-control-plane/src/providers/appwrite/appwrite.client.ts
Normal file
48
baas-control-plane/src/providers/appwrite/appwrite.client.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { ProviderMetrics, ProviderProject, ProviderSecrets, SchemaDefinition } from '../../core/types.js';
|
||||
import { logger } from '../../lib/logger.js';
|
||||
|
||||
export class AppwriteClient {
|
||||
async createProject(name: string, secrets: ProviderSecrets): Promise<ProviderProject> {
|
||||
logger.info('Appwrite create project requested', { name, endpoint: secrets.endpoint });
|
||||
return {
|
||||
externalId: `appwrite_${crypto.randomUUID()}`,
|
||||
dashboardUrl: `${secrets.endpoint}/console/project`,
|
||||
};
|
||||
}
|
||||
|
||||
async deleteProject(externalId: string, secrets: ProviderSecrets): Promise<void> {
|
||||
logger.info('Appwrite delete project requested', { externalId, endpoint: secrets.endpoint });
|
||||
}
|
||||
|
||||
async applySchema(externalId: string, schema: SchemaDefinition, secrets: ProviderSecrets): Promise<void> {
|
||||
logger.info('Appwrite apply schema requested', {
|
||||
externalId,
|
||||
version: schema.version,
|
||||
endpoint: secrets.endpoint,
|
||||
});
|
||||
}
|
||||
|
||||
async collectMetrics(externalId: string, secrets: ProviderSecrets): Promise<ProviderMetrics> {
|
||||
logger.info('Appwrite metrics requested', { externalId, endpoint: secrets.endpoint });
|
||||
return {
|
||||
users: 0,
|
||||
storageMb: 0,
|
||||
requests: 0,
|
||||
functions: 0,
|
||||
capturedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async rotateSecrets(externalId: string, secrets: ProviderSecrets): Promise<ProviderSecrets> {
|
||||
logger.info('Appwrite secrets rotation requested', { externalId, endpoint: secrets.endpoint });
|
||||
return {
|
||||
...secrets,
|
||||
apiKey: `${secrets.apiKey}-rotated`,
|
||||
};
|
||||
}
|
||||
|
||||
async healthCheck(secrets: ProviderSecrets): Promise<boolean> {
|
||||
logger.info('Appwrite health check requested', { endpoint: secrets.endpoint });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import { ProviderMetrics, ProviderSecrets } from '../../core/types.js';
|
||||
import { AppwriteClient } from './appwrite.client.js';
|
||||
|
||||
const client = new AppwriteClient();
|
||||
|
||||
export const appwriteMetrics = {
|
||||
async collect(externalId: string, secrets: ProviderSecrets): Promise<ProviderMetrics> {
|
||||
return client.collectMetrics(externalId, secrets);
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
import { ProviderInterface } from '../../core/provider.interface.js';
|
||||
import { ProviderMetrics, ProviderProject, ProviderSecrets, SchemaDefinition } from '../../core/types.js';
|
||||
import { AppwriteClient } from './appwrite.client.js';
|
||||
|
||||
export class AppwriteProvider implements ProviderInterface {
|
||||
private readonly client = new AppwriteClient();
|
||||
|
||||
async createProject(name: string, secrets: ProviderSecrets): Promise<ProviderProject> {
|
||||
return this.client.createProject(name, secrets);
|
||||
}
|
||||
|
||||
async deleteProject(externalId: string, secrets: ProviderSecrets): Promise<void> {
|
||||
await this.client.deleteProject(externalId, secrets);
|
||||
}
|
||||
|
||||
async applySchema(externalId: string, schema: SchemaDefinition, secrets: ProviderSecrets): Promise<void> {
|
||||
await this.client.applySchema(externalId, schema, secrets);
|
||||
}
|
||||
|
||||
async collectMetrics(externalId: string, secrets: ProviderSecrets): Promise<ProviderMetrics> {
|
||||
return this.client.collectMetrics(externalId, secrets);
|
||||
}
|
||||
|
||||
async rotateSecrets(externalId: string, secrets: ProviderSecrets): Promise<ProviderSecrets> {
|
||||
return this.client.rotateSecrets(externalId, secrets);
|
||||
}
|
||||
|
||||
async healthCheck(secrets: ProviderSecrets): Promise<boolean> {
|
||||
return this.client.healthCheck(secrets);
|
||||
}
|
||||
}
|
||||
10
baas-control-plane/src/providers/appwrite/appwrite.schema.ts
Normal file
10
baas-control-plane/src/providers/appwrite/appwrite.schema.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { SchemaDefinition, ProviderSecrets } from '../../core/types.js';
|
||||
import { AppwriteClient } from './appwrite.client.js';
|
||||
|
||||
const client = new AppwriteClient();
|
||||
|
||||
export const appwriteSchema = {
|
||||
async apply(externalId: string, schema: SchemaDefinition, secrets: ProviderSecrets): Promise<void> {
|
||||
await client.applySchema(externalId, schema, secrets);
|
||||
},
|
||||
};
|
||||
48
baas-control-plane/src/providers/supabase/supabase.client.ts
Normal file
48
baas-control-plane/src/providers/supabase/supabase.client.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { ProviderMetrics, ProviderProject, ProviderSecrets, SchemaDefinition } from '../../core/types.js';
|
||||
import { logger } from '../../lib/logger.js';
|
||||
|
||||
export class SupabaseClient {
|
||||
async createProject(name: string, secrets: ProviderSecrets): Promise<ProviderProject> {
|
||||
logger.info('Supabase create project requested', { name, endpoint: secrets.endpoint });
|
||||
return {
|
||||
externalId: `supabase_${crypto.randomUUID()}`,
|
||||
dashboardUrl: `${secrets.endpoint}/project`,
|
||||
};
|
||||
}
|
||||
|
||||
async deleteProject(externalId: string, secrets: ProviderSecrets): Promise<void> {
|
||||
logger.info('Supabase delete project requested', { externalId, endpoint: secrets.endpoint });
|
||||
}
|
||||
|
||||
async applySchema(externalId: string, schema: SchemaDefinition, secrets: ProviderSecrets): Promise<void> {
|
||||
logger.info('Supabase apply schema requested', {
|
||||
externalId,
|
||||
version: schema.version,
|
||||
endpoint: secrets.endpoint,
|
||||
});
|
||||
}
|
||||
|
||||
async collectMetrics(externalId: string, secrets: ProviderSecrets): Promise<ProviderMetrics> {
|
||||
logger.info('Supabase metrics requested', { externalId, endpoint: secrets.endpoint });
|
||||
return {
|
||||
users: 0,
|
||||
storageMb: 0,
|
||||
requests: 0,
|
||||
functions: 0,
|
||||
capturedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async rotateSecrets(externalId: string, secrets: ProviderSecrets): Promise<ProviderSecrets> {
|
||||
logger.info('Supabase secrets rotation requested', { externalId, endpoint: secrets.endpoint });
|
||||
return {
|
||||
...secrets,
|
||||
apiKey: `${secrets.apiKey}-rotated`,
|
||||
};
|
||||
}
|
||||
|
||||
async healthCheck(secrets: ProviderSecrets): Promise<boolean> {
|
||||
logger.info('Supabase health check requested', { endpoint: secrets.endpoint });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import { ProviderMetrics, ProviderSecrets } from '../../core/types.js';
|
||||
import { SupabaseClient } from './supabase.client.js';
|
||||
|
||||
const client = new SupabaseClient();
|
||||
|
||||
export const supabaseMetrics = {
|
||||
async collect(externalId: string, secrets: ProviderSecrets): Promise<ProviderMetrics> {
|
||||
return client.collectMetrics(externalId, secrets);
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
import { ProviderInterface } from '../../core/provider.interface.js';
|
||||
import { ProviderMetrics, ProviderProject, ProviderSecrets, SchemaDefinition } from '../../core/types.js';
|
||||
import { SupabaseClient } from './supabase.client.js';
|
||||
|
||||
export class SupabaseProvider implements ProviderInterface {
|
||||
private readonly client = new SupabaseClient();
|
||||
|
||||
async createProject(name: string, secrets: ProviderSecrets): Promise<ProviderProject> {
|
||||
return this.client.createProject(name, secrets);
|
||||
}
|
||||
|
||||
async deleteProject(externalId: string, secrets: ProviderSecrets): Promise<void> {
|
||||
await this.client.deleteProject(externalId, secrets);
|
||||
}
|
||||
|
||||
async applySchema(externalId: string, schema: SchemaDefinition, secrets: ProviderSecrets): Promise<void> {
|
||||
await this.client.applySchema(externalId, schema, secrets);
|
||||
}
|
||||
|
||||
async collectMetrics(externalId: string, secrets: ProviderSecrets): Promise<ProviderMetrics> {
|
||||
return this.client.collectMetrics(externalId, secrets);
|
||||
}
|
||||
|
||||
async rotateSecrets(externalId: string, secrets: ProviderSecrets): Promise<ProviderSecrets> {
|
||||
return this.client.rotateSecrets(externalId, secrets);
|
||||
}
|
||||
|
||||
async healthCheck(secrets: ProviderSecrets): Promise<boolean> {
|
||||
return this.client.healthCheck(secrets);
|
||||
}
|
||||
}
|
||||
10
baas-control-plane/src/providers/supabase/supabase.schema.ts
Normal file
10
baas-control-plane/src/providers/supabase/supabase.schema.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { ProviderSecrets, SchemaDefinition } from '../../core/types.js';
|
||||
import { SupabaseClient } from './supabase.client.js';
|
||||
|
||||
const client = new SupabaseClient();
|
||||
|
||||
export const supabaseSchema = {
|
||||
async apply(externalId: string, schema: SchemaDefinition, secrets: ProviderSecrets): Promise<void> {
|
||||
await client.applySchema(externalId, schema, secrets);
|
||||
},
|
||||
};
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { applySchema } from '../lib/appwrite.js';
|
||||
import { env } from '../lib/env.js';
|
||||
import { logger } from '../lib/logger.js';
|
||||
|
||||
const endpoint = process.env.APPWRITE_SETUP_ENDPOINT || env.appwriteAdmin.endpoint || env.defaults.appwriteEndpoint;
|
||||
const projectId = process.env.APPWRITE_SETUP_PROJECT_ID || '';
|
||||
const apiKey = process.env.APPWRITE_SETUP_API_KEY || env.appwriteAdmin.apiKey;
|
||||
|
||||
if (!endpoint || !projectId || !apiKey) {
|
||||
logger.error('Missing Appwrite setup credentials', {
|
||||
endpoint: Boolean(endpoint),
|
||||
projectId: Boolean(projectId),
|
||||
apiKey: Boolean(apiKey),
|
||||
});
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const run = async () => {
|
||||
const result = await applySchema({ endpoint, projectId, apiKey }, { logPrefix: 'setup' });
|
||||
logger.info('Setup completed', result);
|
||||
};
|
||||
|
||||
run().catch((error: Error) => {
|
||||
logger.error('Setup failed', { message: error.message });
|
||||
process.exit(1);
|
||||
});
|
||||
Loading…
Reference in a new issue