Initial commit: SDI SaaS Platform foundation
- Complete monorepo structure with pnpm workspaces - Prisma database schema with 20+ entities - NestJS API with 9 core modules - BullMQ orchestration worker - AWS and Azure provider adapters - Docker Compose infrastructure - Complete documentation
This commit is contained in:
3
apps/worker/.env.example
Normal file
3
apps/worker/.env.example
Normal file
@@ -0,0 +1,3 @@
|
||||
NODE_ENV=development
|
||||
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/sdi_saas?schema=public"
|
||||
REDIS_URL="redis://localhost:6379"
|
||||
25
apps/worker/Dockerfile
Normal file
25
apps/worker/Dockerfile
Normal file
@@ -0,0 +1,25 @@
|
||||
FROM node:20-alpine AS base
|
||||
RUN npm install -g pnpm@8.15.0
|
||||
|
||||
FROM base AS dependencies
|
||||
WORKDIR /app
|
||||
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml* ./
|
||||
COPY apps/worker/package.json ./apps/worker/
|
||||
COPY packages/database/package.json ./packages/database/
|
||||
COPY packages/shared-types/package.json ./packages/shared-types/
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
FROM base AS build
|
||||
WORKDIR /app
|
||||
COPY --from=dependencies /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
RUN pnpm --filter @sdi/database db:generate
|
||||
RUN pnpm --filter @sdi/worker build
|
||||
|
||||
FROM base AS runtime
|
||||
WORKDIR /app
|
||||
COPY --from=dependencies /app/node_modules ./node_modules
|
||||
COPY --from=build /app/apps/worker/dist ./apps/worker/dist
|
||||
COPY --from=build /app/packages ./packages
|
||||
COPY apps/worker/package.json ./apps/worker/
|
||||
CMD ["node", "apps/worker/dist/main.js"]
|
||||
20
apps/worker/package.json
Normal file
20
apps/worker/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "@sdi/worker",
|
||||
"version": "0.1.0",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsx watch src/main.ts",
|
||||
"start": "node dist/main.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sdi/database": "workspace:*",
|
||||
"@sdi/shared-types": "workspace:*",
|
||||
"bullmq": "^5.1.0",
|
||||
"ioredis": "^5.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.11.0",
|
||||
"tsx": "^4.7.0",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
}
|
||||
24
apps/worker/src/adapters/adapter-registry.ts
Normal file
24
apps/worker/src/adapters/adapter-registry.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { ProviderAdapter } from '@sdi/shared-types';
|
||||
import { AwsAdapter } from './aws-adapter';
|
||||
import { AzureAdapter } from './azure-adapter';
|
||||
|
||||
export class AdapterRegistry {
|
||||
private adapters: Map<string, ProviderAdapter> = new Map();
|
||||
|
||||
constructor() {
|
||||
this.adapters.set('aws', new AwsAdapter());
|
||||
this.adapters.set('azure', new AzureAdapter());
|
||||
}
|
||||
|
||||
get(providerType: string): ProviderAdapter {
|
||||
const adapter = this.adapters.get(providerType);
|
||||
if (!adapter) {
|
||||
throw new Error(`No adapter found for provider type: ${providerType}`);
|
||||
}
|
||||
return adapter;
|
||||
}
|
||||
|
||||
register(providerType: string, adapter: ProviderAdapter) {
|
||||
this.adapters.set(providerType, adapter);
|
||||
}
|
||||
}
|
||||
96
apps/worker/src/adapters/aws-adapter.ts
Normal file
96
apps/worker/src/adapters/aws-adapter.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import {
|
||||
ProviderAdapter,
|
||||
ServiceIntent,
|
||||
ValidationResult,
|
||||
QuoteResult,
|
||||
ProvisionRequest,
|
||||
ProvisionResponse,
|
||||
ModifyRequest,
|
||||
ModifyResponse,
|
||||
ActionResult,
|
||||
ServiceStatus,
|
||||
} from '@sdi/shared-types';
|
||||
|
||||
export class AwsAdapter implements ProviderAdapter {
|
||||
async validate(payload: ServiceIntent): Promise<ValidationResult> {
|
||||
// TODO: Implement AWS Direct Connect validation
|
||||
// - Check if source/target endpoints are valid
|
||||
// - Verify bandwidth is supported
|
||||
// - Check location availability
|
||||
|
||||
console.log('AWS: Validating service intent', payload);
|
||||
|
||||
// Mock validation
|
||||
return {
|
||||
ok: true,
|
||||
warnings: ['AWS adapter is in mock mode'],
|
||||
};
|
||||
}
|
||||
|
||||
async quote(payload: ServiceIntent): Promise<QuoteResult> {
|
||||
// TODO: Implement AWS pricing calculation
|
||||
// - Get Direct Connect pricing for bandwidth
|
||||
// - Calculate port hours
|
||||
// - Add data transfer costs
|
||||
|
||||
console.log('AWS: Generating quote', payload);
|
||||
|
||||
return {
|
||||
monthlyRecurring: payload.bandwidthMbps * 0.05,
|
||||
setupFee: 500,
|
||||
currency: 'USD',
|
||||
validUntil: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
|
||||
};
|
||||
}
|
||||
|
||||
async provision(payload: ProvisionRequest): Promise<ProvisionResponse> {
|
||||
// TODO: Implement AWS Direct Connect provisioning
|
||||
// - Create connection via AWS SDK
|
||||
// - Configure virtual interface
|
||||
// - Set up BGP peering
|
||||
|
||||
console.log('AWS: Provisioning service', payload);
|
||||
|
||||
// Mock provisioning
|
||||
const externalServiceId = `dx-${Date.now()}`;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
externalServiceId,
|
||||
metadata: {
|
||||
connectionId: externalServiceId,
|
||||
vlan: 100,
|
||||
bgpAsn: 65000,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async getStatus(externalId: string): Promise<ServiceStatus> {
|
||||
// TODO: Implement status check via AWS SDK
|
||||
console.log('AWS: Getting status for', externalId);
|
||||
return 'active';
|
||||
}
|
||||
|
||||
async modify(payload: ModifyRequest): Promise<ModifyResponse> {
|
||||
// TODO: Implement bandwidth modification
|
||||
console.log('AWS: Modifying service', payload);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async suspend(externalId: string): Promise<ActionResult> {
|
||||
// TODO: Implement service suspension
|
||||
console.log('AWS: Suspending service', externalId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async terminate(externalId: string): Promise<ActionResult> {
|
||||
// TODO: Implement connection deletion via AWS SDK
|
||||
console.log('AWS: Terminating service', externalId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async syncInventory(): Promise<void> {
|
||||
// TODO: Sync AWS Direct Connect inventory
|
||||
console.log('AWS: Syncing inventory');
|
||||
}
|
||||
}
|
||||
94
apps/worker/src/adapters/azure-adapter.ts
Normal file
94
apps/worker/src/adapters/azure-adapter.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import {
|
||||
ProviderAdapter,
|
||||
ServiceIntent,
|
||||
ValidationResult,
|
||||
QuoteResult,
|
||||
ProvisionRequest,
|
||||
ProvisionResponse,
|
||||
ModifyRequest,
|
||||
ModifyResponse,
|
||||
ActionResult,
|
||||
ServiceStatus,
|
||||
} from '@sdi/shared-types';
|
||||
|
||||
export class AzureAdapter implements ProviderAdapter {
|
||||
async validate(payload: ServiceIntent): Promise<ValidationResult> {
|
||||
// TODO: Implement Azure ExpressRoute validation
|
||||
// - Check peering location availability
|
||||
// - Verify SKU and bandwidth tier
|
||||
// - Validate circuit configuration
|
||||
|
||||
console.log('Azure: Validating service intent', payload);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
warnings: ['Azure adapter is in mock mode'],
|
||||
};
|
||||
}
|
||||
|
||||
async quote(payload: ServiceIntent): Promise<QuoteResult> {
|
||||
// TODO: Implement Azure ExpressRoute pricing
|
||||
// - Get circuit pricing by SKU
|
||||
// - Calculate metered vs unlimited data
|
||||
// - Add premium add-on costs if needed
|
||||
|
||||
console.log('Azure: Generating quote', payload);
|
||||
|
||||
return {
|
||||
monthlyRecurring: payload.bandwidthMbps * 0.06,
|
||||
setupFee: 600,
|
||||
currency: 'USD',
|
||||
validUntil: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
|
||||
};
|
||||
}
|
||||
|
||||
async provision(payload: ProvisionRequest): Promise<ProvisionResponse> {
|
||||
// TODO: Implement Azure ExpressRoute provisioning
|
||||
// - Create ExpressRoute circuit
|
||||
// - Configure peering (private/Microsoft)
|
||||
// - Set up route filters
|
||||
|
||||
console.log('Azure: Provisioning service', payload);
|
||||
|
||||
const externalServiceId = `er-${Date.now()}`;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
externalServiceId,
|
||||
metadata: {
|
||||
circuitId: externalServiceId,
|
||||
serviceKey: `sk-${Date.now()}`,
|
||||
peeringLocation: 'mock-location',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async getStatus(externalId: string): Promise<ServiceStatus> {
|
||||
// TODO: Check circuit provisioning state via Azure SDK
|
||||
console.log('Azure: Getting status for', externalId);
|
||||
return 'active';
|
||||
}
|
||||
|
||||
async modify(payload: ModifyRequest): Promise<ModifyResponse> {
|
||||
// TODO: Implement circuit modification
|
||||
console.log('Azure: Modifying service', payload);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async suspend(externalId: string): Promise<ActionResult> {
|
||||
// TODO: Implement circuit suspension
|
||||
console.log('Azure: Suspending service', externalId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async terminate(externalId: string): Promise<ActionResult> {
|
||||
// TODO: Delete ExpressRoute circuit via Azure SDK
|
||||
console.log('Azure: Terminating service', externalId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async syncInventory(): Promise<void> {
|
||||
// TODO: Sync Azure ExpressRoute inventory
|
||||
console.log('Azure: Syncing inventory');
|
||||
}
|
||||
}
|
||||
62
apps/worker/src/main.ts
Normal file
62
apps/worker/src/main.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { Worker, Queue } from 'bullmq';
|
||||
import { PrismaClient } from '@sdi/database';
|
||||
import { ProvisioningOrchestrator } from './orchestration/provisioning-orchestrator';
|
||||
import Redis from 'ioredis';
|
||||
|
||||
const connection = new Redis(process.env.REDIS_URL || 'redis://localhost:6379', {
|
||||
maxRetriesPerRequest: null,
|
||||
});
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
const orchestrator = new ProvisioningOrchestrator(prisma);
|
||||
|
||||
// Provisioning queue worker
|
||||
const provisioningWorker = new Worker(
|
||||
'provisioning',
|
||||
async (job) => {
|
||||
console.log(`Processing job ${job.id}: ${job.name}`);
|
||||
|
||||
switch (job.name) {
|
||||
case 'provision-order':
|
||||
await orchestrator.provisionOrder(job.data.orderId);
|
||||
break;
|
||||
case 'modify-service':
|
||||
await orchestrator.modifyService(job.data.serviceId, job.data.changes);
|
||||
break;
|
||||
case 'suspend-service':
|
||||
await orchestrator.suspendService(job.data.serviceId);
|
||||
break;
|
||||
case 'terminate-service':
|
||||
await orchestrator.terminateService(job.data.serviceId);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown job type: ${job.name}`);
|
||||
}
|
||||
},
|
||||
{
|
||||
connection,
|
||||
concurrency: 5,
|
||||
limiter: {
|
||||
max: 10,
|
||||
duration: 1000,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
provisioningWorker.on('completed', (job) => {
|
||||
console.log(`✓ Job ${job.id} completed successfully`);
|
||||
});
|
||||
|
||||
provisioningWorker.on('failed', (job, err) => {
|
||||
console.error(`✗ Job ${job?.id} failed:`, err.message);
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
process.on('SIGTERM', async () => {
|
||||
console.log('SIGTERM received, closing worker...');
|
||||
await provisioningWorker.close();
|
||||
await prisma.$disconnect();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
console.log('🔧 Worker started and listening for jobs...');
|
||||
168
apps/worker/src/orchestration/provisioning-orchestrator.ts
Normal file
168
apps/worker/src/orchestration/provisioning-orchestrator.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { PrismaClient } from '@sdi/database';
|
||||
import { ServiceOrderStatus } from '@sdi/shared-types';
|
||||
import { AdapterRegistry } from '../adapters/adapter-registry';
|
||||
|
||||
export class ProvisioningOrchestrator {
|
||||
private adapterRegistry: AdapterRegistry;
|
||||
|
||||
constructor(private prisma: PrismaClient) {
|
||||
this.adapterRegistry = new AdapterRegistry();
|
||||
}
|
||||
|
||||
async provisionOrder(orderId: string) {
|
||||
const order = await this.prisma.serviceOrder.findUnique({
|
||||
where: { id: orderId },
|
||||
include: {
|
||||
sourceEndpoint: true,
|
||||
targetEndpoint: true,
|
||||
provider: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!order) {
|
||||
throw new Error(`Order ${orderId} not found`);
|
||||
}
|
||||
|
||||
try {
|
||||
// Update status to validating
|
||||
await this.updateOrderStatus(orderId, 'validating');
|
||||
|
||||
// Get provider adapter
|
||||
const adapter = this.adapterRegistry.get(order.provider.type);
|
||||
|
||||
// Validate service intent
|
||||
const validation = await adapter.validate({
|
||||
sourceEndpointId: order.sourceEndpointId,
|
||||
targetEndpointId: order.targetEndpointId,
|
||||
bandwidthMbps: order.bandwidthMbps,
|
||||
});
|
||||
|
||||
if (!validation.ok) {
|
||||
await this.updateOrderStatus(orderId, 'failed');
|
||||
await this.logAudit(orderId, 'validation_failed', { errors: validation.errors });
|
||||
return;
|
||||
}
|
||||
|
||||
// Update status to provisioning
|
||||
await this.updateOrderStatus(orderId, 'provisioning');
|
||||
|
||||
// Provision service
|
||||
const result = await adapter.provision({
|
||||
sourceEndpointId: order.sourceEndpointId,
|
||||
targetEndpointId: order.targetEndpointId,
|
||||
bandwidthMbps: order.bandwidthMbps,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
await this.updateOrderStatus(orderId, 'failed');
|
||||
await this.logAudit(orderId, 'provision_failed', { error: result.error });
|
||||
return;
|
||||
}
|
||||
|
||||
// Update order with external reference
|
||||
await this.prisma.serviceOrder.update({
|
||||
where: { id: orderId },
|
||||
data: { externalReference: result.externalServiceId },
|
||||
});
|
||||
|
||||
// Create service record
|
||||
const service = await this.prisma.service.create({
|
||||
data: {
|
||||
orderId,
|
||||
tenantId: order.tenantId,
|
||||
status: 'active',
|
||||
activatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// Update order status to active
|
||||
await this.updateOrderStatus(orderId, 'active');
|
||||
await this.logAudit(orderId, 'service_active', { serviceId: service.id });
|
||||
|
||||
console.log(`✓ Order ${orderId} provisioned successfully`);
|
||||
} catch (error) {
|
||||
console.error(`✗ Failed to provision order ${orderId}:`, error);
|
||||
await this.updateOrderStatus(orderId, 'failed');
|
||||
await this.logAudit(orderId, 'provision_error', { error: error.message });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async modifyService(serviceId: string, changes: { bandwidthMbps?: number }) {
|
||||
const service = await this.prisma.service.findUnique({
|
||||
where: { id: serviceId },
|
||||
include: { order: { include: { provider: true } } },
|
||||
});
|
||||
|
||||
if (!service) {
|
||||
throw new Error(`Service ${serviceId} not found`);
|
||||
}
|
||||
|
||||
const adapter = this.adapterRegistry.get(service.order.provider.type);
|
||||
const result = await adapter.modify({
|
||||
externalServiceId: service.order.externalReference!,
|
||||
bandwidthMbps: changes.bandwidthMbps,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
await this.logAudit(serviceId, 'service_modified', changes);
|
||||
}
|
||||
}
|
||||
|
||||
async suspendService(serviceId: string) {
|
||||
const service = await this.prisma.service.findUnique({
|
||||
where: { id: serviceId },
|
||||
include: { order: { include: { provider: true } } },
|
||||
});
|
||||
|
||||
if (!service) {
|
||||
throw new Error(`Service ${serviceId} not found`);
|
||||
}
|
||||
|
||||
const adapter = this.adapterRegistry.get(service.order.provider.type);
|
||||
await adapter.suspend(service.order.externalReference!);
|
||||
|
||||
await this.prisma.service.update({
|
||||
where: { id: serviceId },
|
||||
data: { status: 'suspended', suspendedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
async terminateService(serviceId: string) {
|
||||
const service = await this.prisma.service.findUnique({
|
||||
where: { id: serviceId },
|
||||
include: { order: { include: { provider: true } } },
|
||||
});
|
||||
|
||||
if (!service) {
|
||||
throw new Error(`Service ${serviceId} not found`);
|
||||
}
|
||||
|
||||
const adapter = this.adapterRegistry.get(service.order.provider.type);
|
||||
await adapter.terminate(service.order.externalReference!);
|
||||
|
||||
await this.prisma.service.update({
|
||||
where: { id: serviceId },
|
||||
data: { status: 'terminated', terminatedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
private async updateOrderStatus(orderId: string, status: ServiceOrderStatus) {
|
||||
await this.prisma.serviceOrder.update({
|
||||
where: { id: orderId },
|
||||
data: { status, updatedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
private async logAudit(aggregateId: string, eventType: string, payload: any) {
|
||||
await this.prisma.auditEvent.create({
|
||||
data: {
|
||||
aggregateType: 'order',
|
||||
aggregateId,
|
||||
eventType,
|
||||
actorType: 'system',
|
||||
payload,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
13
apps/worker/tsconfig.json
Normal file
13
apps/worker/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"paths": {
|
||||
"@sdi/database": ["../../packages/database/src"],
|
||||
"@sdi/shared-types": ["../../packages/shared-types/src"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user