- Added generated index files and report directories to .gitignore to prevent unnecessary tracking of transient files. - Updated README links to reflect new documentation paths for better navigation. - Improved documentation organization by ensuring all links point to the correct locations, enhancing user experience and accessibility.
475 lines
14 KiB
TypeScript
475 lines
14 KiB
TypeScript
import { Context } from '../types/context'
|
|
import { AppErrors } from '../lib/errors'
|
|
|
|
export interface ResourceFilter {
|
|
type?: string
|
|
status?: string
|
|
siteId?: string
|
|
tenantId?: string
|
|
}
|
|
|
|
export interface CreateResourceInput {
|
|
name: string
|
|
type: string
|
|
siteId: string
|
|
metadata?: Record<string, unknown>
|
|
}
|
|
|
|
export interface UpdateResourceInput {
|
|
name?: string
|
|
metadata?: Record<string, unknown>
|
|
}
|
|
|
|
interface ResourceRow {
|
|
id: string
|
|
name: string
|
|
type: string
|
|
status: string
|
|
site_id: string | null
|
|
tenant_id: string | null
|
|
metadata: string | Record<string, unknown> | null
|
|
created_at: Date
|
|
updated_at: Date
|
|
[key: string]: unknown
|
|
}
|
|
|
|
interface SiteRow {
|
|
id: string
|
|
name: string
|
|
region: string
|
|
status: string
|
|
metadata: string | Record<string, unknown> | null
|
|
created_at: Date
|
|
updated_at: Date
|
|
[key: string]: unknown
|
|
}
|
|
|
|
/**
|
|
* Get all resources with optional filtering
|
|
*
|
|
* @param context - Request context with user and database connection
|
|
* @param filter - Optional filter criteria (type, status, siteId, tenantId)
|
|
* @returns Array of resources with site information
|
|
* @throws {UnauthenticatedError} If user is not authenticated
|
|
* @example
|
|
* ```typescript
|
|
* const resources = await getResources(context, { type: 'VM', status: 'RUNNING' });
|
|
* ```
|
|
*/
|
|
export async function getResources(context: Context, filter?: ResourceFilter) {
|
|
const db = context.db
|
|
// Use LEFT JOIN to fetch resources and sites in a single query (fixes N+1 problem)
|
|
let query = `
|
|
SELECT
|
|
r.id, r.name, r.type, r.status, r.site_id, r.tenant_id, r.metadata,
|
|
r.created_at, r.updated_at,
|
|
s.id as site_id_full, s.name as site_name, s.region as site_region,
|
|
s.status as site_status, s.metadata as site_metadata,
|
|
s.created_at as site_created_at, s.updated_at as site_updated_at
|
|
FROM resources r
|
|
LEFT JOIN sites s ON r.site_id = s.id
|
|
WHERE 1=1
|
|
`
|
|
const params: unknown[] = []
|
|
let paramCount = 1
|
|
|
|
// Tenant-aware filtering (superior to Azure)
|
|
if (context.tenantContext) {
|
|
if (context.tenantContext.isSystemAdmin) {
|
|
// System admins can see all resources
|
|
} else if (context.tenantContext.tenantId) {
|
|
// Filter by tenant
|
|
query += ` AND (r.tenant_id = $${paramCount} OR r.tenant_id IS NULL)`
|
|
params.push(context.tenantContext.tenantId)
|
|
paramCount++
|
|
} else {
|
|
// Non-tenant users only see system resources
|
|
query += ` AND r.tenant_id IS NULL`
|
|
}
|
|
} else {
|
|
// Unauthenticated users see nothing
|
|
query += ` AND 1=0`
|
|
}
|
|
|
|
if (filter?.type) {
|
|
query += ` AND r.type = $${paramCount}`
|
|
params.push(filter.type)
|
|
paramCount++
|
|
}
|
|
|
|
if (filter?.status) {
|
|
query += ` AND r.status = $${paramCount}`
|
|
params.push(filter.status)
|
|
paramCount++
|
|
}
|
|
|
|
if (filter?.siteId) {
|
|
query += ` AND r.site_id = $${paramCount}`
|
|
params.push(filter.siteId)
|
|
paramCount++
|
|
}
|
|
|
|
query += ' ORDER BY r.created_at DESC'
|
|
|
|
const result = await db.query(query, params)
|
|
// Map results using the joined data (no additional queries needed)
|
|
return result.rows.map((row) => mapResourceWithSite(row))
|
|
}
|
|
|
|
/**
|
|
* Get a single resource by ID
|
|
*
|
|
* @param context - Request context with user and database connection
|
|
* @param id - Resource ID
|
|
* @returns Resource with site information
|
|
* @throws {NotFoundError} If resource not found or user doesn't have access
|
|
* @throws {UnauthenticatedError} If user is not authenticated
|
|
* @example
|
|
* ```typescript
|
|
* const resource = await getResource(context, 'resource-123');
|
|
* ```
|
|
*/
|
|
export async function getResource(context: Context, id: string) {
|
|
const db = context.db
|
|
let query = 'SELECT * FROM resources WHERE id = $1'
|
|
const params: unknown[] = [id]
|
|
let paramCount = 2
|
|
|
|
// Tenant-aware filtering
|
|
if (context.tenantContext) {
|
|
if (context.tenantContext.isSystemAdmin) {
|
|
// System admins can see all resources
|
|
} else if (context.tenantContext.tenantId) {
|
|
query += ` AND (tenant_id = $${paramCount} OR tenant_id IS NULL)`
|
|
params.push(context.tenantContext.tenantId)
|
|
paramCount++
|
|
} else {
|
|
query += ` AND tenant_id IS NULL`
|
|
}
|
|
} else {
|
|
query += ` AND 1=0`
|
|
}
|
|
|
|
const result = await db.query(query, params)
|
|
|
|
if (result.rows.length === 0) {
|
|
throw AppErrors.notFound('Resource', id)
|
|
}
|
|
|
|
return mapResource(result.rows[0], context)
|
|
}
|
|
|
|
/**
|
|
* Create a new resource
|
|
*
|
|
* @param context - Request context with user and database connection
|
|
* @param input - Resource creation input (name, type, siteId, metadata)
|
|
* @returns Created resource with site information
|
|
* @throws {UnauthenticatedError} If user is not authenticated
|
|
* @throws {QuotaExceededError} If tenant quota limits are exceeded
|
|
* @example
|
|
* ```typescript
|
|
* const resource = await createResource(context, {
|
|
* name: 'My VM',
|
|
* type: 'VM',
|
|
* siteId: 'site-123'
|
|
* });
|
|
* ```
|
|
*/
|
|
export async function createResource(context: Context, input: CreateResourceInput) {
|
|
const db = context.db
|
|
|
|
// Set tenant_id from context if available
|
|
const tenantId = context.tenantContext?.tenantId || null
|
|
|
|
// Enforce tenant quotas if tenant context is available
|
|
if (tenantId) {
|
|
const { tenantService } = await import('./tenant.js')
|
|
|
|
// Calculate resource requirements based on input
|
|
const resourceRequest: {
|
|
compute?: { vcpu?: number; memory?: number; instances?: number }
|
|
storage?: { size?: number }
|
|
network?: { bandwidth?: number }
|
|
} = {}
|
|
|
|
// Extract compute requirements from metadata or input
|
|
const metadata = input.metadata || {}
|
|
if (metadata.cpu || metadata.vcpu) {
|
|
const cpu = typeof metadata.cpu === 'number' ? metadata.cpu : (typeof metadata.vcpu === 'number' ? metadata.vcpu : 1)
|
|
const memoryStr = typeof metadata.memory === 'string' ? metadata.memory : String(metadata.memory || '0')
|
|
resourceRequest.compute = {
|
|
vcpu: cpu,
|
|
memory: parseFloat(memoryStr.replace(/[^0-9.]/g, '')) || 0,
|
|
instances: 1,
|
|
}
|
|
}
|
|
|
|
// Extract storage requirements
|
|
if (metadata.storage || metadata.disk) {
|
|
const storageSize = typeof metadata.storage === 'string'
|
|
? metadata.storage
|
|
: (typeof metadata.disk === 'string' ? metadata.disk : String(metadata.storage || metadata.disk || '0'))
|
|
resourceRequest.storage = {
|
|
size: parseFloat(storageSize.replace(/[^0-9.]/g, '')) || 0,
|
|
}
|
|
}
|
|
|
|
// Enforce quota - will throw error if quota exceeded
|
|
await tenantService.enforceQuota(tenantId, resourceRequest)
|
|
}
|
|
|
|
const result = await db.query(
|
|
`INSERT INTO resources (name, type, status, site_id, tenant_id, metadata)
|
|
VALUES ($1, $2, $3, $4, $5, $6)
|
|
RETURNING *`,
|
|
[input.name, input.type, 'PENDING', input.siteId, tenantId, JSON.stringify(input.metadata || {})]
|
|
)
|
|
|
|
const resource = await mapResource(result.rows[0], context)
|
|
|
|
// Record initial usage for billing (per-second granularity)
|
|
if (tenantId) {
|
|
try {
|
|
const { billingService } = await import('./billing.js')
|
|
const metadata = input.metadata || {}
|
|
|
|
// Calculate initial cost based on resource type and specs
|
|
let initialCost = 0
|
|
if (input.type === 'VM' || input.type === 'CONTAINER') {
|
|
const cpu = typeof metadata.cpu === 'number' ? metadata.cpu : (typeof metadata.vcpu === 'number' ? metadata.vcpu : 1)
|
|
const memory = typeof metadata.memory === 'string'
|
|
? parseFloat(metadata.memory.replace(/[^0-9.]/g, ''))
|
|
: (typeof metadata.memory === 'number' ? metadata.memory : 0)
|
|
// Simplified pricing: $0.01 per vCPU-hour, $0.005 per GB-hour
|
|
initialCost = (cpu * 0.01 + memory * 0.005) / 3600 // Per second
|
|
} else if (input.type === 'STORAGE') {
|
|
const storageSize = typeof metadata.storage === 'string'
|
|
? parseFloat(metadata.storage.replace(/[^0-9.]/g, ''))
|
|
: (typeof metadata.storage === 'number' ? metadata.storage : 0)
|
|
// $0.0001 per GB-hour = per second
|
|
initialCost = (storageSize * 0.0001) / 3600
|
|
}
|
|
|
|
if (initialCost > 0) {
|
|
await billingService.recordUsage({
|
|
tenantId,
|
|
resourceId: resource.id,
|
|
resourceType: input.type,
|
|
metricType: 'PROVISIONING',
|
|
quantity: 1,
|
|
unit: 'instance',
|
|
cost: initialCost,
|
|
currency: 'USD',
|
|
timestamp: new Date(),
|
|
labels: { action: 'create', resourceType: input.type },
|
|
})
|
|
}
|
|
} catch (error) {
|
|
// Log but don't fail resource creation if billing recording fails
|
|
const { logger } = await import('../lib/logger.js')
|
|
logger.warn('Failed to record usage for resource creation', { error, resourceId: resource.id })
|
|
}
|
|
}
|
|
|
|
// Record on blockchain if configured (future implementation)
|
|
// This would integrate with blockchain smart contracts for immutable resource tracking
|
|
if (process.env.BLOCKCHAIN_ENABLED === 'true') {
|
|
try {
|
|
const { blockchainService } = await import('./blockchain.js')
|
|
await blockchainService.initialize()
|
|
// Future: Record resource provisioning on blockchain
|
|
// await blockchainService.recordResourceProvisioning(...)
|
|
} catch (error) {
|
|
// Log but don't fail if blockchain is not configured
|
|
const { logger } = await import('../lib/logger.js')
|
|
logger.warn('Failed to record resource on blockchain', { error, resourceId: resource.id })
|
|
}
|
|
}
|
|
|
|
// Publish subscription event
|
|
const { publishResourceCreated } = await import('../schema/subscriptions')
|
|
publishResourceCreated(resource)
|
|
|
|
return resource
|
|
}
|
|
|
|
/**
|
|
* Update an existing resource
|
|
*
|
|
* @param context - Request context with user and database connection
|
|
* @param id - Resource ID
|
|
* @param input - Resource update input (name, metadata)
|
|
* @returns Updated resource
|
|
* @throws {NotFoundError} If resource not found or user doesn't have access
|
|
* @throws {UnauthenticatedError} If user is not authenticated
|
|
* @example
|
|
* ```typescript
|
|
* const resource = await updateResource(context, 'resource-123', {
|
|
* name: 'Updated Name'
|
|
* });
|
|
* ```
|
|
*/
|
|
export async function updateResource(context: Context, id: string, input: UpdateResourceInput) {
|
|
const db = context.db
|
|
const updates: string[] = []
|
|
const params: unknown[] = []
|
|
let paramCount = 1
|
|
|
|
if (input.name !== undefined) {
|
|
updates.push(`name = $${paramCount}`)
|
|
params.push(input.name)
|
|
paramCount++
|
|
}
|
|
|
|
if (input.metadata !== undefined) {
|
|
updates.push(`metadata = $${paramCount}`)
|
|
params.push(JSON.stringify(input.metadata))
|
|
paramCount++
|
|
}
|
|
|
|
if (updates.length === 0) {
|
|
return getResource(context, id)
|
|
}
|
|
|
|
params.push(id)
|
|
const result = await db.query(
|
|
`UPDATE resources SET ${updates.join(', ')} WHERE id = $${paramCount} RETURNING *`,
|
|
params
|
|
)
|
|
|
|
const resource = await mapResource(result.rows[0], context)
|
|
|
|
// Publish subscription event
|
|
const { publishResourceUpdated } = await import('../schema/subscriptions')
|
|
publishResourceUpdated(id, resource)
|
|
|
|
return resource
|
|
}
|
|
|
|
/**
|
|
* Delete a resource
|
|
*
|
|
* @param context - Request context with user and database connection
|
|
* @param id - Resource ID
|
|
* @returns true if deletion was successful
|
|
* @throws {NotFoundError} If resource not found or user doesn't have access
|
|
* @throws {UnauthenticatedError} If user is not authenticated
|
|
* @example
|
|
* ```typescript
|
|
* await deleteResource(context, 'resource-123');
|
|
* ```
|
|
*/
|
|
export async function deleteResource(context: Context, id: string) {
|
|
const db = context.db
|
|
await db.query('DELETE FROM resources WHERE id = $1', [id])
|
|
|
|
// Publish subscription event
|
|
const { publishResourceDeleted } = await import('../schema/subscriptions')
|
|
publishResourceDeleted(id)
|
|
|
|
return true
|
|
}
|
|
|
|
// Optimized mapping function for joined queries (no additional DB queries)
|
|
function mapResourceWithSite(row: {
|
|
id: string
|
|
name: string
|
|
type: string
|
|
status: string
|
|
site_id: string | null
|
|
tenant_id: string | null
|
|
metadata: string | Record<string, unknown> | null
|
|
created_at: Date
|
|
updated_at: Date
|
|
site_id_full: string | null
|
|
site_name: string | null
|
|
site_region: string | null
|
|
site_status: string | null
|
|
site_metadata: string | Record<string, unknown> | null
|
|
site_created_at: Date | null
|
|
site_updated_at: Date | null
|
|
}) {
|
|
const metadata = typeof row.metadata === 'string'
|
|
? (JSON.parse(row.metadata) as Record<string, unknown>)
|
|
: ((row.metadata as Record<string, unknown>) || {})
|
|
|
|
// Map site from joined data (if available)
|
|
let site = null
|
|
if (row.site_id_full) {
|
|
const siteMetadata = typeof row.site_metadata === 'string'
|
|
? (JSON.parse(row.site_metadata) as Record<string, unknown>)
|
|
: ((row.site_metadata as Record<string, unknown>) || {})
|
|
|
|
site = {
|
|
id: row.site_id_full,
|
|
name: row.site_name || 'Unknown',
|
|
region: row.site_region || '',
|
|
status: row.site_status || 'INACTIVE',
|
|
metadata: siteMetadata,
|
|
createdAt: row.site_created_at || new Date(),
|
|
updatedAt: row.site_updated_at || new Date(),
|
|
}
|
|
} else if (row.site_id) {
|
|
// Site ID exists but site not found (LEFT JOIN returned null)
|
|
site = {
|
|
id: row.site_id,
|
|
name: 'Unknown',
|
|
region: '',
|
|
status: 'INACTIVE',
|
|
metadata: {},
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
}
|
|
}
|
|
|
|
return {
|
|
id: row.id,
|
|
name: row.name,
|
|
type: row.type,
|
|
status: row.status,
|
|
site: site || { id: row.site_id || '', name: 'Unknown', region: '', status: 'INACTIVE' },
|
|
metadata,
|
|
createdAt: row.created_at,
|
|
updatedAt: row.updated_at,
|
|
}
|
|
}
|
|
|
|
async function mapResource(row: ResourceRow, context: Context) {
|
|
// Get site information
|
|
const siteResult = await context.db.query('SELECT * FROM sites WHERE id = $1', [row.site_id])
|
|
const site = siteResult.rows.length > 0 ? mapSite(siteResult.rows[0] as SiteRow) : null
|
|
|
|
const metadata = typeof row.metadata === 'string'
|
|
? (JSON.parse(row.metadata) as Record<string, unknown>)
|
|
: ((row.metadata as Record<string, unknown>) || {})
|
|
|
|
return {
|
|
id: row.id,
|
|
name: row.name,
|
|
type: row.type,
|
|
status: row.status,
|
|
site: site || { id: row.site_id || '', name: 'Unknown', region: '', status: 'INACTIVE' },
|
|
metadata,
|
|
createdAt: row.created_at,
|
|
updatedAt: row.updated_at,
|
|
}
|
|
}
|
|
|
|
function mapSite(row: SiteRow) {
|
|
const metadata = typeof row.metadata === 'string'
|
|
? (JSON.parse(row.metadata) as Record<string, unknown>)
|
|
: ((row.metadata as Record<string, unknown>) || {})
|
|
|
|
return {
|
|
id: row.id,
|
|
name: row.name,
|
|
region: row.region,
|
|
status: row.status,
|
|
metadata,
|
|
createdAt: row.created_at,
|
|
updatedAt: row.updated_at,
|
|
}
|
|
}
|
|
|